-
+
- {t('general.lock.yourWalletIsLocked')}
+ {message ?? t('general.lock.yourWalletIsLocked')}
- {t('general.lock.toUnlockOpenPopUp')}
+ {description ?? t('general.lock.toUnlockOpenPopUp')}
diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/assets/components/AssetsPortfolio/AssetPortfolioContent.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/assets/components/AssetsPortfolio/AssetPortfolioContent.tsx
index dce667592..29aa9a5ec 100644
--- a/apps/browser-extension-wallet/src/views/browser-view/features/assets/components/AssetsPortfolio/AssetPortfolioContent.tsx
+++ b/apps/browser-extension-wallet/src/views/browser-view/features/assets/components/AssetsPortfolio/AssetPortfolioContent.tsx
@@ -17,7 +17,7 @@ const searchTokens = (data: IAssetDetails[], searchValue: string) => {
const lowerSearchValue = searchValue.toLowerCase();
return data.filter((item) =>
- fields.some((field) => field in item && item[field] && item[field].toLowerCase().includes(lowerSearchValue))
+ fields.some((field) => field in item && item[field]?.toLowerCase().includes(lowerSearchValue))
);
};
diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/CustomSubmitApiDrawer.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/CustomSubmitApiDrawer.tsx
index 1acb36a71..ab4f349db 100644
--- a/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/CustomSubmitApiDrawer.tsx
+++ b/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/CustomSubmitApiDrawer.tsx
@@ -13,6 +13,7 @@ import SwitchIcon from '@assets/icons/switch.component.svg';
import ErrorIcon from '@assets/icons/address-error-icon.component.svg';
import PlayIcon from '@assets/icons/play-icon.component.svg';
import PauseIcon from '@assets/icons/pause-icon.component.svg';
+import { config } from '@src/config';
const { Text } = Typography;
@@ -23,7 +24,7 @@ interface CustomSubmitApiDrawerProps {
}
const LEARN_SUBMIT_API_URL = 'https://github.com/IntersectMBO/cardano-node/tree/master/cardano-submit-api';
-const DEFAULT_SUBMIT_API = 'http://localhost:8090/api/submit/tx';
+const { DEFAULT_SUBMIT_API } = config();
export const CustomSubmitApiDrawer = ({
visible,
diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/SettingsLayout.module.scss b/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/SettingsLayout.module.scss
index c014b4cfc..d379ea367 100644
--- a/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/SettingsLayout.module.scss
+++ b/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/SettingsLayout.module.scss
@@ -561,3 +561,15 @@ h5 {
.fullWidth {
width: 100%;
}
+
+.warningIcon {
+ color: var(--lace-yellow) !important;
+ position: relative;
+ top: 7px;
+ margin-right: 6px;
+}
+
+.switchToNamiModalTitle {
+ margin: 0 auto;
+ max-width: 300px;
+}
diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/SettingsLayout.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/SettingsLayout.tsx
index dc5277123..ca1eae801 100644
--- a/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/SettingsLayout.tsx
+++ b/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/SettingsLayout.tsx
@@ -7,6 +7,8 @@ import { SettingsRemoveWallet } from './SettingsRemoveWallet';
import { MidnightPreLaunchSettingsBanner } from '@lace/core';
import { Box } from '@input-output-hk/lace-ui-toolkit';
import MidnightPreLaunchBannerImage from '../../../../../../../../packages/core/src/ui/assets/images/midnight-launch-event-sidebar-banner.png';
+import { SettingsSwitchToNami } from './SettingsSwitchToNami';
+import { usePostHogClientContext } from '@providers/PostHogClientProvider';
export interface SettingsLayoutProps {
defaultPassphraseVisible?: boolean;
@@ -18,6 +20,8 @@ export const SettingsLayout = ({
defaultMnemonic
}: SettingsLayoutProps): React.ReactElement => {
const { t } = useTranslation();
+ const posthog = usePostHogClientContext();
+ const useSwitchToNamiMode = posthog?.isFeatureFlagEnabled('use-switch-to-nami-mode');
const sidePanelContent = (
@@ -39,6 +43,7 @@ export const SettingsLayout = ({
+ {useSwitchToNamiMode && }
diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/SettingsSwitchToNami.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/SettingsSwitchToNami.tsx
new file mode 100644
index 000000000..5fc5e3bce
--- /dev/null
+++ b/apps/browser-extension-wallet/src/views/browser-view/features/settings/components/SettingsSwitchToNami.tsx
@@ -0,0 +1,90 @@
+import React, { useEffect, useState } from 'react';
+import { SettingsCard, SettingsLink } from './';
+import { useTranslation } from 'react-i18next';
+import { Typography } from 'antd';
+import { WarningModal } from '@views/browser/components/WarningModal';
+import { Switch } from '@lace/common';
+import styles from './SettingsLayout.module.scss';
+import { getBackgroundStorage, setBackgroundStorage } from '@lib/scripts/background/storage';
+import { BackgroundStorage } from '@lib/scripts/types';
+import { useAnalyticsContext, useBackgroundServiceAPIContext } from '@providers';
+import { PostHogAction } from '@providers/AnalyticsProvider/analyticsTracker';
+
+const { Title } = Typography;
+
+export const SettingsSwitchToNami = ({ popupView }: { popupView?: boolean }): React.ReactElement => {
+ const { t } = useTranslation();
+ const analytics = useAnalyticsContext();
+ const backgroundServices = useBackgroundServiceAPIContext();
+ const [namiMigration, setNamiMigration] = useState();
+ const [modalOpen, setModalOpen] = useState(false);
+
+ useEffect(() => {
+ getBackgroundStorage()
+ .then((storage) => setNamiMigration(storage.namiMigration))
+ .catch(console.error);
+ }, []);
+
+ const handleNamiModeChange = async (activated: boolean) => {
+ const mode = activated ? 'nami' : 'lace';
+ const migration: BackgroundStorage['namiMigration'] = {
+ ...namiMigration,
+ mode
+ };
+
+ setNamiMigration(migration);
+ backgroundServices.handleChangeMode({ mode });
+ await setBackgroundStorage({
+ namiMigration: migration
+ });
+ setModalOpen(false);
+ if (activated) {
+ await analytics.sendEventToPostHog(PostHogAction.SettingsSwitchToNamiClick);
+ try {
+ await backgroundServices.handleOpenPopup();
+ } catch (error) {
+ // improve logging
+ console.warn(error);
+ }
+ } else {
+ window.location.reload();
+ }
+ };
+
+ return (
+ <>
+ {t('browserView.settings.legacyMode.confirmation.title')}
+ }
+ content={t('browserView.settings.legacyMode.confirmation.description')}
+ visible={modalOpen}
+ onCancel={() => setModalOpen(false)}
+ onConfirm={() => handleNamiModeChange(true)}
+ cancelLabel={t('browserView.settings.legacyMode.confirmation.cancel')}
+ confirmLabel={t('browserView.settings.legacyMode.confirmation.confirm')}
+ confirmCustomClassName={styles.settingsConfirmButton}
+ isPopupView={popupView}
+ />
+
+
+ {t('browserView.settings.legacyMode.section')}
+
+ (checked ? setModalOpen(true) : handleNamiModeChange(false))}
+ className={styles.analyticsSwitch}
+ />
+ }
+ data-testid="settings-nami-mode-section"
+ >
+ Nami layout
+
+
+ >
+ );
+};
diff --git a/apps/browser-extension-wallet/src/views/browser-view/features/staking/components/StakePoolDetails/StakePoolConfirmation.tsx b/apps/browser-extension-wallet/src/views/browser-view/features/staking/components/StakePoolDetails/StakePoolConfirmation.tsx
index 88fd8d2a5..bb8593f13 100644
--- a/apps/browser-extension-wallet/src/views/browser-view/features/staking/components/StakePoolDetails/StakePoolConfirmation.tsx
+++ b/apps/browser-extension-wallet/src/views/browser-view/features/staking/components/StakePoolDetails/StakePoolConfirmation.tsx
@@ -1,7 +1,7 @@
/* eslint-disable complexity */
/* eslint-disable react/no-multi-comp */
/* eslint-disable unicorn/no-nested-ternary */
-import React, { useCallback, useMemo, useState } from 'react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
import cn from 'classnames';
import isNil from 'lodash/isNil';
import { Skeleton } from 'antd';
@@ -52,7 +52,11 @@ export const StakePoolConfirmation = ({ popupView }: StakePoolConfirmationProps)
const { balance } = useBalances(priceResult?.cardano?.price);
const { delegationTxFee } = useDelegationStore();
- useBuildDelegation();
+ const { buildDelegation } = useBuildDelegation();
+
+ useEffect(() => {
+ buildDelegation();
+ }, [buildDelegation]);
const {
logo: poolLogo,
diff --git a/apps/browser-extension-wallet/src/views/browser-view/index.tsx b/apps/browser-extension-wallet/src/views/browser-view/index.tsx
index b64d6f7b6..fdf7a6afe 100644
--- a/apps/browser-extension-wallet/src/views/browser-view/index.tsx
+++ b/apps/browser-extension-wallet/src/views/browser-view/index.tsx
@@ -28,6 +28,7 @@ import '../../lib/scripts/keep-alive-ui';
import { PostHogClientProvider } from '@providers/PostHogClientProvider';
import { ExperimentsProvider } from '@providers/ExperimentsProvider/context';
import { AddressesDiscoveryOverlay } from 'components/AddressesDiscoveryOverlay';
+import { NamiMigrationGuard } from '@src/features/nami-migration/NamiMigrationGuard';
const App = (): React.ReactElement => (
@@ -46,7 +47,9 @@ const App = (): React.ReactElement => (
-
+
+
+
diff --git a/apps/browser-extension-wallet/src/views/browser-view/routes/index.tsx b/apps/browser-extension-wallet/src/views/browser-view/routes/index.tsx
index a5beacf2c..e3b076c6b 100644
--- a/apps/browser-extension-wallet/src/views/browser-view/routes/index.tsx
+++ b/apps/browser-extension-wallet/src/views/browser-view/routes/index.tsx
@@ -30,6 +30,11 @@ import { SharedWallet } from '@views/browser/features/shared-wallet';
import { MultiAddressBalanceVisibleModal } from '@views/browser/features/multi-address';
import { useExperimentsContext } from '@providers/ExperimentsProvider';
import { SignMessageDrawer } from '@views/browser/features/sign-message/SignMessageDrawer';
+import warningIcon from '@src/assets/icons/browser-view/warning-icon.svg';
+import { useBackgroundServiceAPIContext } from '@providers';
+import { BackgroundStorage, Message, MessageTypes } from '@lib/scripts/types';
+import { getBackgroundStorage } from '@lib/scripts/background/storage';
+import { useTranslation } from 'react-i18next';
export const defaultRoutes: RouteMap = [
{
@@ -110,10 +115,33 @@ export const BrowserViewRoutes = ({ routesMap = defaultRoutes }: { routesMap?: R
const { areExperimentsLoading } = useExperimentsContext();
const [isLoadingWalletInfo, setIsLoadingWalletInfo] = useState(true);
const { page, setBackgroundPage } = useBackgroundPage();
+ const { t } = useTranslation();
const location = useLocation<{ background?: Location }>();
const currentRoutes = isSharedWallet ? routesMap.filter((route) => route.path !== routes.staking) : routesMap;
+ const backgroundServices = useBackgroundServiceAPIContext();
+ const [namiMigration, setNamiMigration] = useState();
+
+ useEffect(() => {
+ getBackgroundStorage()
+ .then((storage) => setNamiMigration(storage.namiMigration))
+ .catch(console.error);
+ }, []);
+
+ useEffect(() => {
+ const subscription = backgroundServices.requestMessage$?.subscribe(({ type, data }: Message): void => {
+ if (type === MessageTypes.CHANGE_MODE && data.mode !== namiMigration?.mode) {
+ const migration: BackgroundStorage['namiMigration'] = {
+ ...namiMigration,
+ mode: data.mode
+ };
+
+ setNamiMigration(migration);
+ }
+ });
+ return () => subscription.unsubscribe();
+ }, [backgroundServices, namiMigration]);
useEffect(() => {
const isCreatingWallet = [routes.newWallet.root, routes.sharedWallet.root].some((path) =>
@@ -165,6 +193,16 @@ export const BrowserViewRoutes = ({ routesMap = defaultRoutes }: { routesMap?: R
};
});
+ if (namiMigration?.mode === 'nami' && !isLoadingWalletInfo && cardanoWallet) {
+ return (
+
+ );
+ }
+
if (isWalletLocked()) {
return (
diff --git a/apps/browser-extension-wallet/src/views/nami-mode/NamiView.tsx b/apps/browser-extension-wallet/src/views/nami-mode/NamiView.tsx
new file mode 100644
index 000000000..0c05b646a
--- /dev/null
+++ b/apps/browser-extension-wallet/src/views/nami-mode/NamiView.tsx
@@ -0,0 +1,168 @@
+/* eslint-disable max-statements */
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { Main as Nami, OutsideHandlesProvider } from '@lace/nami';
+import { useWalletStore } from '@src/stores';
+import { config } from '@src/config';
+import { useBackgroundServiceAPIContext, useCurrencyStore, useExternalLinkOpener, useTheme } from '@providers';
+import {
+ useCustomSubmitApi,
+ useWalletAvatar,
+ useCollateral,
+ useFetchCoinPrice,
+ useWalletManager,
+ useBuildDelegation,
+ useBalances
+} from '@hooks';
+import { walletManager, withSignTxConfirmation } from '@lib/wallet-api-ui';
+import { useAnalytics } from './hooks';
+import { useDappContext, withDappContext } from '@src/features/dapp/context';
+import { localDappService } from '../browser-view/features/dapp/components/DappList/localDappService';
+import { isValidURL } from '@src/utils/is-valid-url';
+import { CARDANO_COIN_SYMBOL } from './constants';
+import { useDelegationTransaction } from '../browser-view/features/staking/hooks';
+import { useSecrets } from '@lace/core';
+import { useDelegationStore } from '@src/features/delegation/stores';
+import { useStakePoolDetails } from '@src/features/stake-pool-details/store';
+import { getPoolInfos } from '@src/stores/slices';
+import { Wallet } from '@lace/cardano';
+import { walletBalanceTransformer } from '@src/api/transformers';
+import { useObservable } from '@lace/common';
+import { getBackgroundStorage, setBackgroundStorage } from '@lib/scripts/background/storage';
+import { BackgroundStorage } from '@lib/scripts/types';
+
+const { AVAILABLE_CHAINS, DEFAULT_SUBMIT_API } = config();
+
+export const NamiView = withDappContext((): React.ReactElement => {
+ const { setFiatCurrency, fiatCurrency } = useCurrencyStore();
+ const { priceResult } = useFetchCoinPrice();
+ const [namiMigration, setNamiMigration] = useState();
+ const backgroundServices = useBackgroundServiceAPIContext();
+ const { createWallet, getMnemonic, deleteWallet, switchNetwork, enableCustomNode, addAccount, walletRepository } =
+ useWalletManager();
+ const {
+ walletUI,
+ inMemoryWallet,
+ walletInfo,
+ currentChain,
+ environmentName,
+ blockchainProvider: { stakePoolProvider }
+ } = useWalletStore();
+ const { theme, setTheme } = useTheme();
+ const { handleAnalyticsChoice, isAnalyticsOptIn, sendEventToPostHog } = useAnalytics();
+ const connectedDapps = useDappContext();
+ const removeDapp = useCallback((origin: string) => localDappService.removeAuthorizedDapp(origin), []);
+ const { getCustomSubmitApiForNetwork } = useCustomSubmitApi();
+ const cardanoCoin = useMemo(
+ () => ({
+ ...walletUI.cardanoCoin,
+ symbol: CARDANO_COIN_SYMBOL[currentChain.networkId]
+ }),
+ [currentChain.networkId, walletUI.cardanoCoin]
+ );
+ const { txFee, isInitializing, initializeCollateralTx, submitCollateralTx } = useCollateral();
+
+ const cardanoPrice = priceResult.cardano.price;
+ const walletAddress = walletInfo?.addresses[0].address.toString();
+ const { setAvatar } = useWalletAvatar();
+ const { delegationTxFee, setDelegationTxFee, setSelectedStakePool, setDelegationTxBuilder, delegationTxBuilder } =
+ useDelegationStore();
+ const { buildDelegation } = useBuildDelegation();
+ const { signAndSubmitTransaction } = useDelegationTransaction();
+ const { isBuildingTx, stakingError, setIsBuildingTx } = useStakePoolDetails();
+ const passwordUtil = useSecrets();
+ const getStakePoolInfo = useCallback(
+ (id: Wallet.Cardano.PoolId) => getPoolInfos([id], stakePoolProvider),
+ [stakePoolProvider]
+ );
+
+ const resetDelegationState = useCallback(() => {
+ passwordUtil.clearSecrets();
+ setDelegationTxFee();
+ setDelegationTxBuilder();
+ setIsBuildingTx(false);
+ }, [passwordUtil, setDelegationTxBuilder, setDelegationTxFee, setIsBuildingTx]);
+
+ const rewardAccounts = useObservable(inMemoryWallet.delegation.rewardAccounts$);
+ const protocolParameters = useObservable(inMemoryWallet?.protocolParameters$);
+ const isStakeRegistered =
+ rewardAccounts && rewardAccounts[0].credentialStatus === Wallet.Cardano.StakeCredentialStatus.Registered;
+ const { balance } = useBalances(priceResult?.cardano?.price);
+ const { coinBalance: minAda } = walletBalanceTransformer(protocolParameters?.stakeKeyDeposit.toString());
+ const coinBalance = balance?.total?.coinBalance && Number(balance?.total?.coinBalance);
+ const hasNoFunds = (coinBalance < Number(minAda) && !isStakeRegistered) || (coinBalance === 0 && isStakeRegistered);
+ const openExternalLink = useExternalLinkOpener();
+
+ useEffect(() => {
+ getBackgroundStorage()
+ .then((storage) => setNamiMigration(storage.namiMigration))
+ .catch(console.error);
+ }, []);
+
+ const switchWalletMode = async () => {
+ const mode = namiMigration?.mode === 'lace' ? 'nami' : 'lace';
+ const migration: BackgroundStorage['namiMigration'] = {
+ ...namiMigration,
+ mode
+ };
+
+ setNamiMigration(migration);
+ backgroundServices.handleChangeMode({ mode });
+ await setBackgroundStorage({
+ namiMigration: migration
+ });
+ };
+
+ return (
+
+
+
+ );
+});
diff --git a/apps/browser-extension-wallet/src/views/nami-mode/constants.ts b/apps/browser-extension-wallet/src/views/nami-mode/constants.ts
new file mode 100644
index 000000000..cff9c4424
--- /dev/null
+++ b/apps/browser-extension-wallet/src/views/nami-mode/constants.ts
@@ -0,0 +1,8 @@
+import { Wallet } from '@lace/cardano';
+
+export type ADASymbols = '₳' | 't₳';
+
+export const CARDANO_COIN_SYMBOL: { [key in Wallet.Cardano.NetworkId]: ADASymbols } = {
+ [Wallet.Cardano.NetworkId.Mainnet]: '₳',
+ [Wallet.Cardano.NetworkId.Testnet]: 't₳'
+};
diff --git a/apps/browser-extension-wallet/src/views/nami-mode/hooks.ts b/apps/browser-extension-wallet/src/views/nami-mode/hooks.ts
new file mode 100644
index 000000000..80c543fe0
--- /dev/null
+++ b/apps/browser-extension-wallet/src/views/nami-mode/hooks.ts
@@ -0,0 +1,35 @@
+import { useCallback } from 'react';
+import { useLocalStorage } from '@hooks';
+import { useAnalyticsContext } from '@providers';
+import { Action, EnhancedAnalyticsOptInStatus } from '@providers/AnalyticsProvider/analyticsTracker';
+import { ENHANCED_ANALYTICS_OPT_IN_STATUS_LS_KEY } from '@providers/AnalyticsProvider/config';
+
+export const useAnalytics = (): {
+ isAnalyticsOptIn: boolean;
+ sendEventToPostHog: (action: Action) => Promise;
+ handleAnalyticsChoice: (isOptedIn: boolean) => Promise;
+} => {
+ const analytics = useAnalyticsContext();
+ const sendEventToPostHog = useCallback((action: Action) => analytics.sendEventToPostHog(action), [analytics]);
+ const [analyticsStatus, { updateLocalStorage: setEnhancedAnalyticsOptInStatus }] = useLocalStorage(
+ ENHANCED_ANALYTICS_OPT_IN_STATUS_LS_KEY,
+ EnhancedAnalyticsOptInStatus.OptedOut
+ );
+ const isAnalyticsOptIn = analyticsStatus === EnhancedAnalyticsOptInStatus.OptedIn;
+ const handleAnalyticsChoice = useCallback(
+ async (isOptedIn: boolean) => {
+ const status = isOptedIn ? EnhancedAnalyticsOptInStatus.OptedIn : EnhancedAnalyticsOptInStatus.OptedOut;
+
+ if (isOptedIn) {
+ await analytics.setOptedInForEnhancedAnalytics(status);
+ await analytics.sendAliasEvent();
+ } else {
+ await analytics.setOptedInForEnhancedAnalytics(status);
+ }
+ setEnhancedAnalyticsOptInStatus(status);
+ },
+ [analytics, setEnhancedAnalyticsOptInStatus]
+ );
+
+ return { isAnalyticsOptIn, sendEventToPostHog, handleAnalyticsChoice };
+};
diff --git a/apps/browser-extension-wallet/src/views/nami-mode/index.scss b/apps/browser-extension-wallet/src/views/nami-mode/index.scss
new file mode 100644
index 000000000..2ebc045cb
--- /dev/null
+++ b/apps/browser-extension-wallet/src/views/nami-mode/index.scss
@@ -0,0 +1,25 @@
+#nami-mode {
+ width: 100%;
+ height: 100%;
+ font-family: sans-serif;
+}
+
+html[data-theme='dark'] {
+ body:has(#nami-mode) {
+ *::selection {
+ background: #375479;
+ background-color: #375479;
+ color: #f8fbfb;
+ }
+ }
+}
+
+body:has(#nami-mode) {
+ font-family: sans-serif !important;
+
+ *::selection {
+ background: #b5d7ff;
+ background-color: #b5d7ff;
+ color: #19202b;
+ }
+}
diff --git a/apps/browser-extension-wallet/src/views/nami-mode/index.tsx b/apps/browser-extension-wallet/src/views/nami-mode/index.tsx
new file mode 100644
index 000000000..03eb6fdef
--- /dev/null
+++ b/apps/browser-extension-wallet/src/views/nami-mode/index.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import { useWalletStore } from '@src/stores';
+import { useAppInit } from '@hooks';
+import { MainLoader } from '@components/MainLoader';
+import { withDappContext } from '@src/features/dapp/context';
+import { NamiView } from './NamiView';
+import '../../lib/scripts/keep-alive-ui';
+import './index.scss';
+
+export const NamiPopup = withDappContext((): React.ReactElement => {
+ const { inMemoryWallet, walletInfo, cardanoWallet, walletState, initialHdDiscoveryCompleted, currentChain } =
+ useWalletStore();
+
+ useAppInit();
+
+ return (
+
+ {!!cardanoWallet && walletInfo && walletState && inMemoryWallet && initialHdDiscoveryCompleted && currentChain ? (
+
+ ) : (
+
+ )}
+
+ );
+});
diff --git a/apps/browser-extension-wallet/test/jest.config.js b/apps/browser-extension-wallet/test/jest.config.js
index 26402d83c..a888681f1 100644
--- a/apps/browser-extension-wallet/test/jest.config.js
+++ b/apps/browser-extension-wallet/test/jest.config.js
@@ -31,5 +31,6 @@ module.exports = createJestConfig({
'/test/__mocks__/ResizeObserver.js',
'/test/helpers/assertions.js'
],
- setupFilesAfterEnv: ['./test/jest.setup.js', 'jest-canvas-mock']
+ setupFilesAfterEnv: ['./test/jest.setup.js', 'jest-canvas-mock'],
+ testPathIgnorePatterns: ['.*\\.fixture\\.ts$']
});
diff --git a/apps/browser-extension-wallet/test/jest.setup.js b/apps/browser-extension-wallet/test/jest.setup.js
index bc991d98f..1d8b3e74d 100644
--- a/apps/browser-extension-wallet/test/jest.setup.js
+++ b/apps/browser-extension-wallet/test/jest.setup.js
@@ -12,6 +12,9 @@ Object.defineProperty(window, 'matchMedia', {
}))
});
+if (!chrome.runtime) chrome.runtime = {};
+if (!chrome.runtime.id) chrome.runtime.id = 'history-delete';
+
// globally mock, unmock in the specific file test
jest.mock('@src/utils/pgp', () => {});
diff --git a/apps/browser-extension-wallet/webpack.common.app.js b/apps/browser-extension-wallet/webpack.common.app.js
index 9f1742c57..439e829ca 100644
--- a/apps/browser-extension-wallet/webpack.common.app.js
+++ b/apps/browser-extension-wallet/webpack.common.app.js
@@ -69,7 +69,7 @@ module.exports = () =>
]
},
{
- test: /\.(eot|otf|ttf|woff|woff2|gif|png|webm)$/,
+ test: /\.(eot|otf|ttf|woff|woff2|gif|png|webm|mp4)$/,
loader: 'file-loader'
},
{
@@ -82,7 +82,8 @@ module.exports = () =>
new CopyPlugin({
patterns: [
{ from: 'src/assets/branding/*.png', to: '../[name][ext]' },
- { from: 'src/assets/html/trezor-usb-permissions.html', to: '../[name][ext]' }
+ { from: 'src/assets/html/trezor-usb-permissions.html', to: '../[name][ext]' },
+ { from: path.resolve(__dirname, '../../packages/nami/dist/assets/video/*.mp4'), to: '../[name][ext]' }
]
}),
new HtmlWebpackPlugin({
diff --git a/apps/browser-extension-wallet/xsy-nami-migration-tool-0.0.39.tgz b/apps/browser-extension-wallet/xsy-nami-migration-tool-0.0.39.tgz
new file mode 100644
index 000000000..ab46fcdb0
Binary files /dev/null and b/apps/browser-extension-wallet/xsy-nami-migration-tool-0.0.39.tgz differ
diff --git a/commitlint.config.js b/commitlint.config.js
index d99b7d75c..97b9a9bc9 100644
--- a/commitlint.config.js
+++ b/commitlint.config.js
@@ -32,6 +32,8 @@ module.exports = {
'extension',
'staking',
'ui',
+ 'nami',
+ 'translation',
// SRE
'monitoring',
'security'
diff --git a/package.json b/package.json
index f4bf46a5b..062dad8fc 100644
--- a/package.json
+++ b/package.json
@@ -47,10 +47,14 @@
"watch-deps": "EXCLUDE_APP=true yarn watch"
},
"lint-staged": {
- "*(apps/**/*.{js,ts,tsx}|packages/!(translation)/**/*.{js,ts,tsx}|stories/**/*.{js,ts,tsx})": [
- "eslint --cache --cache-location .cache/eslintcache --cache-strategy metadata --fix --ignore-path ./.eslintignore --max-warnings=0",
+ "*(apps/**/*.{js,ts,tsx}|packages/!(translation|nami)/**/*.{js,ts,tsx}|stories/**/*.{js,ts,tsx})": [
+ "eslint --cache --cache-location .cache/eslintcache --cache-strategy metadata --fix --ignore-path ./.eslintignore",
"prettier --write"
],
+ "*(packages/nami/**/*.{js,ts,tsx})": [
+ "yarn workspace @lace/nami lint --fix",
+ "yarn workspace @lace/nami format"
+ ],
"*(packages/translation/**/*.{js,ts,tsx})": [
"yarn workspace @lace/translation lint --fix",
"yarn workspace @lace/translation format"
diff --git a/packages/cardano/package.json b/packages/cardano/package.json
index cb8c39490..84524a56f 100644
--- a/packages/cardano/package.json
+++ b/packages/cardano/package.json
@@ -68,11 +68,12 @@
"react": "17.0.2",
"react-dom": "17.0.2",
"rxjs": "7.4.0",
- "webextension-polyfill": "0.8.0"
+ "webextension-polyfill": "0.10.0"
},
"devDependencies": {
"@cardano-sdk/util-dev": "0.22.10",
"@emurgo/cardano-message-signing-browser": "1.0.1",
+ "@types/webextension-polyfill": "0.10.0",
"axios": "^1.7.4",
"rollup-plugin-polyfill-node": "^0.8.0",
"typescript": "^4.9.5"
diff --git a/packages/cardano/src/wallet/lib/cardano-wallet.ts b/packages/cardano/src/wallet/lib/cardano-wallet.ts
index 2ea67bbed..dd4737777 100644
--- a/packages/cardano/src/wallet/lib/cardano-wallet.ts
+++ b/packages/cardano/src/wallet/lib/cardano-wallet.ts
@@ -34,6 +34,12 @@ export interface WalletMetadata {
export interface AccountMetadata {
name: string;
+ namiMode?: {
+ avatar: string;
+ balance?: string;
+ address?: string;
+ recentSendToAddress?: string;
+ };
}
export interface CardanoWallet {
diff --git a/packages/common/src/analytics/types.ts b/packages/common/src/analytics/types.ts
index bb54a611b..b3d228e89 100644
--- a/packages/common/src/analytics/types.ts
+++ b/packages/common/src/analytics/types.ts
@@ -222,6 +222,7 @@ export enum PostHogAction {
SettingsPaperWalletPasswordNextClick = 'settings | paper wallet - generate pdf | password | click',
SettingsPaperWalletDownloadClick = 'settings | paper wallet - generate pdf | download | click',
SettingsPaperWalletPrintClick = 'settings | paper wallet - generate pdf | print | click',
+ SettingsSwitchToNamiClick = 'nami mode | switch to nami mode | click',
// Recieve section
ReceiveClick = 'receive | receive | click',
ReceiveCopyAddressIconClick = 'receive | receive | copy address icon | click',
diff --git a/packages/common/src/ui/components/Timeline/Timeline.module.scss b/packages/common/src/ui/components/Timeline/Timeline.module.scss
index cbace1b62..1d7cb0c51 100644
--- a/packages/common/src/ui/components/Timeline/Timeline.module.scss
+++ b/packages/common/src/ui/components/Timeline/Timeline.module.scss
@@ -1,5 +1,5 @@
@import '../../styles/theme.scss';
-@import "../../styles/abstracts/typography";
+@import '../../styles/abstracts/typography';
.sideTimeline {
margin: size_unit(7) 0 size_unit(7) size_unit(5) !important;
@@ -16,7 +16,7 @@
.ant-timeline-item-head {
width: size_unit(2.5);
height: size_unit(2.5);
- background: var(--bg-color-container);
+ background: transparent;
}
.ant-timeline-item-tail {
left: 0;
@@ -40,6 +40,5 @@
.inactiveDot {
@extend .dot;
border: 2px solid var(--light-mode-light-grey-plus, var(--dark-mode-mid-grey));
- background-color: var(--light-mode-light-grey-plus, var(--dark-mode-mid-grey));
+ background-color: var(--light-mode-light-grey-plus, var(--dark-mode-light-grey));
}
-
diff --git a/packages/core/package.json b/packages/core/package.json
index 4f38928ef..c72984bd8 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -70,6 +70,7 @@
"@babel/preset-env": "^7.22.20",
"@babel/preset-react": "^7.22.15",
"@babel/preset-typescript": "^7.22.15",
+ "@rollup/plugin-url": "^8.0.2",
"@storybook/addon-actions": "^6.5.16",
"@storybook/addon-essentials": "^6.5.16",
"@storybook/addon-interactions": "^6.5.16",
diff --git a/packages/core/rollup.config.js b/packages/core/rollup.config.js
index c89fb16ca..0e08a791b 100644
--- a/packages/core/rollup.config.js
+++ b/packages/core/rollup.config.js
@@ -2,6 +2,7 @@ import rollupBase from '../../rollup.config.js';
import packageJson from './package.json';
import copy from 'rollup-plugin-copy';
import json from '@rollup/plugin-json';
+import url from '@rollup/plugin-url';
export default (args) => {
const baseConfig = rollupBase(args);
@@ -25,7 +26,11 @@ export default (args) => {
copy({
targets: [{ src: 'src/ui/lib/translations/en.json', dest: 'dist/translations/' }]
}),
- json()
+ json(),
+ url({
+ limit: 0,
+ include: ['**/*.mp4']
+ })
]
};
};
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 02722917f..533fb2948 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -49,3 +49,5 @@ export * from '@ui/components/Transaction';
export * from '@ui/components/PaperWallet';
export * from '@ui/components/Password';
export * from '@ui/components/PasswordVerification';
+export * from '@ui/components/NamiMigrationUpdatingYourWallet';
+export * from '@ui/components/NamiMigration';
diff --git a/packages/core/src/ui/components/NamiMigration/AllDone/AllDone.stories.ts b/packages/core/src/ui/components/NamiMigration/AllDone/AllDone.stories.ts
new file mode 100644
index 000000000..2abbb8469
--- /dev/null
+++ b/packages/core/src/ui/components/NamiMigration/AllDone/AllDone.stories.ts
@@ -0,0 +1,22 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { AllDone } from './AllDone';
+
+const meta: Meta = {
+ title: 'Nami Migration/All Done',
+ component: AllDone,
+ parameters: {
+ layout: 'centered'
+ }
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Overview: Story = {
+ parameters: {
+ decorators: {
+ layout: 'vertical'
+ }
+ }
+};
diff --git a/packages/core/src/ui/components/NamiMigration/AllDone/AllDone.tsx b/packages/core/src/ui/components/NamiMigration/AllDone/AllDone.tsx
new file mode 100644
index 000000000..ef5e61c87
--- /dev/null
+++ b/packages/core/src/ui/components/NamiMigration/AllDone/AllDone.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { Flex, Box, Button, Message } from '@input-output-hk/lace-ui-toolkit';
+
+import { Wizard } from '../Wizard';
+
+interface Props {
+ onClose: () => void;
+}
+
+export const AllDone = ({ onClose }: Props): JSX.Element => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/core/src/ui/components/NamiMigration/AllDone/index.ts b/packages/core/src/ui/components/NamiMigration/AllDone/index.ts
new file mode 100644
index 000000000..1b039a61b
--- /dev/null
+++ b/packages/core/src/ui/components/NamiMigration/AllDone/index.ts
@@ -0,0 +1 @@
+export { AllDone } from './AllDone';
diff --git a/packages/core/src/ui/components/NamiMigration/Customize/Customize.stories.ts b/packages/core/src/ui/components/NamiMigration/Customize/Customize.stories.ts
new file mode 100644
index 000000000..0aba0e343
--- /dev/null
+++ b/packages/core/src/ui/components/NamiMigration/Customize/Customize.stories.ts
@@ -0,0 +1,33 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import nami from './nami.mp4';
+import lace from './lace.mp4';
+
+import { Customize } from './Customize';
+
+const meta: Meta = {
+ title: 'Nami Migration/Customise your wallet',
+ component: Customize,
+ parameters: {
+ layout: 'centered'
+ }
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Overview: Story = {
+ args: {
+ videosURL: {
+ lace,
+ nami
+ },
+ onBack: (): void => void 0,
+ onDone: (): void => void 0,
+ onChange: (): void => void 0
+ },
+ parameters: {
+ decorators: {
+ layout: 'vertical'
+ }
+ }
+};
diff --git a/packages/core/src/ui/components/NamiMigration/Customize/Customize.tsx b/packages/core/src/ui/components/NamiMigration/Customize/Customize.tsx
new file mode 100644
index 000000000..fd46625a2
--- /dev/null
+++ b/packages/core/src/ui/components/NamiMigration/Customize/Customize.tsx
@@ -0,0 +1,74 @@
+import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Flex, Text, Box, Button, ToggleButtonGroup } from '@input-output-hk/lace-ui-toolkit';
+
+import { Wizard } from '../Wizard';
+
+interface Props {
+ onDone: (mode: Mode) => void;
+ onBack: () => void;
+ videosURL: {
+ lace: string;
+ nami: string;
+ };
+ onChange?: (mode: Mode) => void;
+}
+
+type Mode = 'lace' | 'nami';
+
+const noop = (): void => void 0;
+
+export const Customize = ({ onDone, onBack, videosURL, onChange = noop }: Props): JSX.Element => {
+ const { t } = useTranslation();
+ const [mode, setMode] = useState('lace');
+
+ const handleModeChange = (value: Mode) => {
+ setMode(value);
+ onChange(value);
+ };
+
+ return (
+
+
+
+
+ {t('core.namiMigration.customize.title')}
+
+
+
+ {t('core.namiMigration.customize.description')}
+
+
+
+ Lace
+ Nami
+
+
+
+ {mode === 'lace' ? (
+ <>
+
+
+ {t('core.namiMigration.customize.lace')}
+
+ >
+ ) : (
+ <>
+
+
+ {t('core.namiMigration.customize.nami')}
+
+ >
+ )}
+
+
+
+
+ onDone(mode)}
+ />
+
+
+ );
+};
diff --git a/packages/core/src/ui/components/NamiMigration/Customize/index.ts b/packages/core/src/ui/components/NamiMigration/Customize/index.ts
new file mode 100644
index 000000000..e6b1a5a84
--- /dev/null
+++ b/packages/core/src/ui/components/NamiMigration/Customize/index.ts
@@ -0,0 +1 @@
+export { Customize } from './Customize';
diff --git a/packages/core/src/ui/components/NamiMigration/Customize/lace.mp4 b/packages/core/src/ui/components/NamiMigration/Customize/lace.mp4
new file mode 100644
index 000000000..00c0aaf60
Binary files /dev/null and b/packages/core/src/ui/components/NamiMigration/Customize/lace.mp4 differ
diff --git a/packages/core/src/ui/components/NamiMigration/Customize/nami.mp4 b/packages/core/src/ui/components/NamiMigration/Customize/nami.mp4
new file mode 100644
index 000000000..c1ed4c63c
Binary files /dev/null and b/packages/core/src/ui/components/NamiMigration/Customize/nami.mp4 differ
diff --git a/packages/core/src/ui/components/NamiMigration/Welcome/WalletImg.module.scss b/packages/core/src/ui/components/NamiMigration/Welcome/WalletImg.module.scss
new file mode 100644
index 000000000..4101992ab
--- /dev/null
+++ b/packages/core/src/ui/components/NamiMigration/Welcome/WalletImg.module.scss
@@ -0,0 +1,9 @@
+.container {
+ position: relative;
+}
+
+.icon {
+ position: absolute;
+ bottom: -2px;
+ right: -2px;
+}
diff --git a/packages/core/src/ui/components/NamiMigration/Welcome/WalletImg.tsx b/packages/core/src/ui/components/NamiMigration/Welcome/WalletImg.tsx
new file mode 100644
index 000000000..99b0343d4
--- /dev/null
+++ b/packages/core/src/ui/components/NamiMigration/Welcome/WalletImg.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import { sx, Flex, Card } from '@input-output-hk/lace-ui-toolkit';
+import cx from 'classnames';
+import styles from './WalletImg.module.scss';
+
+interface Props {
+ img: string;
+ icon: React.ReactNode;
+ color: 'error' | 'success';
+}
+
+export const WalletImg = ({ img, icon, color }: Props): JSX.Element => (
+
+
+
+ {icon}
+
+
+);
diff --git a/packages/core/src/ui/components/NamiMigration/Welcome/Welcome.stories.ts b/packages/core/src/ui/components/NamiMigration/Welcome/Welcome.stories.ts
new file mode 100644
index 000000000..5f855ef68
--- /dev/null
+++ b/packages/core/src/ui/components/NamiMigration/Welcome/Welcome.stories.ts
@@ -0,0 +1,22 @@
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { Welcome } from './Welcome';
+
+const meta: Meta = {
+ title: 'Nami Migration/Welcome',
+ component: Welcome,
+ parameters: {
+ layout: 'centered'
+ }
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Overview: Story = {
+ parameters: {
+ decorators: {
+ layout: 'vertical'
+ }
+ }
+};
diff --git a/packages/core/src/ui/components/NamiMigration/Welcome/Welcome.tsx b/packages/core/src/ui/components/NamiMigration/Welcome/Welcome.tsx
new file mode 100644
index 000000000..02f7175e3
--- /dev/null
+++ b/packages/core/src/ui/components/NamiMigration/Welcome/Welcome.tsx
@@ -0,0 +1,92 @@
+import React from 'react';
+import { Trans, useTranslation } from 'react-i18next';
+import {
+ Flex,
+ Text,
+ Box,
+ Button,
+ ThemeColorScheme,
+ useTheme,
+ CloseComponent,
+ CheckComponent
+} from '@input-output-hk/lace-ui-toolkit';
+import NamiImg from './nami.png';
+import LaceImg from './lace.png';
+import LaceDarkImg from './lace-dark.png';
+import ArrowImg from './arrow-right.png';
+import { WalletImg } from './WalletImg';
+import { Wizard } from '../Wizard';
+
+interface Props {
+ termsOfServiceUrl: string;
+ privacyPolicyUrl: string;
+ faqUrl: string;
+ colorScheme?: ThemeColorScheme;
+ onNext: () => void;
+}
+
+export const Welcome = ({ termsOfServiceUrl, privacyPolicyUrl, faqUrl, colorScheme, onNext }: Props): JSX.Element => {
+ const { t } = useTranslation();
+
+ const theme = useTheme();
+ const isLight = colorScheme ? colorScheme === 'light' : theme.colorScheme === 'light';
+
+ return (
+
+
+
+
+ {t('core.namiMigration.welcome')}
+
+
+
+
+ } color="error" />
+
+
+
+
+
+ } color="success" />
+
+
+
+ {t('core.namiMigration.description.1')}
+
+ {t('core.namiMigration.description.2')}
+
+
+
+ }}
+ />
+
+
+
+
+ ),
+ a2: (
+
+ )
+ }}
+ />
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/core/src/ui/components/NamiMigration/Welcome/arrow-right.png b/packages/core/src/ui/components/NamiMigration/Welcome/arrow-right.png
new file mode 100644
index 000000000..684b7f976
Binary files /dev/null and b/packages/core/src/ui/components/NamiMigration/Welcome/arrow-right.png differ
diff --git a/packages/core/src/ui/components/NamiMigration/Welcome/index.ts b/packages/core/src/ui/components/NamiMigration/Welcome/index.ts
new file mode 100644
index 000000000..e45cb737b
--- /dev/null
+++ b/packages/core/src/ui/components/NamiMigration/Welcome/index.ts
@@ -0,0 +1 @@
+export { Welcome } from './Welcome';
diff --git a/packages/core/src/ui/components/NamiMigration/Welcome/lace-dark.png b/packages/core/src/ui/components/NamiMigration/Welcome/lace-dark.png
new file mode 100644
index 000000000..7ab708683
Binary files /dev/null and b/packages/core/src/ui/components/NamiMigration/Welcome/lace-dark.png differ
diff --git a/packages/core/src/ui/components/NamiMigration/Welcome/lace.png b/packages/core/src/ui/components/NamiMigration/Welcome/lace.png
new file mode 100644
index 000000000..244fa7fce
Binary files /dev/null and b/packages/core/src/ui/components/NamiMigration/Welcome/lace.png differ
diff --git a/packages/core/src/ui/components/NamiMigration/Welcome/nami.png b/packages/core/src/ui/components/NamiMigration/Welcome/nami.png
new file mode 100644
index 000000000..4e40beb70
Binary files /dev/null and b/packages/core/src/ui/components/NamiMigration/Welcome/nami.png differ
diff --git a/packages/core/src/ui/components/NamiMigration/Wizard.module.scss b/packages/core/src/ui/components/NamiMigration/Wizard.module.scss
new file mode 100644
index 000000000..2fd905f1c
--- /dev/null
+++ b/packages/core/src/ui/components/NamiMigration/Wizard.module.scss
@@ -0,0 +1,19 @@
+[data-theme='dark'] {
+ .container {
+ background-color: #2e2e2e;
+ }
+}
+
+.container {
+ width: 840px;
+ height: 584px;
+}
+
+.timeline {
+ margin-left: 22px !important;
+ margin-top: 48px !important;
+
+ li {
+ width: max-content;
+ }
+}
diff --git a/packages/core/src/ui/components/NamiMigration/Wizard.tsx b/packages/core/src/ui/components/NamiMigration/Wizard.tsx
new file mode 100644
index 000000000..17e069057
--- /dev/null
+++ b/packages/core/src/ui/components/NamiMigration/Wizard.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { Flex, Card, Divider } from '@input-output-hk/lace-ui-toolkit';
+import { Timeline } from '@lace/common';
+import styles from './Wizard.module.scss';
+
+interface Props {
+ children: React.ReactNode;
+ step?: 'welcome' | 'customize';
+ hideTimeline?: boolean;
+}
+
+export const Wizard = ({ children, step, hideTimeline = false }: Props): JSX.Element => {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {hideTimeline === false && (
+ <>
+
+
+ {t('core.namiMigration.timeline.1')}
+
+ {t('core.namiMigration.timeline.2')}
+
+
+ >
+ )}
+
+ {children}
+
+
+
+ );
+};
diff --git a/packages/core/src/ui/components/NamiMigration/index.ts b/packages/core/src/ui/components/NamiMigration/index.ts
new file mode 100644
index 000000000..5912d3a2c
--- /dev/null
+++ b/packages/core/src/ui/components/NamiMigration/index.ts
@@ -0,0 +1,3 @@
+export { Welcome } from './Welcome';
+export { Customize } from './Customize';
+export { AllDone } from './AllDone';
diff --git a/packages/core/src/ui/components/NamiMigrationUpdatingYourWallet/NamiMigrationUpdatingYourWallet.module.scss b/packages/core/src/ui/components/NamiMigrationUpdatingYourWallet/NamiMigrationUpdatingYourWallet.module.scss
new file mode 100644
index 000000000..8828728c7
--- /dev/null
+++ b/packages/core/src/ui/components/NamiMigrationUpdatingYourWallet/NamiMigrationUpdatingYourWallet.module.scss
@@ -0,0 +1,4 @@
+.card {
+ width: 840px;
+ height: 584px;
+}
diff --git a/packages/core/src/ui/components/NamiMigrationUpdatingYourWallet/NamiMigrationUpdatingYourWallet.stories.tsx b/packages/core/src/ui/components/NamiMigrationUpdatingYourWallet/NamiMigrationUpdatingYourWallet.stories.tsx
new file mode 100644
index 000000000..b81a19a4e
--- /dev/null
+++ b/packages/core/src/ui/components/NamiMigrationUpdatingYourWallet/NamiMigrationUpdatingYourWallet.stories.tsx
@@ -0,0 +1,15 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import { NamiMigrationUpdatingYourWallet } from './';
+
+const meta: Meta = {
+ title: 'Nami Migration/UpdatingYourWallet',
+ component: NamiMigrationUpdatingYourWallet,
+ parameters: {
+ layout: 'centered'
+ }
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Overview: Story = {};
diff --git a/packages/core/src/ui/components/NamiMigrationUpdatingYourWallet/NamiMigrationUpdatingYourWallet.tsx b/packages/core/src/ui/components/NamiMigrationUpdatingYourWallet/NamiMigrationUpdatingYourWallet.tsx
new file mode 100644
index 000000000..be1366a9c
--- /dev/null
+++ b/packages/core/src/ui/components/NamiMigrationUpdatingYourWallet/NamiMigrationUpdatingYourWallet.tsx
@@ -0,0 +1,16 @@
+import React, { VFC } from 'react';
+import { Card, Flex, Loader, Text } from '@input-output-hk/lace-ui-toolkit';
+import styles from './NamiMigrationUpdatingYourWallet.module.scss';
+import { useTranslation } from 'react-i18next';
+
+export const NamiMigrationUpdatingYourWallet: VFC = () => {
+ const { t } = useTranslation();
+ return (
+
+
+ {t('core.nami.migration.updating.heading')}
+
+
+
+ );
+};
diff --git a/packages/core/src/ui/components/NamiMigrationUpdatingYourWallet/index.ts b/packages/core/src/ui/components/NamiMigrationUpdatingYourWallet/index.ts
new file mode 100644
index 000000000..8736f29bc
--- /dev/null
+++ b/packages/core/src/ui/components/NamiMigrationUpdatingYourWallet/index.ts
@@ -0,0 +1 @@
+export { NamiMigrationUpdatingYourWallet } from './NamiMigrationUpdatingYourWallet';
diff --git a/packages/core/src/ui/typings/mp4.modules.d.ts b/packages/core/src/ui/typings/mp4.modules.d.ts
new file mode 100755
index 000000000..8a6df7155
--- /dev/null
+++ b/packages/core/src/ui/typings/mp4.modules.d.ts
@@ -0,0 +1,4 @@
+declare module '*.mp4' {
+ const value: string;
+ export default value;
+}
diff --git a/packages/nami/.eslintignore b/packages/nami/.eslintignore
new file mode 100644
index 000000000..b3087e352
--- /dev/null
+++ b/packages/nami/.eslintignore
@@ -0,0 +1,10 @@
+dist/**
+rollup.config.js
+storybook-static
+node_modules
+test
+typings
+.storybook/**
+src/ui/app/components/**
+src/ui/app/pages/**
+**/*.js
diff --git a/packages/nami/.eslintrc.js b/packages/nami/.eslintrc.js
new file mode 100644
index 000000000..849b1e23e
--- /dev/null
+++ b/packages/nami/.eslintrc.js
@@ -0,0 +1,209 @@
+const path = require('node:path');
+
+module.exports = {
+ $schema: 'https://json.schemastore.org/eslintrc.json',
+ root: true,
+ extends: [
+ 'plugin:react/recommended',
+ 'plugin:unicorn/recommended',
+ 'plugin:storybook/recommended',
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:@typescript-eslint/recommended-requiring-type-checking',
+ 'plugin:@typescript-eslint/strict',
+ 'plugin:functional/external-typescript-recommended',
+ 'plugin:functional/lite',
+ 'plugin:functional/stylistic',
+ 'prettier',
+ 'plugin:prettier/recommended',
+ 'plugin:storybook/recommended',
+ ],
+ parser: '@typescript-eslint/parser',
+ parserOptions: {
+ project: ['./tsconfig.eslint.json'],
+ tsconfigRootDir: path.resolve(__dirname),
+ },
+ plugins: [
+ 'eslint-plugin-import',
+ 'prefer-arrow-functions',
+ '@typescript-eslint',
+ 'functional',
+ 'prettier',
+ ],
+ settings: {
+ // Fixes eslint not being able to detect react version
+ react: { pragma: 'React', fragment: 'Fragment', version: 'detect' },
+ },
+ rules: {
+ 'unicorn/filename-case': 0,
+ 'max-params': ['error', 2],
+ 'no-void': 'off',
+ 'prettier/prettier': 'error',
+ 'import/no-default-export': 'error',
+ 'react/no-multi-comp': 'error',
+ 'prefer-arrow-functions/prefer-arrow-functions': 'error',
+ 'import/order': [
+ 'error',
+ {
+ groups: [
+ 'builtin',
+ 'external',
+ 'internal',
+ 'parent',
+ 'sibling',
+ 'index',
+ 'object',
+ 'type',
+ ],
+ pathGroups: [
+ {
+ pattern: 'react',
+ group: 'builtin',
+ },
+ {
+ pattern: 'react',
+ group: 'builtin',
+ },
+ {
+ pattern: '@storybook/**',
+ group: 'external',
+ },
+ ],
+ alphabetize: {
+ order: 'asc',
+ caseInsensitive: true,
+ },
+ 'newlines-between': 'always',
+ pathGroupsExcludedImportTypes: ['react'],
+ },
+ ],
+ 'import/no-default-export': 'off',
+ 'unicorn/no-useless-undefined': 'off',
+ '@typescript-eslint/no-unused-vars': 'off',
+ '@typescript-eslint/no-unsafe-call': 'off',
+ '@typescript-eslint/no-dynamic-delete': 'off',
+ '@typescript-eslint/no-unsafe-return': 'off',
+ '@typescript-eslint/no-unsafe-member-access': 'off',
+ '@typescript-eslint/no-throw-literal': 'off',
+ '@typescript-eslint/no-unnecessary-condition': 'off',
+ '@typescript-eslint/no-explicit-any': 'off',
+ '@typescript-eslint/consistent-type-imports': 'error',
+ '@typescript-eslint/consistent-type-exports': 'error',
+ '@typescript-eslint/explicit-function-return-type': 'off',
+ '@typescript-eslint/explicit-module-boundary-types': 'off',
+ '@typescript-eslint/naming-convention': [
+ 'error',
+ {
+ selector: 'variable',
+ types: ['boolean'],
+ format: ['PascalCase'],
+ prefix: ['is', 'should', 'has', 'can', 'did', 'will'],
+ },
+ ],
+ '@typescript-eslint/no-confusing-void-expression': 'error',
+ '@typescript-eslint/no-redundant-type-constituents': 'error',
+ '@typescript-eslint/no-require-imports': 'error',
+ '@typescript-eslint/no-type-alias': [
+ 'off',
+ {
+ allowGenerics: 'always',
+ allowAliases: 'always',
+ allowMappedTypes: 'in-unions-and-intersections',
+ },
+ ],
+ '@typescript-eslint/explicit-member-accessibility': 'off',
+ '@typescript-eslint/ban-ts-comment': 'off',
+ '@typescript-eslint/no-unnecessary-type-assertion': 'off',
+ '@typescript-eslint/no-unsafe-argument': 'off',
+ '@typescript-eslint/no-unsafe-assignment': 'off',
+ '@typescript-eslint/prefer-enum-initializers': 'error',
+ '@typescript-eslint/prefer-readonly': 'error',
+ '@typescript-eslint/promise-function-async': 'error',
+ '@typescript-eslint/restrict-template-expressions': 'off',
+ '@typescript-eslint/sort-type-constituents': 'error',
+ '@typescript-eslint/strict-boolean-expressions': 'off',
+ '@typescript-eslint/switch-exhaustiveness-check': 'error',
+ '@typescript-eslint/member-ordering': 'off',
+ 'functional/no-classes': 'off',
+ 'functional/immutable-data': 'off',
+ 'functional/no-loop-statements': 'off',
+ 'functional/functional-parameters': 'off',
+ 'functional/no-let': 'off',
+ 'functional/no-mixed-types': 'off',
+ 'functional/no-return-void': 'off',
+ 'functional/no-expression-statements': 'off',
+ 'unicorn/prefer-spread': 'off',
+ 'unicorn/prevent-abbreviations': 'off',
+ 'no-restricted-imports': [
+ 'error',
+ {
+ patterns: [
+ {
+ group: ['*design-system/*/*'],
+ message: 'usage of design system private modules not allowed.',
+ },
+ {
+ group: ['*design-tokens/*'],
+ message: 'usage of design tokens private modules not allowed.',
+ },
+ {
+ group: ['*types/*'],
+ message: 'usage of types private modules not allowed.',
+ },
+ ],
+ },
+ ],
+ },
+ overrides: [
+ {
+ files: [
+ 'index.js',
+ '.eslintrc.js',
+ 'rollup.config.ts',
+ '.storybook/*.js',
+ ],
+ rules: {
+ 'unicorn/prefer-module': ['off'],
+ '@typescript-eslint/no-unsafe-assignment': ['off'],
+ '@typescript-eslint/no-var-requires': ['off'],
+ '@typescript-eslint/no-unsafe-call': ['off'],
+ '@typescript-eslint/require-await': ['off'],
+ '@typescript-eslint/no-require-imports': ['off'],
+ 'functional/no-expression-statements': ['off'],
+ 'functional/immutable-data': ['off'],
+ '@typescript-eslint/strict-boolean-expressions': ['off'],
+ '@typescript-eslint/explicit-module-boundary-types': ['off'],
+ '@typescript-eslint/explicit-function-return-type': ['off'],
+ '@typescript-eslint/no-unsafe-member-access': ['off'],
+ '@typescript-eslint/no-unsafe-return': ['off'],
+ },
+ },
+ {
+ files: ['*.spec.tsx', '*.spec.ts'],
+ rules: {
+ 'functional/no-expression-statements': ['off'],
+ 'functional/no-return-void': ['off'],
+ },
+ },
+ {
+ files: ['jest.config.ts'],
+ rules: {
+ '@typescript-eslint/restrict-template-expressions': ['off'],
+ '@typescript-eslint/no-unsafe-call': ['off'],
+ 'unicorn/prefer-module': ['off'],
+ 'functional/immutable-data': [
+ 'error',
+ {
+ ignorePattern: 'module.exports',
+ },
+ ],
+ },
+ },
+ {
+ files: ['src/**/*.stories.tsx'],
+ rules: {
+ 'react/no-multi-comp': ['off'],
+ 'import/no-default-export': ['off'],
+ },
+ },
+ ],
+};
diff --git a/packages/nami/.gitignore b/packages/nami/.gitignore
new file mode 100755
index 000000000..60a86b72e
--- /dev/null
+++ b/packages/nami/.gitignore
@@ -0,0 +1,6 @@
+# Do not ignore the following
+!src/ui/typings/*
+storybook-static
+dist
+
+*storybook.log
\ No newline at end of file
diff --git a/packages/nami/.prettierignore b/packages/nami/.prettierignore
new file mode 100644
index 000000000..06feae433
--- /dev/null
+++ b/packages/nami/.prettierignore
@@ -0,0 +1,9 @@
+dist/**
+rollup.config.js
+storybook-static
+node_modules
+test
+typings
+.storybook/**
+src/ui/app/components/**
+**/*.js
diff --git a/packages/nami/.prettierrc.js b/packages/nami/.prettierrc.js
new file mode 100644
index 000000000..6d0050026
--- /dev/null
+++ b/packages/nami/.prettierrc.js
@@ -0,0 +1,6 @@
+module.exports = {
+ arrowParens: 'avoid',
+ bracketSpacing: true,
+ singleQuote: true,
+ trailingComma: 'all',
+};
diff --git a/packages/nami/.storybook/main.ts b/packages/nami/.storybook/main.ts
new file mode 100644
index 000000000..545ccc3eb
--- /dev/null
+++ b/packages/nami/.storybook/main.ts
@@ -0,0 +1,104 @@
+import type { StorybookConfig } from '@storybook/react-webpack5';
+const { NormalModuleReplacementPlugin, ProvidePlugin } = require('webpack');
+
+import { join, dirname } from 'path';
+
+/**
+ * This function is used to resolve the absolute path of a package.
+ * It is needed in projects that use Yarn PnP or are set up within a monorepo.
+ */
+function getAbsolutePath(value: string): any {
+ return dirname(require.resolve(join(value, 'package.json')));
+}
+const config: StorybookConfig = {
+ stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
+ addons: [
+ getAbsolutePath('@storybook/addon-webpack5-compiler-swc'),
+ getAbsolutePath('@storybook/addon-links'),
+ getAbsolutePath('@storybook/addon-essentials'),
+ getAbsolutePath('@chromatic-com/storybook'),
+ ],
+ framework: {
+ name: getAbsolutePath('@storybook/react-webpack5'),
+ options: {},
+ },
+ webpackFinal: config => {
+ if (config.resolve?.alias) {
+ config.resolve.alias = {
+ ...config.resolve.alias,
+ '@emotion/core': '@emotion/react',
+ 'emotion-theming': '@emotion/react',
+ };
+ }
+
+ if (config.plugins) {
+ config.plugins.push(
+ new ProvidePlugin({
+ Buffer: ['buffer', 'Buffer'],
+ process: 'process/browser',
+ }),
+ );
+
+ config.plugins.push(
+ new NormalModuleReplacementPlugin(
+ /features\/outside-handles-provider\/useOutsideHandles$/,
+ join(__dirname, '../src/features/outside-handles-provider/useOutsideHandles.mock.ts'),
+ ),
+ new NormalModuleReplacementPlugin(
+ /adapters\/collateral$/,
+ join(__dirname, '../src/adapters/collateral.mock.ts'),
+ ),
+ new NormalModuleReplacementPlugin(
+ /adapters\/delegation$/,
+ join(__dirname, '../src/adapters/delegation.mock.ts'),
+ ),
+ new NormalModuleReplacementPlugin(
+ /api\/extension\/wallet$/,
+ join(__dirname, '../src/api/extension/wallet.mock.ts'),
+ ),
+ new NormalModuleReplacementPlugin(
+ /api\/util$/,
+ join(__dirname, '../src/api/util.mock.ts'),
+ ),
+ new NormalModuleReplacementPlugin(
+ /api\/extension$/,
+ join(__dirname, '../src/api/extension/api.mock.ts'),
+ ),
+ new NormalModuleReplacementPlugin(
+ /store$/,
+ join(__dirname, '../src/ui/store.mock.ts'),
+ ),
+ new NormalModuleReplacementPlugin(
+ /^react-router-dom$/,
+ join(__dirname, './mocks/react-router-dom.mock.tsx'),
+ ),
+ new NormalModuleReplacementPlugin(
+ /loader$/,
+ join(__dirname, '../src/api/loader.mock.ts'),
+ ),
+ new NormalModuleReplacementPlugin(
+ /signTxUtil$/,
+ join(
+ __dirname,
+ '../src/ui/app/pages/dapp-connector/signTxUtil.mock.ts',
+ ),
+ ),
+ new NormalModuleReplacementPlugin(
+ /@cardano-sdk/,
+ join(__dirname, './mocks/cardano-sdk.mock.ts'),
+ ),
+ new NormalModuleReplacementPlugin(
+ /@lace\/cardano$/,
+ join(__dirname, './mocks/lace-cardano.mock.ts'),
+ ),
+ new NormalModuleReplacementPlugin(
+ /@lace\/core$/,
+ join(__dirname, './mocks/lace-core.mock.ts'),
+ ),
+ );
+ }
+
+ return config;
+ },
+};
+export default config;
diff --git a/packages/nami/.storybook/mocks/cardano-sdk.mock.ts b/packages/nami/.storybook/mocks/cardano-sdk.mock.ts
new file mode 100644
index 000000000..361062bf5
--- /dev/null
+++ b/packages/nami/.storybook/mocks/cardano-sdk.mock.ts
@@ -0,0 +1,31 @@
+import { fn } from '@storybook/test';
+
+export const Cardano = {
+ Address: {
+ fromBech32: fn().mockName('fromBech32'),
+ fromBytes: fn().mockName('fromBytes'),
+ },
+ NetworkMagics: {},
+};
+
+export const Serialization = {
+ TransactionOutput: function () {},
+ Value: function () {
+ return { setMultiasset: () => {} };
+ },
+ AuxiliaryData: function () {
+ return { setMetadata: () => {} };
+ },
+ TransactionMetadatum: function () {},
+ GeneralTransactionMetadata: function () {},
+};
+
+Serialization.TransactionMetadatum.fromCore = fn().mockName('fromCore');
+
+export const ProviderUtil = {
+ jsonToMetadatum: fn().mockName('jsonToMetadatum'),
+};
+
+export const Ed25519KeyHashHex = fn().mockName('Ed25519KeyHashHex');
+
+export const handleHttpProvider = fn().mockName('handleHttpProvider');
diff --git a/packages/nami/.storybook/mocks/lace-cardano.mock.ts b/packages/nami/.storybook/mocks/lace-cardano.mock.ts
new file mode 100644
index 000000000..84d4a31f0
--- /dev/null
+++ b/packages/nami/.storybook/mocks/lace-cardano.mock.ts
@@ -0,0 +1 @@
+export const Wallet = {};
diff --git a/packages/nami/.storybook/mocks/lace-core.mock.ts b/packages/nami/.storybook/mocks/lace-core.mock.ts
new file mode 100644
index 000000000..744d139a4
--- /dev/null
+++ b/packages/nami/.storybook/mocks/lace-core.mock.ts
@@ -0,0 +1 @@
+export const getSecureRandomNumber = () => Math.random();
diff --git a/packages/nami/.storybook/mocks/react-router-dom.mock.tsx b/packages/nami/.storybook/mocks/react-router-dom.mock.tsx
new file mode 100644
index 000000000..8cb80269a
--- /dev/null
+++ b/packages/nami/.storybook/mocks/react-router-dom.mock.tsx
@@ -0,0 +1,19 @@
+import { fn } from '@storybook/test';
+import React from 'react';
+
+export * from 'react-router-dom';
+
+export let mockedHistory: string[] = [];
+
+export const useHistory = fn(() => ({
+ push: () => void 0,
+ replace: (route: string) => (mockedHistory = [route]),
+})).mockName('useHistory');
+
+export const HashRouter = fn(({ children }) => <>{children} >).mockName(
+ 'HashRouter',
+);
+
+export const Switch = fn(({ children }) => <>{children} >).mockName('Switch');
+
+export const Route = fn().mockName('Route');
diff --git a/packages/nami/.storybook/preview-head.html b/packages/nami/.storybook/preview-head.html
new file mode 100644
index 000000000..ec2088666
--- /dev/null
+++ b/packages/nami/.storybook/preview-head.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+Nami Package
+
diff --git a/packages/nami/.storybook/preview.tsx b/packages/nami/.storybook/preview.tsx
new file mode 100644
index 000000000..4d1b83cf0
--- /dev/null
+++ b/packages/nami/.storybook/preview.tsx
@@ -0,0 +1,79 @@
+import React from 'react';
+import type { Preview } from '@storybook/react';
+import '../src/ui/app/components/styles.css';
+import 'focus-visible/dist/focus-visible';
+
+import { ChakraProvider, extendTheme } from '@chakra-ui/react';
+import { theme } from '../src/ui/theme';
+import { Scrollbars } from '../src/ui/app/components/scrollbar';
+import { OutsideHandlesProvider } from '../src/features/outside-handles-provider';
+
+const noop = (async () => {}) as any;
+const mock = {} as any;
+
+const preview: Preview = {
+ parameters: {
+ controls: {
+ matchers: {
+ color: /(background|color)$/i,
+ date: /Date$/i,
+ },
+ },
+ },
+ loaders: [],
+};
+
+const cardanoCoin = {
+ id: '1',
+ name: 'Cardano',
+ decimals: 6,
+ symbol: 't₳'
+};
+
+export const decorators = [
+ (Story, { parameters: { colorMode, ...props } }) => (
+ ({
+ status: true,
+ url: 'https://cardano-preprod.blockfrost.io/api/v0'
+ })
+ }
+ cardanoCoin={cardanoCoin}
+ sendEventToPostHog={noop}
+ theme="light"
+ fiatCurrency="USD"
+ currentChain={{ networkId: 0, networkMagic: 0 }}
+ isAnalyticsOptIn={false}
+ walletAddress=""
+ inMemoryWallet={mock}
+ walletManager={mock}
+ walletRepository={mock}
+ handleAnalyticsChoice={noop}
+ createWallet={noop}
+ getMnemonic={noop}
+ deleteWallet={noop}
+ setFiatCurrency={noop}
+ setTheme={noop}
+ withSignTxConfirmation={noop}
+ >
+
+
+ {Story({ args: { colorMode, ...props } })}
+
+
+
+ ),
+];
+
+export default preview;
diff --git a/packages/nami/README.md b/packages/nami/README.md
new file mode 100644
index 000000000..21e9fd2a8
--- /dev/null
+++ b/packages/nami/README.md
@@ -0,0 +1,3 @@
+# Light Wallet | Packages | Core
+
+Contains Nami mode package logic.
diff --git a/packages/nami/package.json b/packages/nami/package.json
new file mode 100644
index 000000000..a3a8de0bf
--- /dev/null
+++ b/packages/nami/package.json
@@ -0,0 +1,135 @@
+{
+ "name": "@lace/nami",
+ "version": "0.1.0",
+ "description": "Nami mode package",
+ "homepage": "https://github.com/input-output-hk/lace/blob/master/packages/nami/README.md",
+ "bugs": {
+ "url": "https://github.com/input-output-hk/lace/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/input-output-hk/lace.git"
+ },
+ "license": "Apache-2.0",
+ "author": {
+ "name": "IOHK"
+ },
+ "exports": {
+ ".": "./dist/index.js",
+ "./adapters": "./dist/adapters/index.js"
+ },
+ "main": "dist/index.js",
+ "module": "dist/index.esm.js",
+ "typesVersions": {
+ "*": {
+ "adapters": [
+ "./dist/adapters/index.d.ts"
+ ]
+ }
+ },
+ "typings": "dist/index.d.ts",
+ "directories": {
+ "lib": "src",
+ "test": "test"
+ },
+ "files": [
+ "dist",
+ "LICENSE",
+ "NOTICE",
+ "README.md"
+ ],
+ "scripts": {
+ "build": "rm -rf dist && run -T rollup -c rollup.config.js",
+ "build-storybook": "storybook build",
+ "chromatic": "echo \"@lace/nami: no chromatic command specified\"",
+ "cleanup": "yarn exec rm -rf dist node_modules",
+ "format": "yarn prettier --write .",
+ "format-check": "yarn prettier --check .",
+ "lint": "eslint .",
+ "prepack": "yarn build",
+ "prestart": "yarn build",
+ "start": "node dist/index.js",
+ "storybook": "storybook dev -p 6006",
+ "test": "NODE_ENV=test run -T jest -c ./test/jest.config.js --silent",
+ "test-storybook:ci": "echo \"@lace/nami: no test-storybook:ci specified\"",
+ "type-check": "echo \"@lace/nami: no type-check command specified\"",
+ "watch": "yarn build --watch"
+ },
+ "dependencies": {
+ "@cardano-foundation/ledgerjs-hw-app-cardano": "^6.0.0",
+ "@cardano-sdk/core": "0.39.3",
+ "@cardano-sdk/crypto": "0.1.30",
+ "@cardano-sdk/tx-construction": "0.21.4",
+ "@cardano-sdk/web-extension": "0.34.2",
+ "@chakra-ui/css-reset": "1.0.0",
+ "@chakra-ui/icons": "1.0.13",
+ "@chakra-ui/react": "1.6.4",
+ "@dicebear/avatars": "^4.6.4",
+ "@dicebear/avatars-bottts-sprites": "^4.6.4",
+ "@emotion/react": "^11.4.0",
+ "@emotion/styled": "^11.3.0",
+ "@fontsource/ubuntu": "^5.0.8",
+ "@lace/cardano": "workspace:^",
+ "@lace/common": "workspace:^",
+ "@lace/core": "workspace:^",
+ "crc": "^4.1.1",
+ "debounce-promise": "^3.1.2",
+ "easy-peasy": "^6.0.4",
+ "focus-visible": "^5.2.0",
+ "framer-motion": "^4.1.16",
+ "javascript-time-ago": "^2.5.10",
+ "promise-latest": "^1.0.4",
+ "react-custom-scrollbars-2": "^4.5.0",
+ "react-icons": "4.2.0",
+ "react-kawaii": "^0.18.0",
+ "react-lazy-load-image-component": "^1.5.1",
+ "react-middle-ellipsis": "^1.2.1",
+ "react-number-format": "^5.3.1",
+ "react-router-dom": "5.2.0",
+ "react-time-ago": "^7.3.3",
+ "react-window": "^1.8.10",
+ "rxjs": "7.4.0",
+ "use-constant": "^1.1.0"
+ },
+ "devDependencies": {
+ "@chromatic-com/storybook": "^1.5.0",
+ "@rollup/plugin-image": "^3.0.3",
+ "@storybook/addon-essentials": "^8.1.6",
+ "@storybook/addon-interactions": "^8.1.6",
+ "@storybook/addon-links": "^8.1.6",
+ "@storybook/addon-webpack5-compiler-swc": "^1.0.3",
+ "@storybook/blocks": "^8.1.6",
+ "@storybook/react": "^8.1.6",
+ "@storybook/react-webpack5": "^8.1.6",
+ "@storybook/test": "^8.1.6",
+ "@svgr/rollup": "^6.1.2",
+ "@types/chrome": "^0.0.268",
+ "@types/react": "17.0.2",
+ "@types/react-kawaii": "^0.17.3",
+ "@types/react-lazy-load-image-component": "1.6.4",
+ "@typescript-eslint/eslint-plugin": "^5.51.0",
+ "@typescript-eslint/parser": "^5.51.0",
+ "eslint": "8.33.0",
+ "eslint-config-prettier": "^9.1.0",
+ "eslint-plugin-functional": "^5.0.4",
+ "eslint-plugin-import": "^2.27.5",
+ "eslint-plugin-prefer-arrow-functions": "^3.1.4",
+ "eslint-plugin-prettier": "^5.1.3",
+ "eslint-plugin-react": "7.31.8",
+ "eslint-plugin-storybook": "^0.8.0",
+ "eslint-plugin-unicorn": "^45.0.2",
+ "prettier": "3.2.5",
+ "react": "17.0.2",
+ "react-dom": "17.0.2",
+ "rollup-plugin-import-css": "^3.5.0",
+ "rollup-plugin-svg": "^2.0.0",
+ "sass": "^1.68.0",
+ "storybook": "^8.1.6",
+ "tsconfig-paths-webpack-plugin": "3.5.2",
+ "typescript": "^4.9.5"
+ },
+ "peerDependencies": {
+ "react": "17.0.2",
+ "react-dom": "17.0.2"
+ }
+}
diff --git a/packages/nami/rollup.config.js b/packages/nami/rollup.config.js
new file mode 100644
index 000000000..c6b8ffa6a
--- /dev/null
+++ b/packages/nami/rollup.config.js
@@ -0,0 +1,68 @@
+import commonjs from '@rollup/plugin-commonjs';
+import typescript from '@rollup/plugin-typescript';
+import css from 'rollup-plugin-import-css';
+import image from '@rollup/plugin-image';
+import json from '@rollup/plugin-json';
+import packageJson from './package.json';
+import svgr from '@svgr/rollup';
+import copy from 'rollup-plugin-copy';
+import url from '@rollup/plugin-url';
+
+const common = {
+ plugins: [
+ typescript({
+ tsconfig: './src/tsconfig.json',
+ composite: false,
+ }),
+ json(),
+ commonjs(),
+ css(),
+ image(),
+ svgr(),
+ copy({
+ targets: [{ src: 'src/assets', dest: 'dist' }],
+ }),
+ url({
+ limit: 0,
+ include: ['**/*.mp4'],
+ emitFiles: true,
+ fileName: '[name][extname]'
+ }),
+ ],
+ external: [/node_modules/],
+};
+
+export default () => [
+ {
+ ...common,
+ input: 'src/adapters/index.ts',
+ output: [
+ {
+ file: 'dist/adapters/index.js',
+ format: 'cjs',
+ sourcemap: true,
+ },
+ {
+ file: 'dist/adapters/index.esm.js',
+ format: 'esm',
+ sourcemap: true,
+ },
+ ],
+ },
+ {
+ ...common,
+ input: 'src/index.ts',
+ output: [
+ {
+ file: packageJson.main,
+ format: 'cjs',
+ sourcemap: true,
+ },
+ {
+ file: packageJson.module,
+ format: 'esm',
+ sourcemap: true,
+ },
+ ],
+ },
+];
diff --git a/packages/nami/src/adapters/README.md b/packages/nami/src/adapters/README.md
new file mode 100644
index 000000000..c33035a01
--- /dev/null
+++ b/packages/nami/src/adapters/README.md
@@ -0,0 +1,3 @@
+# Light Wallet | Packages | Nami | Adapters
+
+Contains data transformation utils and adapters.
diff --git a/packages/nami/src/adapters/account.test.ts b/packages/nami/src/adapters/account.test.ts
new file mode 100644
index 000000000..1420bd973
--- /dev/null
+++ b/packages/nami/src/adapters/account.test.ts
@@ -0,0 +1,367 @@
+import { act, renderHook } from '@testing-library/react-hooks';
+import { BehaviorSubject, of } from 'rxjs';
+
+import { useAccount } from './account';
+
+import type {
+ AnyWallet,
+ WalletManagerApi,
+ WalletRepositoryApi,
+} from '@cardano-sdk/web-extension';
+import type { Wallet } from '@lace/cardano';
+
+const mockAddAccount = jest.fn().mockResolvedValue(undefined);
+const mockActivateAccount = jest.fn().mockResolvedValue(undefined);
+const mockRemoveAccount = jest.fn().mockResolvedValue(undefined);
+const mockUpdateAccountMetadata = jest.fn().mockResolvedValue(undefined);
+const mockRemoveWallet = jest.fn().mockResolvedValue(undefined);
+
+const genMetadata = (index: number) => ({
+ accountIndex: index,
+ metadata: {
+ name: `Account ${index}`,
+ namiMode: { avatar: `avatar${index}`, address: `address${index}` },
+ },
+});
+
+const acc0 = genMetadata(0);
+const acc1 = genMetadata(1);
+const acc2 = genMetadata(2);
+
+const genWallet = (
+ walletId: string,
+ type: string,
+): AnyWallet =>
+ ({
+ walletId,
+ type,
+ accounts: [acc0, acc1, acc2],
+ }) as AnyWallet;
+
+const wallet1 = genWallet('wallet1', 'InMemory');
+const wallet2 = genWallet('wallet2', 'InMemory');
+const trezorWallet1 = genWallet('trezor wallet1', 'Trezor');
+const trezorWallet2 = genWallet('trezor wallet2', 'Trezor');
+const ledgerWallet1 = genWallet('ledger wallet1', 'Ledger');
+const ledgerrWallet2 = genWallet('ledger wallet2', 'Ledger');
+
+const walletRepository = [
+ wallet1,
+ wallet2,
+ trezorWallet1,
+ trezorWallet2,
+ ledgerWallet1,
+ ledgerrWallet2,
+];
+
+const getAccountData = (
+ wallet: Readonly>,
+ accIndex: number,
+) => {
+ const acc =
+ 'accounts' in wallet
+ ? wallet.accounts.find(a => a.accountIndex === accIndex)
+ : {
+ metadata: { name: '', namiMode: {} },
+ };
+ return {
+ index: accIndex,
+ walletId: wallet.walletId,
+ name: acc?.metadata?.name || `${wallet.type} ${accIndex}`,
+ hw: wallet.type === 'Ledger' || wallet.type === 'Trezor',
+ ...acc?.metadata?.namiMode,
+ };
+};
+
+type Wallets$ = WalletRepositoryApi<
+ Wallet.WalletMetadata,
+ Wallet.AccountMetadata
+>['wallets$'];
+
+describe('useAccount', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ it('should return properly sorted accounts info for active non hw account', () => {
+ const wallets$ = of(walletRepository) as Wallets$;
+ const activeWalletId$ = of({
+ walletId: 'wallet1',
+ accountIndex: 1,
+ }) as WalletManagerApi['activeWalletId$'];
+
+ const { result } = renderHook(() =>
+ useAccount({
+ wallets$,
+ activeWalletId$,
+ updateAccountMetadata: mockUpdateAccountMetadata,
+ activateAccount: mockActivateAccount,
+ addAccount: mockAddAccount,
+ removeAccount: mockRemoveAccount,
+ removeWallet: mockRemoveWallet,
+ }),
+ );
+
+ expect(result.current.activeAccount).toEqual(getAccountData(wallet1, 1));
+
+ expect(result.current.nonActiveAccounts).toEqual([
+ getAccountData(wallet1, 0),
+ getAccountData(wallet1, 2),
+ getAccountData(trezorWallet1, 0),
+ getAccountData(trezorWallet1, 1),
+ getAccountData(trezorWallet1, 2),
+ getAccountData(ledgerWallet1, 0),
+ getAccountData(ledgerWallet1, 1),
+ getAccountData(ledgerWallet1, 2),
+ ]);
+
+ expect(result.current.allAccounts).toEqual([
+ getAccountData(wallet1, 0),
+ getAccountData(wallet1, 1),
+ getAccountData(wallet1, 2),
+ getAccountData(trezorWallet1, 0),
+ getAccountData(trezorWallet1, 1),
+ getAccountData(trezorWallet1, 2),
+ getAccountData(ledgerWallet1, 0),
+ getAccountData(ledgerWallet1, 1),
+ getAccountData(ledgerWallet1, 2),
+ ]);
+ });
+
+ it('should return properly sorted accounts info for active hw account', () => {
+ const wallets$ = of(walletRepository) as Wallets$;
+ const activeWalletId$ = of({
+ walletId: 'trezor wallet1',
+ accountIndex: 1,
+ }) as WalletManagerApi['activeWalletId$'];
+
+ const { result } = renderHook(() =>
+ useAccount({
+ wallets$,
+ activeWalletId$,
+ updateAccountMetadata: mockUpdateAccountMetadata,
+ activateAccount: mockActivateAccount,
+ addAccount: mockAddAccount,
+ removeAccount: mockRemoveAccount,
+ removeWallet: mockRemoveWallet,
+ }),
+ );
+
+ expect(result.current.activeAccount).toEqual(
+ getAccountData(trezorWallet1, 1),
+ );
+
+ expect(result.current.nonActiveAccounts).toEqual([
+ getAccountData(wallet1, 0),
+ getAccountData(wallet1, 1),
+ getAccountData(wallet1, 2),
+ getAccountData(trezorWallet1, 0),
+ getAccountData(trezorWallet1, 2),
+ getAccountData(ledgerWallet1, 0),
+ getAccountData(ledgerWallet1, 1),
+ getAccountData(ledgerWallet1, 2),
+ ]);
+
+ expect(result.current.allAccounts).toEqual([
+ getAccountData(wallet1, 0),
+ getAccountData(wallet1, 1),
+ getAccountData(wallet1, 2),
+ getAccountData(trezorWallet1, 0),
+ getAccountData(trezorWallet1, 1),
+ getAccountData(trezorWallet1, 2),
+ getAccountData(ledgerWallet1, 0),
+ getAccountData(ledgerWallet1, 1),
+ getAccountData(ledgerWallet1, 2),
+ ]);
+ });
+
+ it('should call updateAccountMetadata with correct arguments', async () => {
+ const wallets$ = of(walletRepository) as Wallets$;
+ const activeWalletId$ = of({
+ walletId: 'wallet1',
+ accountIndex: 0,
+ }) as WalletManagerApi['activeWalletId$'];
+
+ const { result } = renderHook(() =>
+ useAccount({
+ wallets$,
+ activeWalletId$,
+ updateAccountMetadata: mockUpdateAccountMetadata,
+ activateAccount: mockActivateAccount,
+ addAccount: mockAddAccount,
+ removeAccount: mockRemoveAccount,
+ removeWallet: mockRemoveWallet,
+ }),
+ );
+
+ await result.current.updateAccountMetadata({ name: 'Updated Account 0' });
+
+ expect(mockUpdateAccountMetadata).toHaveBeenNthCalledWith(1, {
+ walletId: 'wallet1',
+ accountIndex: 0,
+ metadata: {
+ name: 'Updated Account 0',
+ namiMode: { avatar: 'avatar0', address: 'address0' },
+ },
+ });
+
+ await result.current.updateAccountMetadata({
+ namiMode: { avatar: 'avatar1' },
+ });
+
+ expect(mockUpdateAccountMetadata).toHaveBeenNthCalledWith(2, {
+ walletId: 'wallet1',
+ accountIndex: 0,
+ metadata: {
+ name: 'Account 0',
+ namiMode: { avatar: 'avatar1', address: 'address0' },
+ },
+ });
+ });
+
+ it('should handle undefined walletId or accountIndex', async () => {
+ const wallets$ = of(walletRepository) as Wallets$;
+ const activeWalletId$ = of({
+ walletId: undefined,
+ accountIndex: undefined,
+ }) as unknown as WalletManagerApi['activeWalletId$'];
+
+ const { result } = renderHook(() =>
+ useAccount({
+ wallets$,
+ activeWalletId$,
+ updateAccountMetadata: mockUpdateAccountMetadata,
+ activateAccount: mockActivateAccount,
+ addAccount: mockAddAccount,
+ removeAccount: mockRemoveAccount,
+ removeWallet: mockRemoveWallet,
+ }),
+ );
+
+ expect(result.current.activeAccount).toEqual(getAccountData(wallet1, 0));
+
+ await result.current.updateAccountMetadata({ name: 'Updated Account' });
+
+ expect(mockUpdateAccountMetadata).not.toHaveBeenCalled();
+ });
+
+ it('should return correct next index', () => {
+ const wallets$ = new BehaviorSubject(walletRepository);
+ const activeWalletId$ = of({
+ walletId: 'wallet1',
+ accountIndex: 0,
+ }) as unknown as WalletManagerApi['activeWalletId$'];
+
+ const { result } = renderHook(() =>
+ useAccount({
+ wallets$: wallets$ as unknown as Wallets$,
+ activeWalletId$,
+ updateAccountMetadata: mockUpdateAccountMetadata,
+ activateAccount: mockActivateAccount,
+ addAccount: mockAddAccount,
+ removeAccount: mockRemoveAccount,
+ removeWallet: mockRemoveWallet,
+ }),
+ );
+
+ expect(result.current.nextIndex).toEqual(3);
+
+ act(() => {
+ wallets$.next([
+ {
+ walletId: 'wallet1',
+ accounts: [
+ {
+ accountIndex: 0,
+ metadata: {
+ name: 'Account 0',
+ namiMode: { avatar: 'avatar0', address: 'address0' },
+ },
+ },
+ {
+ accountIndex: 3,
+ metadata: {
+ name: 'Account 3',
+ namiMode: { avatar: 'avatar3', address: 'address3' },
+ },
+ },
+ {
+ accountIndex: 1,
+ metadata: {
+ name: 'Account 1',
+ namiMode: { avatar: 'avatar1', address: 'address1' },
+ },
+ },
+ ],
+ } as AnyWallet,
+ ]);
+ });
+
+ expect(result.current.nextIndex).toEqual(2);
+ });
+
+ it('should call removeWallet with correct arguments', async () => {
+ const wallets$ = of(walletRepository) as Wallets$;
+ const activeWalletId$ = of({
+ walletId: 'trezor wallet1',
+ accountIndex: 1,
+ }) as unknown as WalletManagerApi['activeWalletId$'];
+
+ const { result } = renderHook(() =>
+ useAccount({
+ wallets$,
+ activeWalletId$,
+ updateAccountMetadata: mockUpdateAccountMetadata,
+ activateAccount: mockActivateAccount,
+ addAccount: mockAddAccount,
+ removeAccount: mockRemoveAccount,
+ removeWallet: mockRemoveWallet,
+ }),
+ );
+
+ await result.current.removeAccount({
+ accountIndex: 1,
+ walletId: 'trezor wallet1',
+ });
+
+ expect(mockRemoveAccount).toHaveBeenCalledWith({
+ accountIndex: 1,
+ walletId: 'trezor wallet1',
+ });
+
+ expect(mockRemoveWallet).not.toHaveBeenCalled();
+ });
+
+ it('should call removeAccount with correct arguments', async () => {
+ const wallets$ = of([
+ {
+ walletId: 'wallet',
+ accounts: [acc1],
+ } as AnyWallet,
+ ]) as Wallets$;
+ const activeWalletId$ = of({
+ walletId: 'wallet',
+ accountIndex: 1,
+ }) as unknown as WalletManagerApi['activeWalletId$'];
+
+ const { result } = renderHook(() =>
+ useAccount({
+ wallets$,
+ activeWalletId$,
+ updateAccountMetadata: mockUpdateAccountMetadata,
+ activateAccount: mockActivateAccount,
+ addAccount: mockAddAccount,
+ removeAccount: mockRemoveAccount,
+ removeWallet: mockRemoveWallet,
+ }),
+ );
+
+ await result.current.removeAccount({
+ accountIndex: 1,
+ walletId: 'wallet',
+ });
+
+ expect(mockRemoveWallet).toHaveBeenCalledWith('wallet');
+
+ expect(mockRemoveAccount).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/nami/src/adapters/account.ts b/packages/nami/src/adapters/account.ts
new file mode 100644
index 000000000..c6f1ad016
--- /dev/null
+++ b/packages/nami/src/adapters/account.ts
@@ -0,0 +1,269 @@
+import { useCallback, useMemo } from 'react';
+
+import {
+ WalletType,
+ type WalletId,
+ type HardwareWallet,
+ type Bip32WalletAccount,
+ type AnyWallet,
+ type RemoveAccountProps,
+ type UpdateAccountMetadataProps,
+ type WalletManagerActivateProps,
+ type WalletManagerApi,
+} from '@cardano-sdk/web-extension';
+import { Wallet } from '@lace/cardano';
+import { useObservable } from '@lace/common';
+import flatten from 'lodash/flatten';
+import groupBy from 'lodash/groupBy';
+import merge from 'lodash/merge';
+
+import type { WalletManagerAddAccountProps } from '../features/outside-handles-provider/types';
+import type { Observable } from 'rxjs';
+
+interface AccountsProps {
+ chainId?: Wallet.Cardano.ChainId;
+ wallets$: Observable<
+ AnyWallet[]
+ >;
+ activeWalletId$: Readonly;
+ addAccount: (props: Readonly) => Promise;
+ activateAccount: (
+ props: Readonly,
+ force: boolean,
+ ) => Promise;
+ removeAccount: (
+ props: Readonly,
+ ) => Promise;
+ removeWallet: (props: WalletId) => Promise;
+ updateAccountMetadata: (
+ props: Readonly>,
+ ) => Promise>;
+}
+
+export interface Account {
+ index: number;
+ walletId: string;
+ name: string;
+ avatar?: string;
+ balance?: string;
+ recentSendToAddress?: string;
+ hw?: boolean;
+}
+
+export interface UseAccount {
+ allAccounts: Account[];
+ activeAccount: Account;
+ nonActiveAccounts: Account[];
+ nextIndex: number;
+ addAccount: (
+ props: Readonly<{ index: number; name: string; passphrase: Uint8Array }>,
+ ) => Promise;
+ activateAccount: (
+ props: Readonly<{
+ accountIndex: number;
+ walletId?: WalletId;
+ force?: boolean;
+ }>,
+ ) => Promise;
+ removeAccount: (
+ props: Readonly<{
+ accountIndex: number;
+ walletId?: WalletId;
+ }>,
+ ) => Promise;
+ updateAccountMetadata: (
+ data: Readonly<{
+ name?: string;
+ namiMode?: Partial;
+ }>,
+ ) => Promise | undefined>;
+}
+
+const getActiveAccountMetadata = ({
+ walletId,
+ accountIndex,
+ wallets,
+}: Readonly<{
+ walletId: WalletId;
+ accountIndex: number;
+ wallets: Readonly[]>;
+}>): Wallet.AccountMetadata => {
+ const wallet = wallets.find(elm => elm.walletId === walletId);
+ const accounts = wallet && 'accounts' in wallet ? wallet.accounts : [];
+ return (
+ accounts.find(acc => acc.accountIndex === accountIndex)?.metadata ?? {
+ name: '',
+ }
+ );
+};
+
+const getNextAccountIndex = (
+ accounts: readonly Account[],
+ activeAccount: Readonly,
+) => {
+ const walletAccounts = accounts.filter(
+ a => a.walletId === activeAccount.walletId,
+ );
+
+ for (const [index, account] of walletAccounts.entries()) {
+ if (account.index !== index) {
+ return index;
+ }
+ }
+
+ return walletAccounts.length;
+};
+
+const getAcountsMapper =
+ (
+ wallet: Readonly>,
+ ) =>
+ ({
+ accountIndex,
+ metadata,
+ }: Readonly>) => ({
+ index: accountIndex,
+ walletId: wallet.walletId,
+ name: metadata?.name || `${wallet.type} ${accountIndex}`,
+ hw: wallet.type === WalletType.Ledger || wallet.type === WalletType.Trezor,
+ ...metadata.namiMode,
+ });
+
+export const useAccount = ({
+ chainId = Wallet.Cardano.ChainIds.Mainnet,
+ wallets$,
+ activeWalletId$,
+ addAccount,
+ activateAccount,
+ removeAccount,
+ removeWallet,
+ updateAccountMetadata,
+}: Readonly): UseAccount => {
+ const activeWallet = useObservable(activeWalletId$);
+ const wallets = useObservable(wallets$);
+ const { walletId, accountIndex } = activeWallet ?? {};
+
+ const allAccountsSorted = useMemo(() => {
+ const allWallets = wallets?.filter(
+ (w): w is HardwareWallet =>
+ w.type !== WalletType.Script,
+ );
+ const groupedWallets = groupBy(allWallets, ({ type }) => type);
+ return flatten(
+ Object.entries(groupedWallets)
+ .sort(([type1], [type2]) => {
+ if (type1 === WalletType.InMemory && type2 !== WalletType.InMemory)
+ return -1;
+ if (type2 === WalletType.InMemory && type1 !== WalletType.InMemory)
+ return 1;
+ return 0;
+ })
+ .map(([_type, wallets]) => {
+ const wallet =
+ wallets.find(w => w.walletId === walletId) ?? wallets[0];
+ const accountsMapper = getAcountsMapper(wallet);
+ return 'accounts' in wallet
+ ? wallet.accounts
+ // eslint-disable-next-line functional/prefer-tacit
+ .map(account => accountsMapper(account))
+ .sort((a, b) => a.index - b.index)
+ : [];
+ }),
+ );
+ }, [wallets, walletId, accountIndex]);
+
+ const activeAccount = useMemo(
+ () =>
+ allAccountsSorted.find(
+ ({ index, walletId }) =>
+ accountIndex === index && walletId === activeWallet?.walletId,
+ ) ??
+ allAccountsSorted[0] ??
+ {},
+ [allAccountsSorted, accountIndex, activeWallet?.walletId],
+ );
+
+ return {
+ allAccounts: allAccountsSorted,
+ activeAccount,
+ nextIndex: useMemo(
+ () => getNextAccountIndex(allAccountsSorted, activeAccount),
+ [allAccountsSorted, activeAccount],
+ ),
+ nonActiveAccounts: useMemo(
+ () =>
+ allAccountsSorted.filter(
+ account =>
+ account.walletId !== walletId || account.index !== accountIndex,
+ ),
+ [allAccountsSorted, accountIndex, walletId],
+ ),
+ addAccount: useCallback(
+ async ({ index, name, passphrase }) => {
+ const wallet = wallets?.find(elm => elm.walletId === walletId);
+ if (wallet === undefined || wallet.type === WalletType.Script) {
+ return;
+ }
+ await addAccount({
+ accountIndex: index,
+ wallet,
+ metadata: { name },
+ passphrase,
+ });
+ },
+ [wallets, addAccount],
+ ),
+ removeAccount: useCallback(
+ async ({ accountIndex, walletId }) => {
+ if (walletId === undefined) {
+ return;
+ }
+ const isLastAccount = !allAccountsSorted.some(
+ a => a.walletId === walletId && a.index !== accountIndex,
+ );
+ console.log(isLastAccount, { accountIndex, walletId });
+ await (isLastAccount
+ ? removeWallet(walletId)
+ : removeAccount({ accountIndex, walletId }));
+ },
+ [removeAccount, walletId, allAccountsSorted],
+ ),
+ activateAccount: useCallback(
+ async props => {
+ if (walletId === undefined) {
+ return;
+ }
+ await activateAccount(
+ {
+ chainId,
+ accountIndex: props.accountIndex,
+ walletId: props.walletId ?? walletId,
+ },
+ props.force ?? false,
+ );
+ },
+ [activateAccount, walletId, chainId],
+ ),
+ updateAccountMetadata: useCallback(
+ async data => {
+ if (walletId === undefined || accountIndex === undefined) {
+ return;
+ }
+
+ const metadata = getActiveAccountMetadata({
+ accountIndex,
+ walletId,
+ wallets,
+ });
+ const updatedMetadata = merge({ ...metadata }, data);
+
+ return updateAccountMetadata({
+ walletId,
+ accountIndex,
+ metadata: updatedMetadata,
+ });
+ },
+ [wallets, walletId, accountIndex, activeAccount],
+ ),
+ };
+};
diff --git a/packages/nami/src/adapters/assets.test.ts b/packages/nami/src/adapters/assets.test.ts
new file mode 100644
index 000000000..ceb41bd91
--- /dev/null
+++ b/packages/nami/src/adapters/assets.test.ts
@@ -0,0 +1,718 @@
+/* eslint-disable unicorn/no-null */
+import { Wallet } from '@lace/cardano';
+import { renderHook } from '@testing-library/react-hooks';
+import { of } from 'rxjs';
+
+import * as utils from './assets';
+
+import type { Asset } from '../types/assets';
+
+const testCases = [
+ [
+ [
+ {
+ assetId:
+ 'f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a000de140736b7977616c6b6572',
+ fingerprint: 'asset1dp5v4kerx7gjdrpphmsuway8enkuk3zlkkg4pg',
+ name: '000de140736b7977616c6b6572',
+ policyId: 'f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a',
+ quantity: '1',
+ supply: '1',
+ nftMetadata: {
+ image: 'ipfs://zb2rhmjYFwxEiTjU5TgWpVWQMj4Dz14LBurTZrZfs8u4epV5m',
+ mediaType: 'image/jpeg',
+ name: '$skywalker',
+ otherProperties: {},
+ version: '1.0',
+ },
+ tokenMetadata: null,
+ },
+ BigInt(1),
+ ],
+ {
+ name: 'skywalker',
+ labeledName: '(222) skywalker',
+ displayName: '$skywalker',
+ fingerprint: 'asset1dp5v4kerx7gjdrpphmsuway8enkuk3zlkkg4pg',
+ policy: 'f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a',
+ quantity: '1',
+ unit: 'f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a000de140736b7977616c6b6572',
+ decimals: 0,
+ image:
+ 'https://ipfs.blockfrost.dev/ipfs/zb2rhmjYFwxEiTjU5TgWpVWQMj4Dz14LBurTZrZfs8u4epV5m',
+ },
+ ],
+ [
+ [
+ {
+ assetId:
+ 'aa0f536f65c1ffd33001a831c418f1e2f3105cfd9741bbcb6202aedc001bc280676f6f7365',
+ fingerprint: 'asset1mrzfck4qrv0yzn70lwjd6ar93vth4maa6qqs2k',
+ name: '001bc280676f6f7365',
+ policyId: 'aa0f536f65c1ffd33001a831c418f1e2f3105cfd9741bbcb6202aedc',
+ quantity: '600',
+ supply: '600',
+ nftMetadata: null,
+ tokenMetadata: null,
+ },
+ BigInt('1'),
+ ],
+ {
+ name: 'goose',
+ labeledName: '(444) goose',
+ displayName: 'goose',
+ fingerprint: 'asset1mrzfck4qrv0yzn70lwjd6ar93vth4maa6qqs2k',
+ policy: 'aa0f536f65c1ffd33001a831c418f1e2f3105cfd9741bbcb6202aedc',
+ quantity: '1',
+ unit: 'aa0f536f65c1ffd33001a831c418f1e2f3105cfd9741bbcb6202aedc001bc280676f6f7365',
+ decimals: 0,
+ image: null,
+ },
+ ],
+ [
+ [
+ {
+ assetId:
+ '659ab0b5658687c2e74cd10dba8244015b713bf503b90557769d77a757696e67526964657273',
+ fingerprint: 'asset1sjk0uucljv4qxxnhq8gjy7r5mar64erhfuh4q8',
+ name: '57696e67526964657273',
+ policyId: '659ab0b5658687c2e74cd10dba8244015b713bf503b90557769d77a7',
+ quantity: '100000000000000',
+ supply: '100000000000000',
+ nftMetadata: null,
+ tokenMetadata: {
+ name: 'WingRiders Preprod Governance Token',
+ url: 'https://app.preprod.wingriders.com',
+ desc: 'WingRiders is a decentralized exchange protocol on Cardano. WRT provides access to dao voting and other DEX related functions.',
+ ticker: 'tWRT',
+ assetId:
+ '659ab0b5658687c2e74cd10dba8244015b713bf503b90557769d77a757696e67526964657273',
+ decimals: 6,
+ },
+ },
+ BigInt('38709316'),
+ ],
+ {
+ name: 'WingRiders',
+ labeledName: 'WingRiders',
+ displayName: 'WingRiders Preprod Governance Token',
+ fingerprint: 'asset1sjk0uucljv4qxxnhq8gjy7r5mar64erhfuh4q8',
+ policy: '659ab0b5658687c2e74cd10dba8244015b713bf503b90557769d77a7',
+ quantity: '38709316',
+ unit: '659ab0b5658687c2e74cd10dba8244015b713bf503b90557769d77a757696e67526964657273',
+ decimals: 6,
+ image: null,
+ },
+ ],
+ [
+ [
+ {
+ assetId:
+ '8309083434b10b3af5e2b0da6214ac17e5989b4b7ccde44f157270a854657374',
+ fingerprint: 'asset10jjmqtkt08mzrpa5w7d9sn5dlus8rp75vt0ta5',
+ name: '54657374',
+ policyId: '8309083434b10b3af5e2b0da6214ac17e5989b4b7ccde44f157270a8',
+ quantity: '1',
+ supply: '1',
+ nftMetadata: {
+ description: 'test',
+ files: [
+ {
+ mediaType: 'image/jpeg',
+ name: 'test',
+ src: 'ipfs://QmNeYAJ98duYmZnesM4m2TDQxc8vGfv225fcTTa1UMn2kL',
+ },
+ ],
+ image: 'ipfs://QmNeYAJ98duYmZnesM4m2TDQxc8vGfv225fcTTa1UMn2kL',
+ mediaType: 'image/jpeg',
+ name: 'test',
+ version: '1.0',
+ },
+ tokenMetadata: null,
+ },
+ BigInt('1'),
+ ],
+ {
+ name: 'Test',
+ labeledName: 'Test',
+ displayName: 'test',
+ fingerprint: 'asset10jjmqtkt08mzrpa5w7d9sn5dlus8rp75vt0ta5',
+ policy: '8309083434b10b3af5e2b0da6214ac17e5989b4b7ccde44f157270a8',
+ quantity: '1',
+ unit: '8309083434b10b3af5e2b0da6214ac17e5989b4b7ccde44f157270a854657374',
+ decimals: 0,
+ image:
+ 'https://ipfs.blockfrost.dev/ipfs/QmNeYAJ98duYmZnesM4m2TDQxc8vGfv225fcTTa1UMn2kL',
+ },
+ ],
+ [
+ [
+ {
+ assetId:
+ 'e16c2dc8ae937e8d3790c7fd7168d7b994621ba14ca11415f39fed724d494e',
+ fingerprint: 'asset1marrj9cp99pa9ag4evkucsrj6uk0vckecktksl',
+ name: '4d494e',
+ policyId: 'e16c2dc8ae937e8d3790c7fd7168d7b994621ba14ca11415f39fed72',
+ quantity: '45000000000000000',
+ supply: '45000000000000000',
+ nftMetadata: null,
+ tokenMetadata: null,
+ },
+ BigInt('67280096'),
+ ],
+ {
+ name: 'MIN',
+ labeledName: 'MIN',
+ displayName: 'MIN',
+ fingerprint: 'asset1marrj9cp99pa9ag4evkucsrj6uk0vckecktksl',
+ policy: 'e16c2dc8ae937e8d3790c7fd7168d7b994621ba14ca11415f39fed72',
+ quantity: '67280096',
+ unit: 'e16c2dc8ae937e8d3790c7fd7168d7b994621ba14ca11415f39fed724d494e',
+ decimals: 0,
+ image: null,
+ },
+ ],
+ [
+ [
+ {
+ assetId:
+ '359289937f6cd0478f2c0737eed4ba879725c09d9d80caeeadf4a67f6261746d616e',
+ fingerprint: 'asset14amr4cepgv90u862p845l0vesxv2xpjqk4nup7',
+ name: '6261746d616e',
+ policyId: '359289937f6cd0478f2c0737eed4ba879725c09d9d80caeeadf4a67f',
+ quantity: '1',
+ supply: '1',
+ nftMetadata: null,
+ tokenMetadata: null,
+ },
+ BigInt('1'),
+ ],
+ {
+ name: 'batman',
+ labeledName: 'batman',
+ displayName: 'batman',
+ fingerprint: 'asset14amr4cepgv90u862p845l0vesxv2xpjqk4nup7',
+ policy: '359289937f6cd0478f2c0737eed4ba879725c09d9d80caeeadf4a67f',
+ quantity: '1',
+ unit: '359289937f6cd0478f2c0737eed4ba879725c09d9d80caeeadf4a67f6261746d616e',
+ decimals: 0,
+ image: null,
+ },
+ ],
+ [
+ [
+ {
+ assetId:
+ '4298bc56195ebed886f2172eb0352a26611ce34f4482a3ee3cc0f39574657374',
+ fingerprint: 'asset1td7qdtk2rmyktdcezzv33askkuddh8a4sl46jj',
+ name: '74657374',
+ policyId: '4298bc56195ebed886f2172eb0352a26611ce34f4482a3ee3cc0f395',
+ quantity: '1',
+ supply: '1',
+ nftMetadata: {
+ image: [
+ 'ipfs://test',
+ 'asset1td7qdtk2rmyktdcezzv33askkuddh8a4sl46jj',
+ ],
+ },
+ tokenMetadata: null,
+ },
+ BigInt('1'),
+ ],
+ {
+ name: 'test',
+ labeledName: 'test',
+ displayName: 'test',
+ fingerprint: 'asset1td7qdtk2rmyktdcezzv33askkuddh8a4sl46jj',
+ policy: '4298bc56195ebed886f2172eb0352a26611ce34f4482a3ee3cc0f395',
+ quantity: '1',
+ unit: '4298bc56195ebed886f2172eb0352a26611ce34f4482a3ee3cc0f39574657374',
+ decimals: 0,
+ image:
+ 'https://ipfs.blockfrost.dev/ipfs/testasset1td7qdtk2rmyktdcezzv33askkuddh8a4sl46jj',
+ },
+ ],
+ [
+ [
+ {
+ assetId:
+ 'f0d1923af53e2b0c25cecdd8efba5672897f89479fa18acf5ff7eb2a4e46542d66696c6573',
+ fingerprint: 'asset1xucvq2hkl0n4y2fnqxkjynp6l57zdmus9uuyhr',
+ name: '4e46542d66696c6573',
+ policyId: 'f0d1923af53e2b0c25cecdd8efba5672897f89479fa18acf5ff7eb2a',
+ quantity: '1',
+ supply: '1',
+ nftMetadata: {
+ description: 'NFT with different types of files',
+ files: [
+ {
+ mediaType: 'video/mp4',
+ name: 'some name',
+ src: 'ipfs://Qmb78QQ4RXxKQrteRn4X3WaMXXfmi2BU2dLjfWxuJoF2N5',
+ },
+ {
+ mediaType: 'audio/mpeg',
+ name: 'some name',
+ src: 'ipfs://Qmb78QQ4RXxKQrteRn4X3WaMXXfmi2BU2dLjfWxuJoF2Ny',
+ },
+ ],
+ image: 'ipfs://somehash',
+ mediaType: 'image/png',
+ name: 'NFT with files',
+ otherProperties: {},
+ version: '1.0',
+ },
+ tokenMetadata: null,
+ },
+ BigInt('1'),
+ ],
+ {
+ name: 'NFT-files',
+ labeledName: 'NFT-files',
+ displayName: 'NFT with files',
+ fingerprint: 'asset1xucvq2hkl0n4y2fnqxkjynp6l57zdmus9uuyhr',
+ policy: 'f0d1923af53e2b0c25cecdd8efba5672897f89479fa18acf5ff7eb2a',
+ quantity: '1',
+ unit: 'f0d1923af53e2b0c25cecdd8efba5672897f89479fa18acf5ff7eb2a4e46542d66696c6573',
+ decimals: 0,
+ image: 'https://ipfs.blockfrost.dev/ipfs/somehash',
+ },
+ ],
+ [
+ [
+ {
+ assetId:
+ '171163f05e4f30b6be3c22668c37978e7d508b84f83558e523133cdf74454d50',
+ fingerprint: 'asset1n3h47u7gxcvh0ldfw7pz0k2d0qem6kvrwuvtu7',
+ name: '74454d50',
+ policyId: '171163f05e4f30b6be3c22668c37978e7d508b84f83558e523133cdf',
+ quantity: '200000000000000',
+ supply: '200000000000000',
+ nftMetadata: null,
+ tokenMetadata: {
+ name: 'tEMP',
+ url: 'https://empowa.io',
+ desc: 'Testnet version of the Empowa utility token (EMP).',
+ ticker: 'tEMP',
+ icon: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
+ assetId:
+ '171163f05e4f30b6be3c22668c37978e7d508b84f83558e523133cdf74454d50',
+ decimals: 6,
+ },
+ },
+ BigInt('49433000000'),
+ ],
+ {
+ name: 'tEMP',
+ labeledName: 'tEMP',
+ displayName: 'tEMP',
+ fingerprint: 'asset1n3h47u7gxcvh0ldfw7pz0k2d0qem6kvrwuvtu7',
+ policy: '171163f05e4f30b6be3c22668c37978e7d508b84f83558e523133cdf',
+ quantity: '49433000000',
+ unit: '171163f05e4f30b6be3c22668c37978e7d508b84f83558e523133cdf74454d50',
+ decimals: 6,
+ image:
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
+ },
+ ],
+ [
+ [
+ {
+ assetId:
+ '28c380222b13ebd7ac76d751e54205179ea065f90acbc95ecafad129576f726c64204d6f62696c65205365656420546573742031353735',
+ fingerprint: 'asset1dzkanr70q3kcfry9atzkhv6xus57zz033x9q9n',
+ name: '576f726c64204d6f62696c65205365656420546573742031353735',
+ policyId: '28c380222b13ebd7ac76d751e54205179ea065f90acbc95ecafad129',
+ quantity: '1',
+ supply: '1',
+ nftMetadata: {
+ description:
+ 'A RealFi NFT with Real World Impact in Housing and Connectivity',
+ files: [
+ {
+ mediaType: 'video/mp4',
+ name: 'Seed Animation',
+ src: 'ipfs://QmeajbzNQ9j6e3kqrxC55n7eXRn2dmU2w6LNSgVVZJr49o',
+ },
+ {
+ mediaType: 'text/plain',
+ name: 'NFT Sale Terms',
+ src: 'ipfs://QmcfUQJg8J8u48XE5stTEDTFTYLe7q3sWfQBBB2xmxrxEw',
+ },
+ ],
+ image: 'ipfs://QmRm3EMVM1DgPYKPenxRXq6rd8ZUioDfjmV64yejUGS46H',
+ mediaType: 'image/jpeg',
+ name: 'World Mobile Seed Test 1575',
+ otherProperties: {},
+ version: '1.0',
+ },
+ tokenMetadata: null,
+ },
+ BigInt('1'),
+ ],
+ {
+ name: 'World Mobile Seed Test 1575',
+ labeledName: 'World Mobile Seed Test 1575',
+ displayName: 'World Mobile Seed Test 1575',
+ fingerprint: 'asset1dzkanr70q3kcfry9atzkhv6xus57zz033x9q9n',
+ policy: '28c380222b13ebd7ac76d751e54205179ea065f90acbc95ecafad129',
+ quantity: '1',
+ unit: '28c380222b13ebd7ac76d751e54205179ea065f90acbc95ecafad129576f726c64204d6f62696c65205365656420546573742031353735',
+ decimals: 0,
+ image:
+ 'https://ipfs.blockfrost.dev/ipfs/QmRm3EMVM1DgPYKPenxRXq6rd8ZUioDfjmV64yejUGS46H',
+ },
+ ],
+];
+
+describe('toAsset', () => {
+ for (const [[asset, quantity], output] of testCases) {
+ it(`should convert asset info with assetId ${asset.assetId}`, () => {
+ const result = utils.toAsset(asset, quantity);
+
+ expect(result).toMatchObject(output);
+ });
+ }
+});
+
+describe('useAssets', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ it('should return empty list if there are no assets and no coins', () => {
+ const total = of({
+ coins: BigInt(0),
+ assets: undefined,
+ });
+ const assetInfo = of(new Map());
+ const balance = {
+ totalCoins: BigInt(0),
+ unspendableCoins: BigInt(0),
+ lockedCoins: BigInt(0),
+ };
+ const { result } = renderHook((...props) =>
+ utils.useAssets({
+ inMemoryWallet: {
+ balance: {
+ utxo: {
+ total$: total,
+ },
+ },
+ assetInfo$: assetInfo,
+ } as unknown as Wallet.ObservableWallet,
+ balance,
+ ...props,
+ }),
+ );
+
+ expect(result.current.assets).toMatchObject([]);
+ expect(result.current.nfts).toMatchObject([]);
+ });
+
+ it('should return empty list if there are no coins and assets list is empty', () => {
+ const total = of({
+ coins: BigInt(0),
+ assets: new Map(),
+ });
+ const assetInfo = of(new Map());
+ const balance = {
+ totalCoins: BigInt(0),
+ unspendableCoins: BigInt(0),
+ lockedCoins: BigInt(0),
+ };
+ const { result } = renderHook((...props) =>
+ utils.useAssets({
+ inMemoryWallet: {
+ balance: {
+ utxo: {
+ total$: total,
+ },
+ },
+ assetInfo$: assetInfo,
+ } as unknown as Wallet.ObservableWallet,
+ balance,
+ ...props,
+ }),
+ );
+
+ expect(result.current.assets).toMatchObject([]);
+ expect(result.current.nfts).toMatchObject([]);
+ });
+
+ it('should return empty list if there are no matching assets info', () => {
+ const total = of({
+ coins: BigInt(0),
+ assets: new Map([
+ [
+ Wallet.Cardano.AssetId(
+ '6b8d07d69639e9413dd637a1a815a7323c69c86abbafb66dbfdb1aa7',
+ ),
+ BigInt(2_000_000),
+ ],
+ ]),
+ });
+ const assetInfo = of(new Map());
+ const balance = {
+ totalCoins: BigInt(0),
+ unspendableCoins: BigInt(0),
+ lockedCoins: BigInt(0),
+ };
+ const { result } = renderHook((...props) =>
+ utils.useAssets({
+ inMemoryWallet: {
+ balance: {
+ utxo: {
+ total$: total,
+ },
+ },
+ assetInfo$: assetInfo,
+ } as unknown as Wallet.ObservableWallet,
+ balance,
+ ...props,
+ }),
+ );
+
+ expect(result.current.assets).toMatchObject([]);
+ expect(result.current.nfts).toMatchObject([]);
+ });
+
+ it('should return proper nfts list if there is only asset which is of nft type', () => {
+ const assetID = Wallet.Cardano.AssetId(
+ '6b8d07d69639e9413dd637a1a815a7323c69c86abbafb66dbfdb1aa7',
+ );
+ const toAssetResult =
+ `toAssetResult${assetID.toString()}` as unknown as Asset;
+ const spy = jest.spyOn(utils, 'toAsset');
+ spy.mockReturnValue(toAssetResult);
+
+ const total = of({
+ coins: BigInt(0),
+ assets: new Map([[assetID, BigInt(2_000_000)]]),
+ });
+ const assetInfo = of(
+ new Map([
+ [
+ assetID,
+ {
+ supply: BigInt(1),
+ nftMetadata: true,
+ assetId:
+ 'f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a000de1406b6c6f73',
+ },
+ ],
+ ]) as unknown as Wallet.Assets,
+ );
+ const balance = {
+ totalCoins: BigInt(0),
+ unspendableCoins: BigInt(0),
+ lockedCoins: BigInt(0),
+ };
+ const { result } = renderHook((...props) =>
+ utils.useAssets({
+ inMemoryWallet: {
+ balance: {
+ utxo: {
+ total$: total,
+ },
+ },
+ assetInfo$: assetInfo,
+ } as unknown as Wallet.ObservableWallet,
+ balance,
+ ...props,
+ }),
+ );
+
+ expect(result.current.assets).toMatchObject([]);
+ expect(result.current.nfts).toMatchObject([toAssetResult]);
+ spy.mockRestore();
+ });
+
+ it('should return empty list if there is only asset with zero balance', () => {
+ const assetID = Wallet.Cardano.AssetId(
+ '6b8d07d69639e9413dd637a1a815a7323c69c86abbafb66dbfdb1aa7',
+ );
+
+ const total = of({
+ coins: BigInt(0),
+ assets: new Map([[assetID, BigInt(0)]]),
+ });
+ const assetInfo = of(
+ new Map([[assetID, { supply: BigInt(1) }]]) as unknown as Wallet.Assets,
+ );
+ const balance = {
+ totalCoins: BigInt(0),
+ unspendableCoins: BigInt(0),
+ lockedCoins: BigInt(0),
+ };
+ const { result } = renderHook((...props) =>
+ utils.useAssets({
+ inMemoryWallet: {
+ balance: {
+ utxo: {
+ total$: total,
+ },
+ },
+ assetInfo$: assetInfo,
+ } as unknown as Wallet.ObservableWallet,
+ balance,
+ ...props,
+ }),
+ );
+
+ expect(result.current.assets).toMatchObject([]);
+ expect(result.current.nfts).toMatchObject([]);
+ });
+
+ it('should return cardano as an asset in the list', () => {
+ const total = of({
+ coins: BigInt(0),
+ assets: new Map(),
+ });
+ const assetInfo = of(new Map());
+ const balance = {
+ totalCoins: BigInt(1),
+ unspendableCoins: BigInt(0),
+ lockedCoins: BigInt(0),
+ };
+ const { result } = renderHook((...props) =>
+ utils.useAssets({
+ inMemoryWallet: {
+ balance: {
+ utxo: {
+ total$: total,
+ },
+ },
+ assetInfo$: assetInfo,
+ } as unknown as Wallet.ObservableWallet,
+ balance,
+ ...props,
+ }),
+ );
+
+ expect(result.current.assets).toMatchObject([
+ {
+ unit: 'lovelace',
+ quantity: (
+ balance.totalCoins -
+ balance.lockedCoins -
+ balance.unspendableCoins
+ ).toString(),
+ },
+ ]);
+ expect(result.current.nfts).toMatchObject([]);
+ });
+
+ it('should return properly mapped asset in the list', () => {
+ const assetID = Wallet.Cardano.AssetId(
+ '6b8d07d69639e9413dd637a1a815a7323c69c86abbafb66dbfdb1aa7',
+ );
+ const toAssetResult =
+ `toAssetResult${assetID.toString()}` as unknown as Asset;
+ const spy = jest.spyOn(utils, 'toAsset');
+ spy.mockReturnValue(toAssetResult);
+
+ const total = of({
+ coins: BigInt(0),
+ assets: new Map([[assetID, BigInt(2_000_000)]]),
+ });
+ const assetInfo = of(
+ new Map([
+ [assetID, { supply: BigInt(2), assetId: '' }],
+ ]) as unknown as Wallet.Assets,
+ );
+ const balance = {
+ totalCoins: BigInt(0),
+ unspendableCoins: BigInt(0),
+ lockedCoins: BigInt(0),
+ };
+ const { result } = renderHook((...props) =>
+ utils.useAssets({
+ inMemoryWallet: {
+ balance: {
+ utxo: {
+ total$: total,
+ },
+ },
+ assetInfo$: assetInfo,
+ } as unknown as Wallet.ObservableWallet,
+ balance,
+ ...props,
+ }),
+ );
+
+ expect(result.current.assets).toMatchObject([toAssetResult]);
+ expect(result.current.nfts).toMatchObject([]);
+ spy.mockRestore();
+ });
+});
+
+describe('searchTokens', () => {
+ it('should return empty list if there are no assets matching search criteria', () => {
+ const assets = [
+ {
+ name: 'name',
+ displayName: 'displayName',
+ policy: 'policy',
+ fingerprint: 'fingerprint',
+ },
+ ] as Asset[];
+
+ expect(utils.searchTokens(assets, 'value')).toMatchObject([]);
+ });
+
+ it('should return an item if there is a match by name field', () => {
+ const search = 'somestring';
+ const assets = [
+ {
+ name: `na${search}me`,
+ displayName: 'displayName',
+ policy: 'policy',
+ fingerprint: 'fingerprint',
+ },
+ ] as Asset[];
+
+ expect(utils.searchTokens(assets, search)).toMatchObject(assets);
+ });
+
+ it('should return an item if there is a match by displayName field', () => {
+ const search = 'somestring';
+ const assets = [
+ {
+ name: 'name',
+ displayName: `displa${search}yName`,
+ policy: 'policy',
+ fingerprint: 'fingerprint',
+ },
+ ] as Asset[];
+
+ expect(utils.searchTokens(assets, search)).toMatchObject(assets);
+ });
+
+ it('should return an item if there is a match by policy field', () => {
+ const search = 'somestring';
+ const assets = [
+ {
+ name: 'name',
+ displayName: 'displayName',
+ policy: `pol${search}icy`,
+ fingerprint: 'fingerprint',
+ },
+ ] as Asset[];
+
+ expect(utils.searchTokens(assets, search)).toMatchObject(assets);
+ });
+
+ it('should return an item if there is a match by fingerprint field', () => {
+ const search = 'somestring';
+ const assets = [
+ {
+ name: 'name',
+ displayName: 'displayName',
+ policy: 'policy',
+ fingerprint: `finge${search}rprint`,
+ },
+ ] as Asset[];
+
+ expect(utils.searchTokens(assets, search)).toMatchObject(assets);
+ });
+});
diff --git a/packages/nami/src/adapters/assets.ts b/packages/nami/src/adapters/assets.ts
new file mode 100644
index 000000000..3154b9df0
--- /dev/null
+++ b/packages/nami/src/adapters/assets.ts
@@ -0,0 +1,138 @@
+import { useEffect, useState } from 'react';
+
+import { useObservable } from '@lace/common';
+import isNil from 'lodash/isNil';
+
+import {
+ convertMetadataPropToString,
+ fromAssetUnit,
+ linkToSrc,
+} from '../api/util';
+
+import type { useBalance } from './balance';
+import type { Asset as NamiAsset, CardanoAsset } from '../types/assets';
+import type { Asset, Cardano } from '@cardano-sdk/core';
+import type { HandleInfo, Assets } from '@cardano-sdk/wallet';
+import type { Wallet } from '@lace/cardano';
+
+export type AssetOrHandleInfo = Asset.AssetInfo | HandleInfo;
+export type AssetOrHandleInfoMap = Map;
+
+export const withHandleInfo = (
+ assets: Readonly,
+ handles: HandleInfo[] = [],
+): AssetOrHandleInfoMap => {
+ const assetsWithHandleInfo: AssetOrHandleInfoMap = new Map(assets);
+
+ for (const handle of handles) {
+ if (assetsWithHandleInfo.has(handle.assetId)) {
+ assetsWithHandleInfo.set(handle.assetId, handle);
+ }
+ }
+
+ return assetsWithHandleInfo;
+};
+
+export const toAsset = (
+ assetInfo: Readonly,
+ quantity: bigint,
+): Readonly => {
+ const { name, label } = fromAssetUnit(assetInfo.assetId);
+ const bufferName = Buffer.from(name, 'hex').toString();
+
+ const labeledName = Number.isInteger(label)
+ ? `(${label}) ${bufferName}`
+ : bufferName;
+ const displayName =
+ assetInfo.tokenMetadata?.name ?? assetInfo.nftMetadata?.name ?? bufferName;
+
+ let image = assetInfo.tokenMetadata?.icon ?? assetInfo.nftMetadata?.image;
+ if ('handle' in assetInfo) {
+ image = assetInfo.image ?? image;
+ }
+
+ return {
+ name: bufferName,
+ labeledName,
+ displayName,
+ fingerprint: assetInfo.fingerprint,
+ policy: assetInfo.policyId,
+ quantity: quantity.toString(),
+ unit: assetInfo.assetId,
+ decimals: assetInfo.tokenMetadata?.decimals ?? 0,
+ image: linkToSrc(
+ convertMetadataPropToString(image) ?? '',
+ Boolean(assetInfo.tokenMetadata?.icon),
+ ),
+ };
+};
+
+export const isNFT = (assetInfo: Readonly): boolean =>
+ (assetInfo?.nftMetadata && !fromAssetUnit(assetInfo?.assetId).label) ||
+ fromAssetUnit(assetInfo?.assetId).label === 222;
+
+interface Props {
+ inMemoryWallet: Wallet.ObservableWallet;
+ balance: ReturnType;
+}
+
+export const useAssets = ({ inMemoryWallet, balance }: Readonly) => {
+ const [fullAssetList, setFullAssetList] = useState<{
+ assets: (CardanoAsset | NamiAsset)[];
+ nfts: NamiAsset[];
+ }>({ assets: [], nfts: [] });
+ const utxoTotal = useObservable(inMemoryWallet.balance.utxo.total$);
+ const assetsInfo = useObservable(inMemoryWallet.assetInfo$);
+
+ useEffect(() => {
+ const tokens: NamiAsset[] = [];
+ const nfts: NamiAsset[] = [];
+
+ if (!isNil(utxoTotal?.assets) && utxoTotal?.assets?.size > 0) {
+ for (const [assetId, assetBalance] of utxoTotal.assets) {
+ const assetInfo = assetsInfo?.get(assetId);
+ if (assetBalance <= 0 || !assetInfo) continue;
+
+ const asset = toAsset(assetInfo, assetBalance);
+ if (isNFT(assetInfo)) {
+ nfts.push(asset);
+ } else {
+ tokens.push(asset);
+ }
+ }
+ }
+
+ const cardano =
+ balance.totalCoins > BigInt(0)
+ ? [
+ {
+ unit: 'lovelace',
+ quantity: (
+ balance.totalCoins -
+ balance.lockedCoins -
+ balance.unspendableCoins
+ ).toString(),
+ },
+ ]
+ : [];
+
+ setFullAssetList({ assets: [...cardano, ...tokens], nfts });
+ }, [assetsInfo, utxoTotal, balance?.totalCoins]);
+
+ return fullAssetList;
+};
+
+export const searchTokens = (
+ data: readonly NamiAsset[],
+ searchValue: string,
+) => {
+ const fields = ['name', 'displayName', 'policy', 'fingerprint'] as const;
+ const lowerSearchValue = searchValue.toLowerCase();
+
+ return data.filter(item =>
+ fields.some(
+ field =>
+ field in item && item[field]?.toLowerCase().includes(lowerSearchValue),
+ ),
+ );
+};
diff --git a/packages/nami/src/adapters/balance.test.ts b/packages/nami/src/adapters/balance.test.ts
new file mode 100644
index 000000000..d3cd4a66b
--- /dev/null
+++ b/packages/nami/src/adapters/balance.test.ts
@@ -0,0 +1,108 @@
+import { renderHook } from '@testing-library/react-hooks';
+import { of } from 'rxjs';
+
+import { useBalance } from './balance';
+
+import type { Wallet } from '@lace/cardano';
+
+describe('useBalance', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('should return initial state when observables are undefined', () => {
+ const { result } = renderHook(() =>
+ useBalance({
+ inMemoryWallet: {
+ balance: { utxo: {}, rewardAccounts: {} },
+ } as Wallet.ObservableWallet,
+ }),
+ );
+
+ expect(result.current).toEqual({
+ totalCoins: BigInt(0),
+ unspendableCoins: BigInt(0),
+ lockedCoins: BigInt(0),
+ });
+ });
+
+ test('should return total and unspendable coins when assets are undefined', () => {
+ const total$ = of({
+ coins: BigInt(5000),
+ assets: undefined,
+ });
+ const unspendable$ = of({
+ coins: BigInt(1000),
+ });
+ const rewards$ = of(BigInt(1000));
+
+ const addresses$ = of([{ address: 'some-address' }]);
+ const protocolParameters$ = of({
+ coinsPerUtxoByte: 100,
+ });
+
+ const { result } = renderHook(() =>
+ useBalance({
+ inMemoryWallet: {
+ balance: {
+ utxo: { total$, unspendable$ },
+ rewardAccounts: { rewards$ },
+ },
+ addresses$,
+ protocolParameters$,
+ } as Wallet.ObservableWallet,
+ }),
+ );
+
+ expect(result.current).toEqual({
+ totalCoins: BigInt(5000) + BigInt(1000),
+ unspendableCoins: BigInt(1000),
+ lockedCoins: BigInt(0),
+ });
+ });
+
+ test('should calculate locked coins when assets are present', () => {
+ const mockAssets = new Map([['asset1', BigInt(100)]]);
+ const total$ = of({ coins: BigInt(10_000), assets: mockAssets });
+ const rewards$ = of(BigInt(1000));
+ const unspendable$ = of({
+ coins: BigInt(1000),
+ });
+ const addresses$ = of([{ address: 'some-address' }]);
+ const protocolParameters$ = of({
+ coinsPerUtxoByte: 100,
+ });
+ const minAdaRequired = jest.fn().mockReturnValue(BigInt(1500));
+
+ const { result } = renderHook(() =>
+ useBalance({
+ minAdaRequired,
+ inMemoryWallet: {
+ balance: {
+ utxo: { total$, unspendable$ },
+ rewardAccounts: { rewards$ },
+ },
+ addresses$,
+ protocolParameters$,
+ } as Wallet.ObservableWallet,
+ }),
+ );
+
+ expect(minAdaRequired).toHaveBeenCalledWith(
+ {
+ address: 'some-address',
+ value: {
+ coins: BigInt(0),
+ assets: mockAssets,
+ },
+ },
+ BigInt(100),
+ );
+
+ expect(result.current).toEqual({
+ totalCoins: BigInt(10_000) + BigInt(1000),
+ unspendableCoins: BigInt(1000),
+ lockedCoins: BigInt(1500),
+ });
+ });
+});
diff --git a/packages/nami/src/adapters/balance.ts b/packages/nami/src/adapters/balance.ts
new file mode 100644
index 000000000..cc2b76dfb
--- /dev/null
+++ b/packages/nami/src/adapters/balance.ts
@@ -0,0 +1,92 @@
+import { minAdaRequired as minAdaRequiredSdk } from '@cardano-sdk/tx-construction';
+import { useObservable } from '@lace/common';
+
+import type { Wallet } from '@lace/cardano';
+
+interface Props {
+ inMemoryWallet: Wallet.ObservableWallet;
+ minAdaRequired?: (
+ output: Readonly,
+ coinsPerUtxoByte: bigint,
+ ) => bigint;
+}
+
+interface GetBalance {
+ rewards?: bigint;
+ total?: Wallet.Cardano.Value;
+ address?: Wallet.Cardano.PaymentAddress;
+ unspendable?: Wallet.Cardano.Value;
+ protocolParameters?: Wallet.Cardano.ProtocolParameters;
+ minAdaRequired?: (
+ output: Readonly,
+ coinsPerUtxoByte: bigint,
+ ) => bigint;
+}
+
+export const getBalance = ({
+ rewards,
+ address,
+ protocolParameters,
+ total,
+ unspendable,
+ minAdaRequired = minAdaRequiredSdk,
+}: Readonly) => {
+ if (
+ rewards === undefined ||
+ total === undefined ||
+ address === undefined ||
+ unspendable === undefined ||
+ protocolParameters === undefined
+ ) {
+ return {
+ totalCoins: BigInt(0),
+ unspendableCoins: BigInt(0),
+ lockedCoins: BigInt(0),
+ };
+ }
+
+ if (!total.assets || total.assets?.size === 0) {
+ return {
+ totalCoins: total.coins + rewards,
+ unspendableCoins: unspendable.coins,
+ lockedCoins: BigInt(0),
+ };
+ } else {
+ const outputs = {
+ address,
+ value: {
+ coins: BigInt(0),
+ assets: total.assets ?? new Map(),
+ },
+ };
+
+ return {
+ totalCoins: total.coins + rewards,
+ unspendableCoins: unspendable.coins,
+ lockedCoins: minAdaRequired(
+ outputs,
+ BigInt(protocolParameters.coinsPerUtxoByte),
+ ),
+ };
+ }
+};
+
+export const useBalance = ({
+ inMemoryWallet,
+ minAdaRequired = minAdaRequiredSdk,
+}: Readonly) => {
+ const rewards = useObservable(inMemoryWallet.balance.rewardAccounts.rewards$);
+ const total = useObservable(inMemoryWallet.balance.utxo.total$);
+ const unspendable = useObservable(inMemoryWallet.balance.utxo.unspendable$);
+ const addresses = useObservable(inMemoryWallet.addresses$);
+ const protocolParameters = useObservable(inMemoryWallet.protocolParameters$);
+
+ return getBalance({
+ address: addresses?.[0]?.address,
+ minAdaRequired,
+ protocolParameters,
+ rewards,
+ total,
+ unspendable,
+ });
+};
diff --git a/packages/nami/src/adapters/collateral.mock.ts b/packages/nami/src/adapters/collateral.mock.ts
new file mode 100644
index 000000000..aca50c840
--- /dev/null
+++ b/packages/nami/src/adapters/collateral.mock.ts
@@ -0,0 +1,11 @@
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+/* eslint-disable @typescript-eslint/no-unsafe-argument */
+import { fn } from '@storybook/test';
+
+import * as actualApi from './collateral';
+
+export * from './collateral';
+
+export const useCollateral = fn(actualApi.useCollateral).mockName(
+ 'useCollateral',
+);
diff --git a/packages/nami/src/adapters/collateral.test.ts b/packages/nami/src/adapters/collateral.test.ts
new file mode 100644
index 000000000..60f5c0284
--- /dev/null
+++ b/packages/nami/src/adapters/collateral.test.ts
@@ -0,0 +1,141 @@
+import { useObservable } from '@lace/common';
+import { renderHook } from '@testing-library/react-hooks';
+import { of } from 'rxjs';
+
+import { useCollateral } from './collateral';
+
+import type { Wallet } from '@lace/cardano';
+
+const mockSubmitCollateralTx = jest.fn().mockResolvedValue(undefined);
+const mockWithSignTxConfirmation = jest.fn().mockResolvedValue(undefined);
+const mockSetUnspendable = jest.fn().mockResolvedValue(undefined);
+
+describe('useCollateral', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('should return truthful hasCollateral value when unspendable coins value is equal to COLLATERAL_AMOUNT_LOVELACES', () => {
+ const unspendable$ = of({
+ coins: BigInt(5000),
+ });
+ const { result } = renderHook(() =>
+ useCollateral({
+ inMemoryWallet: {
+ utxo: {
+ setUnspendable: mockSetUnspendable,
+ },
+ balance: {
+ utxo: { unspendable$ },
+ },
+ } as unknown as Wallet.ObservableWallet,
+ submitCollateralTx: mockSubmitCollateralTx,
+ withSignTxConfirmation: mockWithSignTxConfirmation,
+ }),
+ );
+
+ expect(result.current.hasCollateral).toBeTruthy();
+ });
+
+ test('should return truthful hasCollateral value when unspendable coins value is greater than COLLATERAL_AMOUNT_LOVELACES', () => {
+ const unspendable$ = of({
+ coins: BigInt(5001),
+ });
+ const { result } = renderHook(() =>
+ useCollateral({
+ inMemoryWallet: {
+ utxo: {
+ setUnspendable: mockSetUnspendable,
+ },
+ balance: {
+ utxo: { unspendable$ },
+ },
+ } as unknown as Wallet.ObservableWallet,
+ submitCollateralTx: mockSubmitCollateralTx,
+ withSignTxConfirmation: mockWithSignTxConfirmation,
+ }),
+ );
+
+ expect(result.current.hasCollateral).toBeTruthy();
+ });
+
+ test('should return falsy hasCollateral value', () => {
+ const unspendable$ = of({
+ coins: BigInt(4999),
+ });
+ const { result } = renderHook(() =>
+ useCollateral({
+ inMemoryWallet: {
+ utxo: {
+ setUnspendable: mockSetUnspendable,
+ },
+ balance: {
+ utxo: { unspendable$ },
+ },
+ } as unknown as Wallet.ObservableWallet,
+ submitCollateralTx: mockSubmitCollateralTx,
+ withSignTxConfirmation: mockWithSignTxConfirmation,
+ }),
+ );
+
+ expect(result.current.hasCollateral).toBeFalsy();
+ });
+
+ test('should call setUnspendable with empty array when reclaiming collateral', async () => {
+ const unspendable$ = of({
+ coins: BigInt(4999),
+ });
+ const { result } = renderHook(() =>
+ useCollateral({
+ inMemoryWallet: {
+ utxo: {
+ setUnspendable: mockSetUnspendable,
+ },
+ balance: {
+ utxo: { unspendable$ },
+ },
+ } as unknown as Wallet.ObservableWallet,
+ submitCollateralTx: mockSubmitCollateralTx,
+ withSignTxConfirmation: mockWithSignTxConfirmation,
+ }),
+ );
+
+ expect(mockSetUnspendable).not.toBeCalled();
+
+ await result.current.reclaimCollateral();
+
+ expect(mockSetUnspendable).toBeCalledWith([]);
+ expect(mockSetUnspendable).toBeCalledTimes(1);
+ });
+
+ test('should call withSignTxConfirmation with proper props when submitting collateral', async () => {
+ const password = 'password';
+ const unspendable$ = of({
+ coins: BigInt(4999),
+ });
+ const { result } = renderHook(() =>
+ useCollateral({
+ inMemoryWallet: {
+ utxo: {
+ setUnspendable: mockSetUnspendable,
+ },
+ balance: {
+ utxo: { unspendable$ },
+ },
+ } as unknown as Wallet.ObservableWallet,
+ submitCollateralTx: mockSubmitCollateralTx,
+ withSignTxConfirmation: mockWithSignTxConfirmation,
+ }),
+ );
+
+ expect(mockWithSignTxConfirmation).not.toBeCalled();
+
+ await result.current.submitCollateral(password);
+
+ expect(mockWithSignTxConfirmation).toBeCalledWith(
+ mockSubmitCollateralTx,
+ password,
+ );
+ expect(mockWithSignTxConfirmation).toBeCalledTimes(1);
+ });
+});
diff --git a/packages/nami/src/adapters/collateral.ts b/packages/nami/src/adapters/collateral.ts
new file mode 100644
index 000000000..039efbed2
--- /dev/null
+++ b/packages/nami/src/adapters/collateral.ts
@@ -0,0 +1,38 @@
+import { useCallback, useMemo } from 'react';
+
+import { useObservable } from '@lace/common';
+
+import type { Wallet } from '@lace/cardano';
+
+export const COLLATERAL_AMOUNT_LOVELACES = BigInt(5000);
+
+interface Props {
+ inMemoryWallet: Wallet.ObservableWallet;
+ submitCollateralTx: () => Promise;
+ withSignTxConfirmation: (
+ action: () => Promise,
+ password?: string,
+ ) => Promise;
+}
+
+export const useCollateral = ({
+ inMemoryWallet,
+ submitCollateralTx,
+ withSignTxConfirmation,
+}: Readonly) => {
+ const unspendable = useObservable(inMemoryWallet.balance.utxo.unspendable$);
+ const hasCollateral = useMemo(
+ () => unspendable?.coins >= COLLATERAL_AMOUNT_LOVELACES,
+ [unspendable?.coins],
+ );
+ const reclaimCollateral = useCallback(async () => {
+ await inMemoryWallet.utxo.setUnspendable([]);
+ }, [inMemoryWallet.utxo]);
+ const submitCollateral = useCallback(
+ async (password: string) =>
+ withSignTxConfirmation(submitCollateralTx, password),
+ [submitCollateralTx, withSignTxConfirmation],
+ );
+
+ return { hasCollateral, reclaimCollateral, submitCollateral };
+};
diff --git a/packages/nami/src/adapters/currency.ts b/packages/nami/src/adapters/currency.ts
new file mode 100644
index 000000000..09e9b84a9
--- /dev/null
+++ b/packages/nami/src/adapters/currency.ts
@@ -0,0 +1,27 @@
+import { useCallback } from 'react';
+
+export enum CurrencyCode {
+ USD = 'USD',
+ EUR = 'EUR',
+}
+
+export const useFiatCurrency = (
+ fiatCurrency: string,
+ setFiatCurrency: (currency: string) => void,
+): {
+ currency: CurrencyCode;
+ setCurrency: (currency: CurrencyCode) => void;
+} => {
+ return {
+ currency:
+ fiatCurrency in CurrencyCode
+ ? CurrencyCode[fiatCurrency]
+ : CurrencyCode.USD,
+ setCurrency: useCallback(
+ currency => {
+ setFiatCurrency(CurrencyCode[currency]);
+ },
+ [setFiatCurrency],
+ ),
+ };
+};
diff --git a/packages/nami/src/adapters/delegation.mock.ts b/packages/nami/src/adapters/delegation.mock.ts
new file mode 100644
index 000000000..6ae3261c9
--- /dev/null
+++ b/packages/nami/src/adapters/delegation.mock.ts
@@ -0,0 +1,11 @@
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+/* eslint-disable @typescript-eslint/no-unsafe-argument */
+import { fn } from '@storybook/test';
+
+import * as actualApi from './delegation';
+
+export * from './delegation';
+
+export const useDelegation = fn(actualApi.useDelegation).mockName(
+ 'useDelegation',
+);
diff --git a/packages/nami/src/adapters/delegation.test.ts b/packages/nami/src/adapters/delegation.test.ts
new file mode 100644
index 000000000..a8e468398
--- /dev/null
+++ b/packages/nami/src/adapters/delegation.test.ts
@@ -0,0 +1,208 @@
+import { Wallet } from '@lace/cardano';
+import { renderHook } from '@testing-library/react-hooks';
+import { of } from 'rxjs';
+
+import { useDelegation } from './delegation';
+
+const mockBuildDelegation = jest.fn().mockResolvedValue(undefined);
+const mockSetSelectedStakePool = jest.fn().mockResolvedValue(undefined);
+
+describe('useDelegation', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('should return no transformed delegation if there is no delegationDistribution', () => {
+ const stakeKeyDeposit = 123;
+ const inMemoryWallet = {
+ currentEpoch$: of(true),
+ delegation: {
+ distribution$: of(undefined),
+ rewardsHistory$: of(true),
+ },
+ protocolParameters$: of({ stakeKeyDeposit }),
+ balance: { utxo: {}, rewardAccounts: {} },
+ } as unknown as Wallet.ObservableWallet;
+ const { result } = renderHook(() =>
+ useDelegation({
+ inMemoryWallet,
+ buildDelegation: mockBuildDelegation,
+ setSelectedStakePool: mockSetSelectedStakePool,
+ }),
+ );
+
+ expect(result.current.delegation).toBe(undefined);
+ expect(result.current.stakeRegistration).toEqual(
+ stakeKeyDeposit.toString(),
+ );
+ expect(typeof result.current.initDelegation).toBe('function');
+ });
+
+ test('should return no transformed delegation if there is no delegationRewardsHistory', () => {
+ const stakeKeyDeposit = 123;
+ const inMemoryWallet = {
+ currentEpoch$: of(true),
+ delegation: {
+ distribution$: of(true),
+ rewardsHistory$: of(undefined),
+ },
+ protocolParameters$: of({ stakeKeyDeposit }),
+ balance: { utxo: {}, rewardAccounts: {} },
+ } as unknown as Wallet.ObservableWallet;
+ const { result } = renderHook(() =>
+ useDelegation({
+ inMemoryWallet,
+ buildDelegation: mockBuildDelegation,
+ setSelectedStakePool: mockSetSelectedStakePool,
+ }),
+ );
+
+ expect(result.current.delegation).toBe(undefined);
+ expect(result.current.stakeRegistration).toEqual(
+ stakeKeyDeposit.toString(),
+ );
+ expect(typeof result.current.initDelegation).toBe('function');
+ });
+
+ test('should return no transformed delegation if there is no currentEpoch', () => {
+ const stakeKeyDeposit = 123;
+ const inMemoryWallet = {
+ currentEpoch$: of(true),
+ delegation: {
+ distribution$: of(undefined),
+ rewardsHistory$: of(undefined),
+ },
+ protocolParameters$: of({ stakeKeyDeposit }),
+ balance: { utxo: {}, rewardAccounts: {} },
+ } as unknown as Wallet.ObservableWallet;
+ const { result } = renderHook(() =>
+ useDelegation({
+ inMemoryWallet,
+ buildDelegation: mockBuildDelegation,
+ setSelectedStakePool: mockSetSelectedStakePool,
+ }),
+ );
+
+ expect(result.current.delegation).toBe(undefined);
+ expect(result.current.stakeRegistration).toEqual(
+ stakeKeyDeposit.toString(),
+ );
+ expect(typeof result.current.initDelegation).toBe('function');
+ });
+
+ test('should return no transformed delegation if delegationDistribution has no values', () => {
+ const stakeKeyDeposit = 123;
+ const inMemoryWallet = {
+ currentEpoch$: of(true),
+ delegation: {
+ distribution$: of(new Map()),
+ rewardsHistory$: of(true),
+ },
+ protocolParameters$: of({ stakeKeyDeposit }),
+ balance: { utxo: {}, rewardAccounts: {} },
+ } as unknown as Wallet.ObservableWallet;
+ const { result } = renderHook(() =>
+ useDelegation({
+ inMemoryWallet,
+ buildDelegation: mockBuildDelegation,
+ setSelectedStakePool: mockSetSelectedStakePool,
+ }),
+ );
+
+ expect(result.current.delegation).toBe(undefined);
+ expect(result.current.stakeRegistration).toEqual(
+ stakeKeyDeposit.toString(),
+ );
+ expect(typeof result.current.initDelegation).toBe('function');
+ });
+
+ test('should return transformed delegation', () => {
+ const poolId = 'poolId';
+ const rewardBalance = 456;
+ const metadata = {
+ ticker: 'ticker',
+ homepage: 'homepage',
+ description: 'description',
+ };
+ const distribution = {
+ pool: { metadata, id: poolId },
+ };
+ const stakeKeyDeposit = 123;
+ const inMemoryWallet = {
+ currentEpoch$: of(true),
+ delegation: {
+ distribution$: of(new Map([[poolId, distribution]])),
+ rewardsHistory$: of(true),
+ rewardAccounts$: of([
+ {
+ address: 'address',
+ credentialStatus: Wallet.Cardano.StakeCredentialStatus.Registered,
+ rewardBalance,
+ },
+ ]),
+ },
+ addresses$: of([{ rewardAccount: 'address' }]),
+ protocolParameters$: of({ stakeKeyDeposit }),
+ balance: { utxo: {}, rewardAccounts: {} },
+ } as unknown as Wallet.ObservableWallet;
+ const { result } = renderHook(() =>
+ useDelegation({
+ inMemoryWallet,
+ buildDelegation: mockBuildDelegation,
+ setSelectedStakePool: mockSetSelectedStakePool,
+ }),
+ );
+
+ expect(result.current.delegation).toEqual({
+ poolId,
+ ticker: metadata?.ticker ?? '',
+ homepage: metadata?.homepage ?? '',
+ description: metadata?.description ?? '',
+ rewards: rewardBalance.toString(),
+ });
+ expect(result.current.stakeRegistration).toEqual(
+ stakeKeyDeposit.toString(),
+ );
+ expect(typeof result.current.initDelegation).toBe('function');
+ });
+
+ test('should call setSelectedStakePool and buildDelegation with correct arguments', async () => {
+ const poolId = 'poolId';
+ const distribution = {
+ pool: { metadata: {} },
+ };
+ const stakeKeyDeposit = 123;
+ const inMemoryWallet = {
+ currentEpoch$: of(true),
+ delegation: {
+ distribution$: of(new Map([[poolId, distribution]])),
+ rewardsHistory$: of(undefined),
+ rewardAccounts$: of([
+ {
+ address: 'address',
+ credentialStatus: Wallet.Cardano.StakeCredentialStatus.Registered,
+ },
+ ]),
+ },
+ addresses$: of([{ rewardAccount: 'address' }]),
+ protocolParameters$: of({ stakeKeyDeposit }),
+ balance: { utxo: {}, rewardAccounts: {} },
+ } as unknown as Wallet.ObservableWallet;
+ const { result } = renderHook(() =>
+ useDelegation({
+ inMemoryWallet,
+ buildDelegation: mockBuildDelegation,
+ setSelectedStakePool: mockSetSelectedStakePool,
+ }),
+ );
+
+ await result.current.initDelegation({
+ hexId: poolId,
+ } as Wallet.Cardano.StakePool);
+
+ expect(mockSetSelectedStakePool).toBeCalledWith({
+ hexId: poolId,
+ });
+ expect(mockBuildDelegation).toBeCalledWith(poolId);
+ });
+});
diff --git a/packages/nami/src/adapters/delegation.ts b/packages/nami/src/adapters/delegation.ts
new file mode 100644
index 000000000..0d7c2af84
--- /dev/null
+++ b/packages/nami/src/adapters/delegation.ts
@@ -0,0 +1,91 @@
+import { useCallback } from 'react';
+
+import { Wallet } from '@lace/cardano';
+import { useObservable } from '@lace/common';
+import isUndefined from 'lodash/isUndefined';
+
+export const LAST_STABLE_EPOCH = 2;
+
+interface Props {
+ inMemoryWallet: Wallet.ObservableWallet;
+ buildDelegation: (
+ hexId?: Readonly,
+ ) => Promise;
+ setSelectedStakePool: (pool?: Readonly) => void;
+}
+
+export interface TransformedDelegation {
+ poolId: string;
+ ticker: string;
+ homepage: string;
+ description: string;
+ rewards: string;
+}
+
+export interface Delegation {
+ delegation?: TransformedDelegation;
+ initDelegation: (pool?: Readonly) => Promise;
+ stakeRegistration: string;
+}
+
+export const useDelegation = ({
+ inMemoryWallet,
+ buildDelegation,
+ setSelectedStakePool,
+}: Readonly): Delegation => {
+ const delegationDistribution = useObservable(
+ inMemoryWallet.delegation.distribution$,
+ );
+ const delegationRewardsHistory = useObservable(
+ inMemoryWallet.delegation.rewardsHistory$,
+ );
+ const currentEpoch = useObservable(inMemoryWallet.currentEpoch$);
+ const addresses = useObservable(inMemoryWallet.addresses$);
+ const rewards = useObservable(inMemoryWallet.delegation.rewardAccounts$);
+ const rewardAccount = rewards?.find(
+ ({ address }) => address === addresses[0].rewardAccount,
+ );
+ const protocolParameters = useObservable(inMemoryWallet?.protocolParameters$);
+ const stakeRegistration = protocolParameters?.stakeKeyDeposit.toString();
+
+ const initDelegation = useCallback(
+ async (pool?: Readonly) => {
+ setSelectedStakePool(pool);
+ await buildDelegation(pool?.hexId);
+ },
+ [buildDelegation, setSelectedStakePool],
+ );
+
+ if (
+ [delegationDistribution, delegationRewardsHistory, currentEpoch].some(val =>
+ isUndefined(val),
+ )
+ ) {
+ return { initDelegation, stakeRegistration };
+ }
+
+ const delegation = [...(delegationDistribution?.values() || [])]?.[0];
+
+ if (!delegation) return { initDelegation, stakeRegistration };
+
+ const {
+ pool: { metadata, id: poolId },
+ } = delegation;
+ const transformedDelegation = {
+ poolId,
+ ticker: metadata?.ticker ?? '',
+ homepage: metadata?.homepage ?? '',
+ description: metadata?.description ?? '',
+ rewards:
+ rewardAccount?.credentialStatus ===
+ Wallet.Cardano.StakeCredentialStatus.Registered
+ ? rewardAccount?.rewardBalance.toString()
+ : '0',
+ };
+
+ return {
+ delegation: transformedDelegation,
+ initDelegation,
+ stakeRegistration,
+ };
+};
diff --git a/packages/nami/src/adapters/index.ts b/packages/nami/src/adapters/index.ts
new file mode 100644
index 000000000..21b0c3f22
--- /dev/null
+++ b/packages/nami/src/adapters/index.ts
@@ -0,0 +1 @@
+export { getBalance } from './balance';
diff --git a/packages/nami/src/adapters/wallet.test.ts b/packages/nami/src/adapters/wallet.test.ts
new file mode 100644
index 000000000..e5e6b9e31
--- /dev/null
+++ b/packages/nami/src/adapters/wallet.test.ts
@@ -0,0 +1,258 @@
+import { renderHook, act } from '@testing-library/react-hooks';
+import { BehaviorSubject, of } from 'rxjs';
+
+import { ERROR } from '../config/config';
+
+import { useChangePassword, useDeleteWalletWithPassword } from './wallet';
+
+import type {
+ WalletManagerApi,
+ WalletRepositoryApi,
+} from '@cardano-sdk/web-extension';
+import type { Wallet } from '@lace/cardano';
+
+const extendedAccountPublicKey =
+ 'ba4f80dea2632a17c99ae9d8b934abf0' +
+ '2643db5426b889fef14709c85e294aa1' +
+ '2ac1f1560a893ea7937c5bfbfdeab459' +
+ 'b1a396f1174b9c5a673a640d01880c35';
+
+describe('useDeleteWalletWithPassword', () => {
+ const mockDeleteWallet = jest.fn();
+ const mockEmip3decrypt = jest.fn();
+ const mockActiveWalletId$ = of({
+ walletId: 'wallet1',
+ accountIndex: 0,
+ }) as WalletManagerApi['activeWalletId$'];
+ const mockWallets$ = of([
+ {
+ walletId: 'wallet1',
+ encryptedSecrets: { keyMaterial: '1234' },
+ },
+ ]) as WalletRepositoryApi<
+ Wallet.WalletMetadata,
+ Wallet.AccountMetadata
+ >['wallets$'];
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should call deleteWallet when the password is correct', async () => {
+ mockEmip3decrypt.mockResolvedValue(new Uint8Array());
+ const { result } = renderHook(() =>
+ useDeleteWalletWithPassword({
+ wallets$: mockWallets$,
+ activeWalletId$: mockActiveWalletId$,
+ deleteWallet: mockDeleteWallet,
+ emip3decrypt: mockEmip3decrypt,
+ }),
+ );
+
+ await result.current('correct-password');
+
+ expect(mockEmip3decrypt).toHaveBeenCalledWith(
+ Buffer.from('1234', 'hex'),
+ Buffer.from('correct-password'),
+ );
+ expect(mockDeleteWallet).toHaveBeenCalledWith(false);
+ });
+
+ it('should throw an error when the password is incorrect', async () => {
+ mockEmip3decrypt.mockRejectedValue(new Error('error'));
+ const { result } = renderHook(() =>
+ useDeleteWalletWithPassword({
+ wallets$: mockWallets$,
+ activeWalletId$: mockActiveWalletId$,
+ deleteWallet: mockDeleteWallet,
+ emip3decrypt: mockEmip3decrypt,
+ }),
+ );
+ await expect(result.current('wrong-password')).rejects.toEqual(
+ ERROR.wrongPassword,
+ );
+ expect(mockDeleteWallet).not.toHaveBeenCalled();
+ });
+});
+
+describe('useChangePassword', () => {
+ const mockCreateWallet = jest.fn();
+ const mockGetMnemonic = jest.fn();
+ const mockDeleteWallet = jest.fn();
+ const mockAddAccount = jest.fn();
+ const mockActivateWallet = jest.fn();
+ const mockUpdateAccountMetadata = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should change the password correctly for wallet with on account', async () => {
+ const mockActiveWalletId$ = of({
+ walletId: 'wallet1',
+ accountIndex: 0,
+ }) as WalletManagerApi['activeWalletId$'];
+ const mockWallets$ = of([
+ {
+ walletId: 'wallet1',
+ metadata: { name: 'test-wallet' },
+ accounts: [
+ {
+ accountIndex: 0,
+ metadata: {},
+ extendedAccountPublicKey,
+ },
+ ],
+ },
+ ]) as WalletRepositoryApi<
+ Wallet.WalletMetadata,
+ Wallet.AccountMetadata
+ >['wallets$'];
+ mockGetMnemonic.mockResolvedValue(['mnemonic']);
+ mockCreateWallet.mockResolvedValue({
+ source: { wallet: { walletId: 'wallet1' } },
+ });
+ const { result } = renderHook(() =>
+ useChangePassword({
+ chainId: { networkId: 0, networkMagic: 0 },
+ createWallet: mockCreateWallet,
+ getMnemonic: mockGetMnemonic,
+ deleteWallet: mockDeleteWallet,
+ updateAccountMetadata: mockUpdateAccountMetadata,
+ wallets$: mockWallets$,
+ activeWalletId$: mockActiveWalletId$,
+ addAccount: mockAddAccount,
+ activateWallet: mockActivateWallet,
+ }),
+ );
+
+ await result.current('current-password', 'new-password');
+
+ expect(mockGetMnemonic).toHaveBeenCalledWith(
+ Buffer.from('current-password'),
+ );
+ expect(mockDeleteWallet).toHaveBeenCalledWith(false);
+ expect(mockCreateWallet).toHaveBeenCalledWith({
+ mnemonic: ['mnemonic'],
+ name: 'test-wallet',
+ password: 'new-password',
+ });
+ expect(mockUpdateAccountMetadata).toHaveBeenCalled();
+ expect(mockAddAccount).not.toHaveBeenCalled();
+ expect(mockActivateWallet).toHaveBeenCalledWith({
+ chainId: { networkId: 0, networkMagic: 0 },
+ walletId: 'wallet1',
+ accountIndex: 0,
+ });
+ });
+
+ it('should change the password correctly for wallet with two accounts and second account active', async () => {
+ const mockActiveWalletId$ = of({
+ walletId: 'wallet1',
+ accountIndex: 1,
+ }) as WalletManagerApi['activeWalletId$'];
+ const mockWallets$ = of([
+ {
+ walletId: 'wallet1',
+ metadata: { name: 'test-wallet' },
+ accounts: [
+ {
+ accountIndex: 0,
+ metadata: { name: 'account 0' },
+ extendedAccountPublicKey,
+ },
+ {
+ accountIndex: 1,
+ metadata: { name: 'account 1' },
+ extendedAccountPublicKey,
+ },
+ ],
+ },
+ ]) as WalletRepositoryApi<
+ Wallet.WalletMetadata,
+ Wallet.AccountMetadata
+ >['wallets$'];
+ mockGetMnemonic.mockResolvedValue(['mnemonic']);
+ mockCreateWallet.mockResolvedValue({
+ source: { wallet: { walletId: 'wallet1' } },
+ });
+ const { result } = renderHook(() =>
+ useChangePassword({
+ chainId: { networkId: 0, networkMagic: 0 },
+ createWallet: mockCreateWallet,
+ getMnemonic: mockGetMnemonic,
+ deleteWallet: mockDeleteWallet,
+ updateAccountMetadata: mockUpdateAccountMetadata,
+ wallets$: mockWallets$,
+ activeWalletId$: mockActiveWalletId$,
+ addAccount: mockAddAccount,
+ activateWallet: mockActivateWallet,
+ }),
+ );
+
+ await result.current('current-password', 'new-password');
+
+ expect(mockGetMnemonic).toHaveBeenCalledWith(
+ Buffer.from('current-password'),
+ );
+ expect(mockDeleteWallet).toHaveBeenCalledWith(false);
+ expect(mockCreateWallet).toHaveBeenCalledWith({
+ mnemonic: ['mnemonic'],
+ name: 'test-wallet',
+ password: 'new-password',
+ });
+ expect(mockUpdateAccountMetadata).toHaveBeenCalledWith({
+ walletId: 'wallet1',
+ accountIndex: 0,
+ metadata: { name: 'account 0' },
+ });
+ expect(mockAddAccount).toHaveBeenCalledWith({
+ walletId: 'wallet1',
+ accountIndex: 1,
+ metadata: { name: 'account 1' },
+ extendedAccountPublicKey,
+ });
+ expect(mockActivateWallet).toHaveBeenCalledWith({
+ chainId: { networkId: 0, networkMagic: 0 },
+ walletId: 'wallet1',
+ accountIndex: 1,
+ });
+ });
+
+ it('should throw an error if the password is wrong', async () => {
+ mockGetMnemonic.mockRejectedValue(new Error('error'));
+ const mockActiveWalletId$ = of({
+ walletId: 'wallet1',
+ accountIndex: 0,
+ }) as WalletManagerApi['activeWalletId$'];
+ const mockWallets$ = of([
+ {
+ walletId: 'wallet1',
+ metadata: { name: 'test-wallet' },
+ accounts: [{ accountIndex: 0, metadata: {} }],
+ },
+ ]) as WalletRepositoryApi<
+ Wallet.WalletMetadata,
+ Wallet.AccountMetadata
+ >['wallets$'];
+
+ const { result } = renderHook(() =>
+ useChangePassword({
+ chainId: { networkId: 0, networkMagic: 0 },
+ createWallet: mockCreateWallet,
+ getMnemonic: mockGetMnemonic,
+ deleteWallet: mockDeleteWallet,
+ updateAccountMetadata: mockUpdateAccountMetadata,
+ wallets$: mockWallets$,
+ activeWalletId$: mockActiveWalletId$,
+ addAccount: mockAddAccount,
+ activateWallet: mockActivateWallet,
+ }),
+ );
+
+ await expect(
+ result.current('wrong-password', 'new-password'),
+ ).rejects.toEqual(ERROR.wrongPassword);
+ expect(mockDeleteWallet).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/nami/src/adapters/wallet.ts b/packages/nami/src/adapters/wallet.ts
new file mode 100644
index 000000000..388b89c2c
--- /dev/null
+++ b/packages/nami/src/adapters/wallet.ts
@@ -0,0 +1,166 @@
+import { useCallback } from 'react';
+
+import { Wallet } from '@lace/cardano';
+import { useObservable } from '@lace/common';
+
+import { ERROR } from '../config/config';
+
+import type { CreateWalletParams } from '../types/wallet';
+import type {
+ AddAccountProps,
+ AnyWallet,
+ UpdateAccountMetadataProps,
+ WalletManagerActivateProps,
+ WalletManagerApi,
+} from '@cardano-sdk/web-extension';
+import type { Observable } from 'rxjs';
+
+const clearBytes = (bytes: Uint8Array) => {
+ for (let i = 0; i < bytes.length; i++) {
+ bytes[i] = 0;
+ }
+};
+
+interface DeleteWalletProps {
+ wallets$: Observable<
+ AnyWallet[]
+ >;
+ activeWalletId$: Readonly;
+ deleteWallet: (
+ isForgotPasswordFlow?: boolean,
+ ) => Promise;
+ emip3decrypt?: (
+ encrypted: Uint8Array,
+ passphrase: Uint8Array,
+ ) => Promise;
+}
+
+export const useDeleteWalletWithPassword = ({
+ wallets$,
+ activeWalletId$,
+ deleteWallet,
+ emip3decrypt = Wallet.KeyManagement.emip3decrypt,
+}: Readonly) => {
+ const activeWallet = useObservable(activeWalletId$);
+ const wallets = useObservable(wallets$);
+ const { walletId } = activeWallet ?? {};
+ const wallet = wallets?.find(elm => elm.walletId === walletId);
+
+ return useCallback(
+ async (password: string) => {
+ if (!wallet) {
+ return;
+ }
+
+ try {
+ if ('encryptedSecrets' in wallet) {
+ const keyMaterialBytes = await emip3decrypt(
+ Buffer.from(wallet.encryptedSecrets.keyMaterial, 'hex'),
+ Buffer.from(password),
+ );
+ clearBytes(keyMaterialBytes);
+ await deleteWallet(false);
+ }
+ } catch {
+ throw ERROR.wrongPassword;
+ }
+ },
+ [wallet, deleteWallet],
+ );
+};
+
+interface ChangePasswordProps {
+ chainId: Wallet.Cardano.ChainId;
+ createWallet: (
+ args: Readonly,
+ ) => Promise;
+ getMnemonic: (passphrase: Uint8Array) => Promise;
+ activeWalletId$: Readonly;
+ wallets$: Observable<
+ AnyWallet[]
+ >;
+ addAccount: (
+ props: Readonly>,
+ ) => Promise>;
+ activateWallet: (
+ props: Readonly,
+ force?: boolean,
+ ) => Promise;
+ deleteWallet: (
+ isForgotPasswordFlow?: boolean,
+ ) => Promise;
+ updateAccountMetadata: (
+ props: Readonly>,
+ ) => Promise>;
+}
+
+export const useChangePassword = ({
+ chainId,
+ addAccount,
+ activateWallet,
+ createWallet,
+ getMnemonic,
+ deleteWallet,
+ updateAccountMetadata,
+ wallets$,
+ activeWalletId$,
+}: Readonly) => {
+ const activeWallet = useObservable(activeWalletId$);
+ const wallets = useObservable(wallets$);
+ const { walletId, accountIndex } = activeWallet ?? {};
+ const wallet = wallets?.find(elm => elm.walletId === walletId);
+
+ return useCallback(
+ async (currentPassword: string, newPassword: string) => {
+ try {
+ if (!wallet?.metadata?.name) {
+ return;
+ }
+ const mnemonic = await getMnemonic(Buffer.from(currentPassword));
+ await deleteWallet(false);
+ const newWallet = await createWallet({
+ mnemonic,
+ name: wallet.metadata.name,
+ password: newPassword,
+ });
+ const { walletId } = newWallet.source.wallet;
+
+ if (!('accounts' in wallet)) {
+ return;
+ }
+
+ for await (const account of wallet.accounts) {
+ const { accountIndex, metadata, extendedAccountPublicKey } = account;
+ await (accountIndex === 0
+ ? updateAccountMetadata({
+ accountIndex,
+ walletId,
+ metadata,
+ })
+ : addAccount({
+ accountIndex,
+ metadata,
+ walletId,
+ extendedAccountPublicKey: Wallet.Crypto.Bip32PublicKeyHex(
+ extendedAccountPublicKey,
+ ),
+ }));
+ }
+ await activateWallet({ chainId, walletId, accountIndex });
+ } catch {
+ throw ERROR.wrongPassword;
+ }
+ },
+ [
+ chainId,
+ accountIndex,
+ getMnemonic,
+ createWallet,
+ deleteWallet,
+ updateAccountMetadata,
+ addAccount,
+ activateWallet,
+ wallet?.metadata?.name,
+ ],
+ );
+};
diff --git a/packages/nami/src/api/extension/api.mock.ts b/packages/nami/src/api/extension/api.mock.ts
new file mode 100644
index 000000000..11960656b
--- /dev/null
+++ b/packages/nami/src/api/extension/api.mock.ts
@@ -0,0 +1,81 @@
+/* eslint-disable @typescript-eslint/no-unsafe-argument */
+import { fn } from '@storybook/test';
+
+import * as actualApi from './';
+
+import type { HardwareDeviceInfo } from '../../ui/app/hw/types';
+
+export * from './';
+
+export const createTab = fn(actualApi.createTab).mockName('createTab');
+
+export const getAccounts = fn(actualApi.getAccounts).mockName('getAccounts');
+
+export const getHwAccounts = fn(
+ // eslint-disable-next-line @typescript-eslint/require-await
+ async ({ device, id }: Readonly) =>
+ actualApi.getHwAccounts({ device, id }),
+).mockName('getHwAccounts');
+
+export const getCurrentAccount = fn(actualApi.getCurrentAccount).mockName(
+ 'getCurrentAccount',
+);
+
+export const getCurrentAccountIndex = fn(
+ actualApi.getCurrentAccountIndex,
+).mockName('getCurrentAccountIndex');
+
+export const getDelegation = fn(actualApi.getDelegation).mockName(
+ 'getDelegation',
+);
+
+export const getNetwork = fn(actualApi.getNetwork).mockName('getNetwork');
+
+export const getTransactions = fn(actualApi.getTransactions).mockName(
+ 'getTransactions',
+);
+
+export const updateAccount = fn(actualApi.updateAccount).mockName(
+ 'updateAccount',
+);
+
+export const onAccountChange = fn(actualApi.onAccountChange).mockName(
+ 'onAccountChange',
+);
+
+export const isValidAddress = fn(actualApi.isValidAddress).mockName(
+ 'isValidAddress',
+);
+
+export const getUtxos = fn(actualApi.getUtxos).mockName('getUtxos');
+
+export const getAdaHandle = fn(actualApi.getAdaHandle).mockName('getAdaHandle');
+
+export const getAsset = fn(actualApi.getAsset).mockName('getAsset');
+
+export const updateTxInfo = fn(actualApi.updateTxInfo).mockName('updateTxInfo');
+
+export const setTransactions = fn(actualApi.setTransactions).mockName(
+ 'setTransactions',
+);
+
+export const setTxDetail = fn(actualApi.setTxDetail).mockName('setTxDetail');
+
+export const createHWAccounts = fn(actualApi.createHWAccounts).mockName(
+ 'createHWAccounts',
+);
+
+export const initHW = fn(
+ async ({ device, id }: Readonly) => {
+ return actualApi.initHW({ device, id });
+ },
+).mockName('initHW');
+
+// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
+export const getFavoriteIcon = fn(actualApi.getFavoriteIcon).mockName(
+ 'getFavoriteIcon',
+);
+
+export const extractKeyOrScriptHash = fn(
+ actualApi.extractKeyOrScriptHash,
+).mockName('extractKeyOrScriptHash');
diff --git a/packages/nami/src/api/extension/index.ts b/packages/nami/src/api/extension/index.ts
new file mode 100644
index 000000000..bda05dd7e
--- /dev/null
+++ b/packages/nami/src/api/extension/index.ts
@@ -0,0 +1,1769 @@
+/* eslint-disable unicorn/no-array-reduce */
+/* eslint-disable max-params */
+/* eslint-disable unicorn/no-null */
+/* eslint-disable functional/prefer-immutable-types */
+import Ada, { HARDENED } from '@cardano-foundation/ledgerjs-hw-app-cardano';
+import {
+ Cardano,
+ ProviderError,
+ ProviderFailure,
+ TxCBOR,
+} from '@cardano-sdk/core';
+import { createAvatar } from '@dicebear/avatars';
+import * as style from '@dicebear/avatars-bottts-sprites';
+import TrezorConnect from '@trezor/connect-web';
+
+import {
+ // ADA_HANDLE,
+ APIError,
+ DataSignError,
+ ERROR,
+ EVENT,
+ HW,
+ // LOCAL_STORAGE,
+ // NODE,
+ SENDER,
+ STORAGE,
+ TARGET,
+ TxSendError,
+ TxSignError,
+} from '../../config/config';
+// import { POPUP_WINDOW } from '../../config/config';
+// import { mnemonicToEntropy } from 'bip39';
+// import cryptoRandomString from 'crypto-random-string';
+import { Loader } from '../loader';
+// import { initTx } from './wallet';
+import {
+ blockfrostRequest,
+ // networkNameToId,
+ // utxoFromJson,
+ // assetsToValue,
+ txToLedger,
+ txToTrezor,
+ // linkToSrc,
+ // convertMetadataPropToString,
+ // fromAssetUnit,
+ // toAssetUnit,
+ // Data,
+} from '../util';
+
+import type { HandleProvider } from '@cardano-sdk/core';
+import type { HexBlob } from '@cardano-sdk/util';
+import type { Wallet } from '@lace/cardano';
+import type { NetworkType } from 'types';
+
+// import TransportWebUSB from '@ledgerhq/hw-transport-webusb';
+// import AssetFingerprint from '@emurgo/cip14-js';
+
+export const getStorage = async key =>
+ new Promise((res, rej) => {
+ chrome.storage.local.get(key, result => {
+ if (chrome.runtime.lastError) rej(undefined);
+ res(key ? result[key] : result);
+ });
+ });
+export const setStorage = async item =>
+ new Promise((res, rej) => {
+ chrome.storage.local.set(item, () => {
+ if (chrome.runtime.lastError) rej(chrome.runtime.lastError);
+ res(true);
+ });
+ });
+
+// export const removeStorage = (item) =>
+// new Promise((res, rej) =>
+// chrome.storage.local.remove(item, () => {
+// if (chrome.runtime.lastError) rej(chrome.runtime.lastError);
+// res(true);
+// })
+// );
+
+export const encryptWithPassword = async (password, rootKeyBytes) => {
+ return await Promise.resolve('');
+ // await Loader.load();
+ // const rootKeyHex = Buffer.from(rootKeyBytes, 'hex').toString('hex');
+ // const passwordHex = Buffer.from(password).toString('hex');
+ // const salt = cryptoRandomString({ length: 2 * 32 });
+ // const nonce = cryptoRandomString({ length: 2 * 12 });
+ // return Loader.Cardano.encrypt_with_password(
+ // passwordHex,
+ // salt,
+ // nonce,
+ // rootKeyHex
+ // );
+};
+
+export const decryptWithPassword = async (password, encryptedKeyHex) => {
+ return await Promise.resolve('');
+ // await Loader.load();
+ // const passwordHex = Buffer.from(password).toString('hex');
+ // let decryptedHex;
+ // try {
+ // decryptedHex = Loader.Cardano.decrypt_with_password(
+ // passwordHex,
+ // encryptedKeyHex
+ // );
+ // } catch (err) {
+ // throw new Error(ERROR.wrongPassword);
+ // }
+ // return decryptedHex;
+};
+
+export const getFavoriteIcon = domain => {
+ return `chrome-extension://${chrome.runtime.id}/_favicon/?pageUrl=${domain}&size=32`;
+ // const result = await getStorage(STORAGE.whitelisted);
+ // return result ? result : [];
+};
+
+// export const isWhitelisted = async (_origin) => {
+// const whitelisted = await getWhitelisted();
+// let access = false;
+// if (whitelisted.includes(_origin)) access = true;
+// return access;
+// };
+
+export const setWhitelisted = async origin => {
+ await Promise.resolve();
+ // let whitelisted = await getWhitelisted();
+ // whitelisted ? whitelisted.push(origin) : (whitelisted = [origin]);
+ // return await setStorage({ [STORAGE.whitelisted]: whitelisted });
+};
+
+// export const setCurrency = (currency) =>
+// setStorage({ [STORAGE.currency]: currency });
+
+export const getDelegation = async () => {
+ const currentAccount = await getCurrentAccount();
+ const stake = await blockfrostRequest(
+ `/accounts/${currentAccount.rewardAddr}`,
+ );
+ if (!stake || stake.error || !stake.pool_id) return {};
+ const delegation = await blockfrostRequest(
+ `/pools/${stake.pool_id}/metadata`,
+ );
+ if (!delegation || delegation.error) return {};
+ return {
+ active: stake.active,
+ rewards: stake.withdrawable_amount,
+ homepage: delegation.homepage,
+ poolId: stake.pool_id,
+ ticker: delegation.ticker,
+ description: delegation.description,
+ name: delegation.name,
+ };
+};
+
+export const getPoolMetadata = async poolId => {
+ return await Promise.resolve({});
+ // if (!poolId) {
+ // throw new Error('poolId argument not provided');
+ // }
+
+ // const delegation = await blockfrostRequest(`/pools/${poolId}/metadata`);
+
+ // if (delegation.error) {
+ // throw new Error(delegation.message);
+ // }
+
+ // return {
+ // ticker: delegation.ticker,
+ // name: delegation.name,
+ // id: poolId,
+ // hex: delegation.hex,
+ // };
+};
+
+// export const getBalance = async () => {
+// await Loader.load();
+// const currentAccount = await getCurrentAccount();
+// const result = await blockfrostRequest(
+// `/addresses/${currentAccount.paymentKeyHashBech32}`
+// );
+// if (result.error) {
+// if (result.status_code === 400) throw APIError.InvalidRequest;
+// else if (result.status_code === 500) throw APIError.InternalError;
+// else return Loader.Cardano.Value.new(Loader.Cardano.BigNum.from_str('0'));
+// }
+// const value = await assetsToValue(result.amount);
+// return value;
+// };
+
+// export const getBalanceExtended = async () => {
+// const currentAccount = await getCurrentAccount();
+// const result = await blockfrostRequest(
+// `/addresses/${currentAccount.paymentKeyHashBech32}/extended`
+// );
+// if (result.error) {
+// if (result.status_code === 400) throw APIError.InvalidRequest;
+// else if (result.status_code === 500) throw APIError.InternalError;
+// else return [];
+// }
+// return result.amount;
+// };
+
+// export const getFullBalance = async () => {
+// const currentAccount = await getCurrentAccount();
+// const result = await blockfrostRequest(
+// `/accounts/${currentAccount.rewardAddr}`
+// );
+// if (result.error) return '0';
+// return (
+// BigInt(result.controlled_amount) - BigInt(result.withdrawable_amount)
+// ).toString();
+// };
+
+export const getTransactions = async (paginate = 1, count = 10) => {
+ const currentAccount = await getCurrentAccount();
+ const result = await blockfrostRequest(
+ `/addresses/${currentAccount.paymentKeyHashBech32}/transactions?page=${paginate}&order=desc&count=${count}`,
+ );
+ if (!result || result.error) return [];
+ return result.map(tx => ({
+ txHash: tx.tx_hash,
+ txIndex: tx.tx_index,
+ blockHeight: tx.block_height,
+ }));
+};
+
+// export const getTxInfo = async (txHash) => {
+// const result = await blockfrostRequest(`/txs/${txHash}`);
+// if (!result || result.error) return null;
+// return result;
+// };
+
+// export const getBlock = async (blockHashOrNumb) => {
+// const result = await blockfrostRequest(`/blocks/${blockHashOrNumb}`);
+// if (!result || result.error) return null;
+// return result;
+// };
+
+// export const getTxUTxOs = async (txHash) => {
+// const result = await blockfrostRequest(`/txs/${txHash}/utxos`);
+// if (!result || result.error) return null;
+// return result;
+// };
+
+// export const getTxMetadata = async (txHash) => {
+// const result = await blockfrostRequest(`/txs/${txHash}/metadata`);
+// if (!result || result.error) return null;
+// return result;
+// };
+
+export const updateTxInfo = async txHash => {
+ return await Promise.resolve({});
+ // const currentAccount = await getCurrentAccount();
+ // const network = await getNetwork();
+ // let detail = await currentAccount[network.id].history.details[txHash];
+ // if (typeof detail !== 'object' || Object.keys(detail).length < 4) {
+ // detail = {};
+ // const info = getTxInfo(txHash);
+ // const uTxOs = getTxUTxOs(txHash);
+ // const metadata = getTxMetadata(txHash);
+ // detail.info = await info;
+ // if (info) detail.block = await getBlock(detail.info.block_height);
+ // detail.utxos = await uTxOs;
+ // detail.metadata = await metadata;
+};
+
+// return detail;
+// };
+
+export const setTxDetail = async txObject => {
+ return await Promise.resolve(true);
+ // const currentIndex = await getCurrentAccountIndex();
+ // const network = await getNetwork();
+ // const accounts = await getStorage(STORAGE.accounts);
+ // for (const txHash of Object.keys(txObject)) {
+ // const txDetail = txObject[txHash];
+ // accounts[currentIndex][network.id].history.details[txHash] = txDetail;
+ // await setStorage({
+ // [STORAGE.accounts]: {
+ // ...accounts,
+ // },
+ // });
+ // delete txObject[txHash];
+ // }
+ // return true;
+};
+
+export const getSpecificUtxo = async (txHash, txId) => {
+ const result = await blockfrostRequest(`/txs/${txHash}/utxos`);
+ if (!result || result.error) return null;
+ return result.outputs[txId];
+};
+
+/**
+ *
+ * @param {string} amount - cbor value
+ * @param {Object} paginate
+ * @param {number} paginate.page
+ * @param {number} paginate.limit
+ * @returns
+ */
+export const getUtxos = async (amount = undefined, paginate = undefined) => {
+ return await Promise.resolve([]);
+ // const currentAccount = await getCurrentAccount();
+ // let result = [];
+ // let page = paginate && paginate.page ? paginate.page + 1 : 1;
+ // const limit = paginate && paginate.limit ? `&count=${paginate.limit}` : '';
+ // while (true) {
+ // let pageResult = await blockfrostRequest(
+ // `/addresses/${currentAccount.paymentKeyHashBech32}/utxos?page=${page}${limit}`
+ // );
+ // if (pageResult.error) {
+ // if (result.status_code === 400) throw APIError.InvalidRequest;
+ // else if (result.status_code === 500) throw APIError.InternalError;
+ // else {
+ // pageResult = [];
+ // }
+ // }
+ // result = result.concat(pageResult);
+ // if (pageResult.length <= 0 || paginate) break;
+ // page++;
+ // }
+
+ // // exclude collateral input from overall utxo set
+ // if (currentAccount.collateral) {
+ // result = result.filter(
+ // (utxo) =>
+ // !(
+ // utxo.tx_hash === currentAccount.collateral.txHash &&
+ // utxo.output_index === currentAccount.collateral.txId
+ // )
+ // );
+ // }
+
+ // const address = await getAddress();
+ // let converted = await Promise.all(
+ // result.map(async (utxo) => await utxoFromJson(utxo, address))
+ // );
+ // // filter utxos
+ // if (amount) {
+ // await Loader.load();
+ // let filterValue;
+ // try {
+ // filterValue = Loader.Cardano.Value.from_bytes(Buffer.from(amount, 'hex'));
+ // } catch (e) {
+ // throw APIError.InvalidRequest;
+ // }
+
+ // converted = converted.filter(
+ // (unspent) =>
+ // !unspent.output().amount().compare(filterValue) ||
+ // unspent.output().amount().compare(filterValue) !== -1
+ // );
+ // }
+ // if ((amount || paginate) && converted.length <= 0) {
+ // return null;
+ // }
+ // return converted;
+};
+
+// const checkCollateral = async (currentAccount, network, checkTx) => {
+// if (checkTx) {
+// const transactions = await getTransactions();
+// if (
+// transactions.length <= 0 ||
+// currentAccount[network.id].history.confirmed.includes(
+// transactions[0].txHash
+// )
+// )
+// return;
+// }
+// let result = [];
+// let page = 1;
+// while (true) {
+// let pageResult = await blockfrostRequest(
+// `/addresses/${currentAccount.paymentKeyHashBech32}/utxos?page=${page}`
+// );
+// if (pageResult.error) {
+// if (result.status_code === 400) throw APIError.InvalidRequest;
+// else if (result.status_code === 500) throw APIError.InternalError;
+// else {
+// pageResult = [];
+// }
+// }
+// result = result.concat(pageResult);
+// if (pageResult.length <= 0) break;
+// page++;
+// }
+
+// // exclude collateral input from overall utxo set
+// if (currentAccount[network.id].collateral) {
+// const initialSize = result.length;
+// result = result.filter(
+// (utxo) =>
+// !(
+// utxo.tx_hash === currentAccount[network.id].collateral.txHash &&
+// utxo.output_index === currentAccount[network.id].collateral.txId
+// )
+// );
+
+// if (initialSize == result.length) {
+// delete currentAccount[network.id].collateral;
+// return true;
+// }
+// }
+// };
+
+// export const getCollateral = async () => {
+// await Loader.load();
+// const currentIndex = await getCurrentAccountIndex();
+// const accounts = await getStorage(STORAGE.accounts);
+// const currentAccount = accounts[currentIndex];
+// const network = await getNetwork();
+// if (await checkCollateral(currentAccount, network, true)) {
+// await setStorage({ [STORAGE.accounts]: accounts });
+// }
+// const collateral = currentAccount[network.id].collateral;
+// if (collateral) {
+// const collateralUtxo = Loader.Cardano.TransactionUnspentOutput.new(
+// Loader.Cardano.TransactionInput.new(
+// Loader.Cardano.TransactionHash.from_bytes(
+// Buffer.from(collateral.txHash, 'hex')
+// ),
+// Loader.Cardano.BigNum.from_str(collateral.txId.toString())
+// ),
+// Loader.Cardano.TransactionOutput.new(
+// Cardano.Address.fromBech32(
+// currentAccount[network.id].paymentAddr
+// ),
+// Loader.Cardano.Value.new(
+// Loader.Cardano.BigNum.from_str(collateral.lovelace)
+// )
+// )
+// );
+// return [collateralUtxo];
+// }
+// const utxos = await getUtxos();
+// return utxos.filter(
+// (utxo) =>
+// utxo
+// .output()
+// .amount()
+// .coin()
+// .compare(Loader.Cardano.BigNum.from_str('50000000')) <= 0 &&
+// !utxo.output().amount().multiasset()
+// );
+// };
+
+export const getAddress = async () => {
+ const currentAccount = await getCurrentAccount();
+ const paymentAddr = Buffer.from(
+ Cardano.Address.fromBech32(currentAccount.paymentAddr).toBytes(),
+ 'hex',
+ ).toString('hex');
+ return paymentAddr;
+};
+
+// export const getRewardAddress = async () => {
+// await Loader.load();
+// const currentAccount = await getCurrentAccount();
+// const rewardAddr = Buffer.from(
+// Cardano.Address.fromBech32(currentAccount.rewardAddr).to_bytes(),
+// 'hex'
+// ).toString('hex');
+// return rewardAddr;
+// };
+
+export const getCurrentAccountIndex = async () =>
+ getStorage(STORAGE.currentAccount);
+
+export const getNetwork = async (): Promise<{
+ id: string;
+ name: NetworkType;
+ node: string;
+}> =>
+ getStorage(STORAGE.network) as Promise<{
+ id: string;
+ name: NetworkType;
+ node: string;
+ }>;
+
+// export const setNetwork = async (network) => {
+// const currentNetwork = await getNetwork();
+// let id;
+// let node;
+// if (network.id === NETWORK_ID.mainnet) {
+// id = NETWORK_ID.mainnet;
+// node = NODE.mainnet;
+// } else if (network.id === NETWORK_ID.testnet) {
+// id = NETWORK_ID.testnet;
+// node = NODE.testnet;
+// } else if (network.id === NETWORK_ID.preview) {
+// id = NETWORK_ID.preview;
+// node = NODE.preview;
+// } else {
+// id = NETWORK_ID.preprod;
+// node = NODE.preprod;
+// }
+// if (network.node) node = network.node;
+// if (currentNetwork && currentNetwork.id !== id)
+// emitNetworkChange(networkNameToId(id));
+// await setStorage({
+// [STORAGE.network]: {
+// id,
+// node,
+// mainnetSubmit: network.mainnetSubmit,
+// testnetSubmit: network.testnetSubmit,
+// },
+// });
+// return true;
+// };
+
+const accountToNetworkSpecific = (account, network) => {
+ const assets = account[network.id].assets;
+ const lovelace = account[network.id].lovelace;
+ const history = account[network.id].history;
+ const minAda = account[network.id].minAda;
+ const collateral = account[network.id].collateral;
+ const recentSendToAddresses = account[network.id].recentSendToAddresses;
+ const paymentAddr = account[network.id].paymentAddr;
+ const rewardAddr = account[network.id].rewardAddr;
+
+ return {
+ ...account,
+ paymentAddr,
+ rewardAddr,
+ assets,
+ lovelace,
+ minAda,
+ collateral,
+ history,
+ recentSendToAddresses,
+ };
+};
+
+// /** Returns account with network specific settings (e.g. address, reward address, etc.) */
+export const getCurrentAccount = async () => {
+ return await Promise.resolve({});
+ // const currentAccountIndex = await getCurrentAccountIndex();
+ // const accounts = await getStorage(STORAGE.accounts);
+ // const network = await getNetwork();
+ // if (network && accounts) {
+ // return accountToNetworkSpecific(accounts[currentAccountIndex], network);
+ // }
+
+ // return {};
+};
+
+// /** Returns accounts with network specific settings (e.g. address, reward address, etc.) */
+export const getAccounts = async () => {
+ return await Promise.resolve([]);
+ // const accounts = await getStorage(STORAGE.accounts);
+ // const network = await getNetwork();
+ // for (const index in accounts) {
+ // accounts[index] = await accountToNetworkSpecific(accounts[index], network);
+ // }
+ // return accounts;
+};
+
+// export const createPopup = async (popup) => {
+// let left = 0;
+// let top = 0;
+// try {
+// const lastFocused = await new Promise((res, rej) => {
+// chrome.windows.getLastFocused((windowObject) => {
+// return res(windowObject);
+// });
+// });
+// top = lastFocused.top;
+// left =
+// lastFocused.left +
+// Math.round((lastFocused.width - POPUP_WINDOW.width) / 2);
+// } catch (_) {
+// // The following properties are more than likely 0, due to being
+// // opened from the background chrome process for the extension that
+// // has no physical dimensions
+// const { screenX, screenY, outerWidth } = window;
+// top = Math.max(screenY, 0);
+// left = Math.max(screenX + (outerWidth - POPUP_WINDOW.width), 0);
+// }
+
+// const { popupWindow, tab } = await new Promise((res, rej) =>
+// chrome.tabs.create(
+// {
+// url: chrome.runtime.getURL(popup + '.html'),
+// active: false,
+// },
+// function (tab) {
+// chrome.windows.create(
+// {
+// tabId: tab.id,
+// type: 'popup',
+// focused: true,
+// ...POPUP_WINDOW,
+// left,
+// top,
+// },
+// function (newWindow) {
+// return res({ popupWindow: newWindow, tab });
+// }
+// );
+// }
+// )
+// );
+
+// if (popupWindow.left !== left && popupWindow.state !== 'fullscreen') {
+// await new Promise((res, rej) => {
+// chrome.windows.update(popupWindow.id, { left, top }, () => {
+// return res();
+// });
+// });
+// }
+// return tab;
+// };
+
+export const createTab = async (tab, query = '') =>
+ new Promise((res, rej) => {
+ chrome.tabs.create(
+ {
+ url: chrome.runtime.getURL(`${tab}.html${query}`),
+ active: true,
+ },
+ tab => {
+ chrome.windows.create(
+ {
+ tabId: tab.id,
+ focused: true,
+ },
+ () => {
+ res(tab);
+ },
+ );
+ },
+ );
+ });
+
+// export const getCurrentWebpage = () =>
+// new Promise((res, rej) => {
+// chrome.tabs.query(
+// {
+// active: true,
+// lastFocusedWindow: true,
+// status: 'complete',
+// windowType: 'normal',
+// },
+// function (tabs) {
+// res({
+// url: new URL(tabs[0].url).origin,
+// favicon: tabs[0].favIconUrl,
+// tabId: tabs[0].id,
+// });
+// }
+// );
+// });
+
+const harden = (num: number) => {
+ return 0x80_00_00_00 + num;
+};
+
+export const bytesAddressToBinary = bytes =>
+ bytes.reduce((str, byte) => `${str}${byte.toString(2).padStart(8, '0')}`, '');
+
+export const isValidAddress = (
+ address: string,
+ currentChain: Wallet.Cardano.ChainId,
+) => {
+ try {
+ const addr = Cardano.Address.fromBech32(address);
+ if (
+ (addr.getNetworkId() === Cardano.NetworkId.Mainnet &&
+ currentChain.networkMagic === Cardano.NetworkMagics.Mainnet) ||
+ (addr.getNetworkId() === Cardano.NetworkId.Testnet &&
+ (currentChain.networkMagic === Cardano.NetworkMagics.Preview ||
+ currentChain.networkMagic === Cardano.NetworkMagics.Preprod))
+ ) {
+ return addr.toBytes();
+ }
+ return false;
+ } catch {}
+ try {
+ const addr = Cardano.ByronAddress.fromAddress(
+ Cardano.Address.fromBase58(address),
+ )?.toAddress();
+ if (
+ (addr?.getNetworkId() === Cardano.NetworkId.Mainnet &&
+ currentChain.networkMagic === Cardano.NetworkMagics.Mainnet) ||
+ (addr?.getNetworkId() === Cardano.NetworkId.Testnet &&
+ (currentChain.networkMagic === Cardano.NetworkMagics.Preview ||
+ currentChain.networkMagic === Cardano.NetworkMagics.Preprod))
+ )
+ return addr.toBytes();
+ return false;
+ } catch {}
+ return false;
+};
+
+const isValidAddressBytes = (
+ address: HexBlob,
+ currentChain: Wallet.Cardano.ChainId,
+) => {
+ try {
+ const addr = Cardano.Address.fromBytes(address);
+ if (
+ (addr.getNetworkId() === Cardano.NetworkId.Mainnet &&
+ currentChain.networkMagic === Cardano.NetworkMagics.Mainnet) ||
+ (addr.getNetworkId() === Cardano.NetworkId.Testnet &&
+ (currentChain.networkMagic === Cardano.NetworkMagics.Preview ||
+ currentChain.networkMagic === Cardano.NetworkMagics.Preprod))
+ )
+ return true;
+ return false;
+ } catch {}
+ try {
+ const addr = Cardano.ByronAddress.fromAddress(
+ Cardano.Address.fromBase58(address),
+ )?.toAddress();
+ if (
+ (addr?.getNetworkId() === Cardano.NetworkId.Mainnet &&
+ currentChain.networkMagic === Cardano.NetworkMagics.Mainnet) ||
+ (addr?.getNetworkId() === Cardano.NetworkId.Testnet &&
+ (currentChain.networkMagic === Cardano.NetworkMagics.Preview ||
+ currentChain.networkMagic === Cardano.NetworkMagics.Preprod))
+ )
+ return true;
+ return false;
+ } catch {}
+ return false;
+};
+
+// export const extractKeyHash = async (address) => {
+// await Loader.load();
+// if (!(await isValidAddressBytes(Buffer.from(address, 'hex'))))
+// throw DataSignError.InvalidFormat;
+// try {
+// const addr = Loader.Cardano.BaseAddress.from_address(
+// Loader.Cardano.Address.from_bytes(Buffer.from(address, 'hex'))
+// );
+// return addr.payment_cred().to_keyhash().to_bech32('addr_vkh');
+// } catch (e) {}
+// try {
+// const addr = Loader.Cardano.EnterpriseAddress.from_address(
+// Loader.Cardano.Address.from_bytes(Buffer.from(address, 'hex'))
+// );
+// return addr.payment_cred().to_keyhash().to_bech32('addr_vkh');
+// } catch (e) {}
+// try {
+// const addr = Loader.Cardano.PointerAddress.from_address(
+// Loader.Cardano.Address.from_bytes(Buffer.from(address, 'hex'))
+// );
+// return addr.payment_cred().to_keyhash().to_bech32('addr_vkh');
+// } catch (e) {}
+// try {
+// const addr = Loader.Cardano.RewardAddress.from_address(
+// Loader.Cardano.Address.from_bytes(Buffer.from(address, 'hex'))
+// );
+// return addr.payment_cred().to_keyhash().to_bech32('stake_vkh');
+// } catch (e) {}
+// throw DataSignError.AddressNotPK;
+// };
+
+export const extractKeyOrScriptHash = async address => {
+ await Loader.load();
+ if (!isValidAddressBytes(Buffer.from(address, 'hex')))
+ throw DataSignError.InvalidFormat;
+ try {
+ const addr = Loader.Cardano.BaseAddress.from_address(
+ Loader.Cardano.Address.from_bytes(Buffer.from(address, 'hex')),
+ );
+
+ const credential = addr.payment_cred();
+ if (credential.kind() === 0)
+ return credential.to_keyhash().to_bech32('addr_vkh');
+ if (credential.kind() === 1)
+ return credential.to_scripthash().to_bech32('script');
+ } catch {}
+ try {
+ const addr = Loader.Cardano.EnterpriseAddress.from_address(
+ Loader.Cardano.Address.from_bytes(Buffer.from(address, 'hex')),
+ );
+ const credential = addr.payment_cred();
+ if (credential.kind() === 0)
+ return credential.to_keyhash().to_bech32('addr_vkh');
+ if (credential.kind() === 1)
+ return credential.to_scripthash().to_bech32('script');
+ } catch {}
+ try {
+ const addr = Loader.Cardano.PointerAddress.from_address(
+ Loader.Cardano.Address.from_bytes(Buffer.from(address, 'hex')),
+ );
+ const credential = addr.payment_cred();
+ if (credential.kind() === 0)
+ return credential.to_keyhash().to_bech32('addr_vkh');
+ if (credential.kind() === 1)
+ return credential.to_scripthash().to_bech32('script');
+ } catch {}
+ try {
+ const addr = Loader.Cardano.RewardAddress.from_address(
+ Loader.Cardano.Address.from_bytes(Buffer.from(address, 'hex')),
+ );
+ const credential = addr.payment_cred();
+ if (credential.kind() === 0)
+ return credential.to_keyhash().to_bech32('stake_vkh');
+ if (credential.kind() === 1)
+ return credential.to_scripthash().to_bech32('script');
+ } catch {}
+ throw new Error('No address type matched.');
+};
+
+// export const verifySigStructure = async (sigStructure) => {
+// await Loader.load();
+// try {
+// Loader.Message.SigStructure.from_bytes(Buffer.from(sigStructure, 'hex'));
+// } catch (e) {
+// throw DataSignError.InvalidFormat;
+// }
+// };
+
+// export const verifyPayload = (payload) => {
+// if (Buffer.from(payload, 'hex').length <= 0)
+// throw DataSignError.InvalidFormat;
+// };
+
+// export const verifyTx = async (tx) => {
+// await Loader.load();
+// const network = await getNetwork();
+// try {
+// const parseTx = Loader.Cardano.Transaction.from_bytes(
+// Buffer.from(tx, 'hex')
+// );
+// let networkId = parseTx.body().network_id()
+// ? parseTx.body().network_id().kind()
+// : null;
+// if (!networkId && networkId != 0) {
+// networkId = parseTx.body().outputs().get(0).address().network_id();
+// }
+// if (networkId != networkNameToId(network.id)) throw Error('Wrong network');
+// } catch (e) {
+// throw APIError.InvalidRequest;
+// }
+// };
+
+// /**
+// * @param {string} address - cbor
+// * @param {string} payload - hex encoded utf8 string
+// * @param {string} password
+// * @param {number} accountIndex
+// * @returns
+// */
+
+// //deprecated soon
+// export const signData = async (address, payload, password, accountIndex) => {
+// await Loader.load();
+// const keyHash = await extractKeyHash(address);
+// const prefix = keyHash.startsWith('addr_vkh') ? 'addr_vkh' : 'stake_vkh';
+// let { paymentKey, stakeKey } = await requestAccountKey(
+// password,
+// accountIndex
+// );
+// const accountKey = prefix === 'addr_vkh' ? paymentKey : stakeKey;
+
+// const publicKey = accountKey.to_public();
+// if (keyHash !== publicKey.hash().to_bech32(prefix))
+// throw DataSignError.ProofGeneration;
+
+// const protectedHeaders = Loader.Message.HeaderMap.new();
+// protectedHeaders.set_algorithm_id(
+// Loader.Message.Label.from_algorithm_id(Loader.Message.AlgorithmId.EdDSA)
+// );
+// protectedHeaders.set_key_id(publicKey.as_bytes());
+// protectedHeaders.set_header(
+// Loader.Message.Label.new_text('address'),
+// Loader.Message.CBORValue.new_bytes(Buffer.from(address, 'hex'))
+// );
+// const protectedSerialized =
+// Loader.Message.ProtectedHeaderMap.new(protectedHeaders);
+// const unprotectedHeaders = Loader.Message.HeaderMap.new();
+// const headers = Loader.Message.Headers.new(
+// protectedSerialized,
+// unprotectedHeaders
+// );
+// const builder = Loader.Message.COSESign1Builder.new(
+// headers,
+// Buffer.from(payload, 'hex'),
+// false
+// );
+// const toSign = builder.make_data_to_sign().to_bytes();
+
+// const signedSigStruc = accountKey.sign(toSign).to_bytes();
+// const coseSign1 = builder.build(signedSigStruc);
+
+// stakeKey.free();
+// stakeKey = null;
+// paymentKey.free();
+// paymentKey = null;
+
+// return Buffer.from(coseSign1.to_bytes(), 'hex').toString('hex');
+// };
+
+// export const signDataCIP30 = async (
+// address,
+// payload,
+// password,
+// accountIndex
+// ) => {
+// await Loader.load();
+// const keyHash = await extractKeyHash(address);
+// const prefix = keyHash.startsWith('addr_vkh') ? 'addr_vkh' : 'stake_vkh';
+// let { paymentKey, stakeKey } = await requestAccountKey(
+// password,
+// accountIndex
+// );
+// const accountKey = prefix === 'addr_vkh' ? paymentKey : stakeKey;
+
+// const publicKey = accountKey.to_public();
+// if (keyHash !== publicKey.hash().to_bech32(prefix))
+// throw DataSignError.ProofGeneration;
+// const protectedHeaders = Loader.Message.HeaderMap.new();
+// protectedHeaders.set_algorithm_id(
+// Loader.Message.Label.from_algorithm_id(Loader.Message.AlgorithmId.EdDSA)
+// );
+// // protectedHeaders.set_key_id(publicKey.as_bytes()); // Removed to adhere to CIP-30
+// protectedHeaders.set_header(
+// Loader.Message.Label.new_text('address'),
+// Loader.Message.CBORValue.new_bytes(Buffer.from(address, 'hex'))
+// );
+// const protectedSerialized =
+// Loader.Message.ProtectedHeaderMap.new(protectedHeaders);
+// const unprotectedHeaders = Loader.Message.HeaderMap.new();
+// const headers = Loader.Message.Headers.new(
+// protectedSerialized,
+// unprotectedHeaders
+// );
+// const builder = Loader.Message.COSESign1Builder.new(
+// headers,
+// Buffer.from(payload, 'hex'),
+// false
+// );
+// const toSign = builder.make_data_to_sign().to_bytes();
+
+// const signedSigStruc = accountKey.sign(toSign).to_bytes();
+// const coseSign1 = builder.build(signedSigStruc);
+
+// stakeKey.free();
+// stakeKey = null;
+// paymentKey.free();
+// paymentKey = null;
+
+// const key = Loader.Message.COSEKey.new(
+// Loader.Message.Label.from_key_type(Loader.Message.KeyType.OKP)
+// );
+// key.set_algorithm_id(
+// Loader.Message.Label.from_algorithm_id(Loader.Message.AlgorithmId.EdDSA)
+// );
+// key.set_header(
+// Loader.Message.Label.new_int(
+// Loader.Message.Int.new_negative(Loader.Message.BigNum.from_str('1'))
+// ),
+// Loader.Message.CBORValue.new_int(
+// Loader.Message.Int.new_i32(6) //Loader.Message.CurveType.Ed25519
+// )
+// ); // crv (-1) set to Ed25519 (6)
+// key.set_header(
+// Loader.Message.Label.new_int(
+// Loader.Message.Int.new_negative(Loader.Message.BigNum.from_str('2'))
+// ),
+// Loader.Message.CBORValue.new_bytes(publicKey.as_bytes())
+// ); // x (-2) set to public key
+
+// return {
+// signature: Buffer.from(coseSign1.to_bytes()).toString('hex'),
+// key: Buffer.from(key.to_bytes()).toString('hex'),
+// };
+// };
+
+export const signTx = async (
+ tx: string,
+ keyHashes: string[],
+ password: string,
+ accountIndex: number,
+ partialSign = false,
+) => {
+ let { paymentKey, stakeKey } = await requestAccountKey(
+ password,
+ accountIndex,
+ );
+ const paymentKeyHash = Buffer.from(
+ paymentKey.to_public().hash().to_bytes(),
+ 'hex',
+ ).toString('hex');
+ const stakeKeyHash = Buffer.from(
+ stakeKey.to_public().hash().to_bytes(),
+ 'hex',
+ ).toString('hex');
+
+ const rawTx = Loader.Cardano.Transaction.from_bytes(Buffer.from(tx, 'hex'));
+
+ const txWitnessSet = Loader.Cardano.TransactionWitnessSet.new();
+ const vkeyWitnesses = Loader.Cardano.Vkeywitnesses.new();
+ const txHash = Loader.Cardano.hash_transaction(rawTx.body());
+ for (const keyHash of keyHashes) {
+ let signingKey;
+ if (keyHash === paymentKeyHash) signingKey = paymentKey;
+ else if (keyHash === stakeKeyHash) signingKey = stakeKey;
+ else if (partialSign) {
+ continue;
+ } else {
+ throw TxSignError.ProofGeneration;
+ }
+ const vkey = Loader.Cardano.make_vkey_witness(txHash, signingKey);
+ vkeyWitnesses.add(vkey);
+ }
+
+ stakeKey.free();
+ stakeKey = null;
+ paymentKey.free();
+ paymentKey = null;
+
+ txWitnessSet.set_vkeys(vkeyWitnesses);
+ return txWitnessSet;
+};
+
+export const signTxHW = async (
+ tx,
+ keyHashes,
+ account,
+ hw,
+ partialSign = false,
+) => {
+ const rawTx = Loader.Cardano.Transaction.from_bytes(Buffer.from(tx, 'hex'));
+ const address = Cardano.Address.fromBech32(account.paymentAddr);
+ const network = address.getNetworkId();
+ const keys = {
+ payment: { hash: null, path: null },
+ stake: { hash: null, path: null },
+ };
+ if (hw.device === HW.ledger) {
+ const appAda = hw.appAda;
+ for (const keyHash of keyHashes) {
+ if (keyHash === account.paymentKeyHash)
+ keys.payment = {
+ hash: keyHash,
+ path: [
+ HARDENED + 1852,
+ HARDENED + 1815,
+ HARDENED + (hw.account as number),
+ 0,
+ 0,
+ ],
+ };
+ else if (keyHash === account.stakeKeyHash)
+ keys.stake = {
+ hash: keyHash,
+ path: [
+ HARDENED + 1852,
+ HARDENED + 1815,
+ HARDENED + (hw.account as number),
+ 2,
+ 0,
+ ],
+ };
+ else if (partialSign) {
+ continue;
+ } else {
+ throw TxSignError.ProofGeneration;
+ }
+ }
+ const ledgerTx = await txToLedger(
+ rawTx,
+ network,
+ keys,
+ Buffer.from(address.toBytes()).toString('hex'),
+ hw.account,
+ );
+ const result = await appAda.signTransaction(ledgerTx);
+ // getting public keys
+ const witnessSet = Loader.Cardano.TransactionWitnessSet.new();
+ const vkeys = Loader.Cardano.Vkeywitnesses.new();
+ for (const witness of result.witnesses) {
+ if (
+ witness.path[3] == 0 // payment key
+ ) {
+ const vkey = Loader.Cardano.Vkey.new(
+ Loader.Cardano.Bip32PublicKey.from_bytes(
+ Buffer.from(account.publicKey, 'hex'),
+ )
+ .derive(0)
+ .derive(0)
+ .to_raw_key(),
+ );
+ const signature = Loader.Cardano.Ed25519Signature.from_hex(
+ witness.witnessSignatureHex,
+ );
+ vkeys.add(Loader.Cardano.Vkeywitness.new(vkey, signature));
+ } else if (
+ witness.path[3] == 2 // stake key
+ ) {
+ const vkey = Loader.Cardano.Vkey.new(
+ Loader.Cardano.Bip32PublicKey.from_bytes(
+ Buffer.from(account.publicKey, 'hex'),
+ )
+ .derive(2)
+ .derive(0)
+ .to_raw_key(),
+ );
+ const signature = Loader.Cardano.Ed25519Signature.from_hex(
+ witness.witnessSignatureHex,
+ );
+ vkeys.add(Loader.Cardano.Vkeywitness.new(vkey, signature));
+ }
+ }
+ witnessSet.set_vkeys(vkeys);
+ return witnessSet;
+ } else {
+ for (const keyHash of keyHashes) {
+ if (keyHash === account.paymentKeyHash)
+ keys.payment = {
+ hash: keyHash,
+ path: `m/1852'/1815'/${hw.account}'/0/0`,
+ };
+ else if (keyHash === account.stakeKeyHash)
+ keys.stake = {
+ hash: keyHash,
+ path: `m/1852'/1815'/${hw.account}'/2/0`,
+ };
+ else if (partialSign) {
+ continue;
+ } else {
+ throw TxSignError.ProofGeneration;
+ }
+ }
+ const trezorTx = await txToTrezor(
+ rawTx,
+ network,
+ keys,
+ Buffer.from(address.toBytes()).toString('hex'),
+ hw.account,
+ );
+ const result = await TrezorConnect.cardanoSignTransaction(trezorTx);
+ if (!result.success) throw new Error('Trezor could not sign tx');
+ // getting public keys
+ const witnessSet = Loader.Cardano.TransactionWitnessSet.new();
+ const vkeys = Loader.Cardano.Vkeywitnesses.new();
+ for (const witness of result.payload.witnesses) {
+ const vkey = Loader.Cardano.Vkey.new(
+ Loader.Cardano.PublicKey.from_bytes(Buffer.from(witness.pubKey, 'hex')),
+ );
+ const signature = Loader.Cardano.Ed25519Signature.from_hex(
+ witness.signature,
+ );
+ vkeys.add(Loader.Cardano.Vkeywitness.new(vkey, signature));
+ }
+ witnessSet.set_vkeys(vkeys);
+ return witnessSet;
+ }
+};
+
+// /**
+// *
+// * @param {string} tx - cbor hex string
+// * @returns
+// */
+
+export const submitTx = async (
+ tx: string,
+ inMemoryWallet: Wallet.ObservableWallet,
+): Promise => {
+ try {
+ const result = await inMemoryWallet.submitTx(TxCBOR(tx));
+ return result;
+ } catch (error) {
+ if (
+ error instanceof ProviderError &&
+ ProviderFailure.BadRequest === error.reason
+ ) {
+ throw { ...TxSendError.Failure, message: error.message };
+ }
+ throw APIError.InvalidRequest;
+ }
+};
+
+// const emitNetworkChange = async (networkId) => {
+// //to webpage
+// chrome.tabs.query({}, (tabs) => {
+// tabs.forEach((tab) =>
+// chrome.tabs.sendMessage(tab.id, {
+// data: networkId,
+// target: TARGET,
+// sender: SENDER.extension,
+// event: EVENT.networkChange,
+// })
+// );
+// });
+// };
+
+// const emitAccountChange = async (addresses) => {
+// //to extenstion itself
+// if (typeof window !== 'undefined') {
+// window.postMessage({
+// data: addresses,
+// target: TARGET,
+// sender: SENDER.extension,
+// event: EVENT.accountChange,
+// });
+// }
+// //to webpage
+// chrome.tabs.query({}, (tabs) => {
+// tabs.forEach((tab) =>
+// chrome.tabs.sendMessage(tab.id, {
+// data: addresses,
+// target: TARGET,
+// sender: SENDER.extension,
+// event: EVENT.accountChange,
+// })
+// );
+// });
+// };
+
+export const onAccountChange = callback => {
+ const responseHandler = e => {
+ const response = e.data;
+ if (
+ typeof response !== 'object' ||
+ response === null ||
+ !response.target ||
+ response.target !== TARGET ||
+ !response.event ||
+ response.event !== EVENT.accountChange ||
+ !response.sender ||
+ response.sender !== SENDER.extension
+ )
+ return;
+ callback(response.data);
+ };
+ window.addEventListener('message', responseHandler);
+ return {
+ remove: () => {
+ window.removeEventListener('message', responseHandler);
+ },
+ };
+};
+
+export const requestAccountKey = async (password, accountIndex) => {
+ await Loader.load();
+ const encryptedRootKey = await getStorage(STORAGE.encryptedKey);
+ let accountKey;
+ try {
+ accountKey = Loader.Cardano.Bip32PrivateKey.from_bytes(
+ Buffer.from(await decryptWithPassword(password, encryptedRootKey), 'hex'),
+ )
+ .derive(harden(1852)) // purpose
+ .derive(harden(1815)) // coin type;
+ .derive(harden(Number.parseInt(accountIndex)));
+ } catch {
+ throw ERROR.wrongPassword;
+ }
+
+ return {
+ accountKey,
+ paymentKey: accountKey.derive(0).derive(0).to_raw_key(),
+ stakeKey: accountKey.derive(2).derive(0).to_raw_key(),
+ };
+};
+
+export const createHWAccounts = async accounts => {
+ await Loader.load();
+ const existingAccounts = await getStorage(STORAGE.accounts);
+ for (const account of accounts) {
+ const publicKey = Loader.Cardano.Bip32PublicKey.from_bytes(
+ Buffer.from(account.publicKey, 'hex'),
+ );
+
+ const paymentKeyHashRaw = publicKey.derive(0).derive(0).to_raw_key().hash();
+ const stakeKeyHashRaw = publicKey.derive(2).derive(0).to_raw_key().hash();
+
+ const paymentKeyHash = Buffer.from(paymentKeyHashRaw.to_bytes()).toString(
+ 'hex',
+ );
+
+ const paymentKeyHashBech32 = paymentKeyHashRaw.to_bech32('addr_vkh');
+
+ const stakeKeyHash = Buffer.from(stakeKeyHashRaw.to_bytes()).toString(
+ 'hex',
+ );
+
+ const paymentAddrMainnet = Loader.Cardano.BaseAddress.new(
+ Loader.Cardano.NetworkInfo.mainnet().network_id(),
+ Loader.Cardano.StakeCredential.from_keyhash(paymentKeyHashRaw),
+ Loader.Cardano.StakeCredential.from_keyhash(stakeKeyHashRaw),
+ )
+ .to_address()
+ .to_bech32();
+
+ const rewardAddrMainnet = Loader.Cardano.RewardAddress.new(
+ Loader.Cardano.NetworkInfo.mainnet().network_id(),
+ Loader.Cardano.StakeCredential.from_keyhash(stakeKeyHashRaw),
+ )
+ .to_address()
+ .to_bech32();
+
+ const paymentAddrTestnet = Loader.Cardano.BaseAddress.new(
+ Loader.Cardano.NetworkInfo.testnet().network_id(),
+ Loader.Cardano.StakeCredential.from_keyhash(paymentKeyHashRaw),
+ Loader.Cardano.StakeCredential.from_keyhash(stakeKeyHashRaw),
+ )
+ .to_address()
+ .to_bech32();
+
+ const rewardAddrTestnet = Loader.Cardano.RewardAddress.new(
+ Loader.Cardano.NetworkInfo.testnet().network_id(),
+ Loader.Cardano.StakeCredential.from_keyhash(stakeKeyHashRaw),
+ )
+ .to_address()
+ .to_bech32();
+
+ const index = account.accountIndex;
+ const name = account.name;
+
+ const networkDefault = {
+ lovelace: null,
+ minAda: 0,
+ assets: [],
+ history: { confirmed: [], details: {} },
+ };
+
+ existingAccounts[index] = {
+ index,
+ publicKey: Buffer.from(publicKey.as_bytes()).toString('hex'),
+ paymentKeyHash,
+ paymentKeyHashBech32,
+ stakeKeyHash,
+ name,
+ [NETWORK_ID.mainnet]: {
+ ...networkDefault,
+ paymentAddr: paymentAddrMainnet,
+ rewardAddr: rewardAddrMainnet,
+ },
+ [NETWORK_ID.testnet]: {
+ ...networkDefault,
+ paymentAddr: paymentAddrTestnet,
+ rewardAddr: rewardAddrTestnet,
+ },
+ [NETWORK_ID.preview]: {
+ ...networkDefault,
+ paymentAddr: paymentAddrTestnet,
+ rewardAddr: rewardAddrTestnet,
+ },
+ [NETWORK_ID.preprod]: {
+ ...networkDefault,
+ paymentAddr: paymentAddrTestnet,
+ rewardAddr: rewardAddrTestnet,
+ },
+ avatar: Math.random().toString(),
+ };
+ }
+ await setStorage({
+ [STORAGE.accounts]: existingAccounts,
+ });
+};
+
+export const indexToHw = accountIndex => ({
+ device: accountIndex.split('-')[0],
+ id: accountIndex.split('-')[1],
+ account: Number.parseInt(accountIndex.split('-')[2]),
+});
+
+export const getHwAccounts = async ({ device, id }) => {
+ const accounts = await getStorage(STORAGE.accounts);
+ const hwAccounts = {};
+ for (const accountIndex of Object.keys(accounts).filter(
+ accountIndex =>
+ isHW(accountIndex) &&
+ indexToHw(accountIndex).device == device &&
+ indexToHw(accountIndex).id == id,
+ ))
+ hwAccounts[accountIndex] = accounts[accountIndex];
+ return hwAccounts;
+};
+
+export const isHW = accountIndex =>
+ accountIndex != undefined &&
+ accountIndex != 0 &&
+ typeof accountIndex !== 'number' &&
+ (accountIndex.startsWith(HW.trezor) || accountIndex.startsWith(HW.ledger));
+
+export const initHW = async ({ device, id }) => {
+ return await Promise.resolve({});
+ // if (device == HW.ledger) {
+ // const foundDevice = await new Promise((res, rej) =>
+ // navigator.usb
+ // .getDevices()
+ // .then((devices) =>
+ // res(
+ // devices.find(
+ // (device) =>
+ // device.productId == id && device.manufacturerName === 'Ledger'
+ // )
+ // )
+ // )
+ // );
+ // const transport = await TransportWebUSB.open(foundDevice);
+ // const appAda = new Ada(transport);
+ // await appAda.getVersion(); // check if Ledger has Cardano app opened
+ // return appAda;
+ // } else if (device == HW.trezor) {
+ // try {
+ // await TrezorConnect.init({
+ // manifest: {
+ // email: 'namiwallet.cardano@gmail.com',
+ // appUrl: 'http://namiwallet.io',
+ // },
+ // });
+ // } catch (e) {}
+ // }
+};
+
+// /**
+// *
+// * @param {string} assetName utf8 encoded
+// */
+export const getAdaHandle = async (
+ assetName: string,
+ handleResolver: HandleProvider,
+) => {
+ try {
+ if (!assetName) {
+ return null;
+ }
+ const resolvedHandle = await handleResolver.resolveHandles({
+ handles: [assetName],
+ });
+
+ return resolvedHandle[0]?.cardanoAddress ?? null;
+ } catch {
+ return null;
+ }
+};
+
+// export const createWallet = async (name, seedPhrase, password) => {
+// await Loader.load();
+
+// let entropy = mnemonicToEntropy(seedPhrase);
+// let rootKey = Loader.Cardano.Bip32PrivateKey.from_bip39_entropy(
+// Buffer.from(entropy, 'hex'),
+// Buffer.from('')
+// );
+// entropy = null;
+// seedPhrase = null;
+
+// const encryptedRootKey = await encryptWithPassword(
+// password,
+// rootKey.as_bytes()
+// );
+// rootKey.free();
+// rootKey = null;
+
+// const checkStore = await getStorage(STORAGE.encryptedKey);
+// if (checkStore) throw new Error(ERROR.storeNotEmpty);
+// await setStorage({ [STORAGE.encryptedKey]: encryptedRootKey });
+// await setStorage({
+// [STORAGE.network]: { id: NETWORK_ID.mainnet, node: NODE.mainnet },
+// });
+
+// await setStorage({
+// [STORAGE.currency]: 'usd',
+// });
+
+// const index = await createAccount(name, password);
+
+// //check for sub accounts
+// let searchIndex = 1;
+// while (true) {
+// let { paymentKey, stakeKey } = await requestAccountKey(
+// password,
+// searchIndex
+// );
+// const paymentKeyHashBech32 = paymentKey
+// .to_public()
+// .hash()
+// .to_bech32('addr_vkh');
+// // const stakeKeyHash = stakeKey.to_public().hash();
+// paymentKey.free();
+// // stakeKey.free();
+// paymentKey = null;
+// // stakeKey = null;
+// // const paymentAddr = Loader.Cardano.BaseAddress.new(
+// // Loader.Cardano.NetworkInfo.mainnet().network_id(),
+// // Loader.Cardano.StakeCredential.from_keyhash(paymentKeyHash),
+// // Loader.Cardano.StakeCredential.from_keyhash(stakeKeyHash)
+// // )
+// // .to_address()
+// // .to_bech32();
+// const transactions = await blockfrostRequest(
+// `/addresses/${paymentKeyHashBech32}/transactions`
+// );
+// if (transactions && !transactions.error && transactions.length >= 1)
+// createAccount(`Account ${searchIndex}`, password, searchIndex);
+// else break;
+// searchIndex++;
+// }
+
+// password = null;
+// await switchAccount(index);
+
+// return true;
+// };
+
+// export const mnemonicToObject = (mnemonic) => {
+// const mnemonicMap = {};
+// mnemonic.split(' ').forEach((word, index) => (mnemonicMap[index + 1] = word));
+// return mnemonicMap;
+// };
+
+// export const mnemonicFromObject = (mnemonicMap) => {
+// return Object.keys(mnemonicMap).reduce(
+// (acc, key) => (acc ? acc + ' ' + mnemonicMap[key] : acc + mnemonicMap[key]),
+// ''
+// );
+// };
+
+export const avatarToImage = avatar => {
+ const blob = new Blob(
+ [
+ createAvatar(style, {
+ seed: avatar,
+ }),
+ ],
+ { type: 'image/svg+xml' },
+ );
+ return URL.createObjectURL(blob);
+};
+
+export const getAsset = async unit => {
+ return await Promise.resolve({});
+ // if (!window.assets) {
+ // window.assets = JSON.parse(
+ // localStorage.getItem(LOCAL_STORAGE.assets) || '{}'
+ // );
+ // }
+ // const assets = window.assets;
+ // const asset = assets[unit] || {};
+ // const time = Date.now();
+ // const h1 = 6000000;
+ // if (asset && asset.time && time - asset.time <= h1 && !asset.mint) {
+ // return asset;
+ // } else {
+ // const { policyId, name, label } = fromAssetUnit(unit);
+ // const bufferName = Buffer.from(name, 'hex');
+ // asset.unit = unit;
+ // asset.policy = policyId;
+ // asset.fingerprint = AssetFingerprint.fromParts(
+ // Buffer.from(policyId, 'hex'),
+ // bufferName
+ // ).fingerprint();
+ // asset.name = Number.isInteger(label)
+ // ? `(${label}) ` + bufferName.toString()
+ // : bufferName.toString();
+
+ // // CIP-0067 & CIP-0068 (support 222 and 333 sub standards)
+
+ // if (label === 222) {
+ // const refUnit = toAssetUnit(policyId, name, 100);
+ // try {
+ // const owners = await blockfrostRequest(`/assets/${refUnit}/addresses`);
+ // if (!owners || owners.error) {
+ // throw new Error('No owner found.');
+ // }
+ // const [refUtxo] = await blockfrostRequest(
+ // `/addresses/${owners[0].address}/utxos/${refUnit}`
+ // );
+ // const datum =
+ // refUtxo?.inline_datum ||
+ // (await blockfrostRequest(`/scripts/datum/${refUtxo?.data_hash}/cbor`))
+ // ?.cbor;
+ // const metadataDatum = datum && (await Data.from(datum));
+
+ // if (metadataDatum.index !== 0) throw new Error('No correct metadata.');
+
+ // const metadata = metadataDatum && Data.toJson(metadataDatum.fields[0]);
+
+ // asset.displayName = metadata.name;
+ // asset.image = metadata.image ? linkToSrc(convertMetadataPropToString(metadata.image)) : '';
+ // asset.decimals = 0;
+ // } catch (_e) {
+ // asset.displayName = asset.name;
+ // asset.mint = true;
+ // }
+ // } else if (label === 333) {
+ // const refUnit = toAssetUnit(policyId, name, 100);
+ // try {
+ // const owners = await blockfrostRequest(`/assets/${refUnit}/addresses`);
+ // if (!owners || owners.error) {
+ // throw new Error('No owner found.');
+ // }
+ // const [refUtxo] = await blockfrostRequest(
+ // `/addresses/${owners[0].address}/utxos/${refUnit}`
+ // );
+ // const datum =
+ // refUtxo?.inline_datum ||
+ // (await blockfrostRequest(`/scripts/datum/${refUtxo?.data_hash}/cbor`))
+ // ?.cbor;
+ // const metadataDatum = datum && (await Data.from(datum));
+
+ // if (metadataDatum.index !== 0) throw new Error('No correct metadata.');
+
+ // const metadata = metadataDatum && Data.toJson(metadataDatum.fields[0]);
+
+ // asset.displayName = metadata.name;
+ // asset.image = linkToSrc(convertMetadataPropToString(metadata.logo)) || '';
+ // asset.decimals = metadata.decimals || 0;
+ // } catch (_e) {
+ // asset.displayName = asset.name;
+ // asset.mint = true;
+ // }
+ // } else {
+ // let result = await blockfrostRequest(`/assets/${unit}`);
+ // if (!result || result.error) {
+ // result = {};
+ // asset.mint = true;
+ // }
+ // const onchainMetadata =
+ // result.onchain_metadata &&
+ // ((result.onchain_metadata.version === 2 &&
+ // result.onchain_metadata?.[`0x${policyId}`]?.[`0x${name}`]) ||
+ // result.onchain_metadata);
+ // asset.displayName =
+ // (onchainMetadata && onchainMetadata.name) ||
+ // (result.metadata && result.metadata.name) ||
+ // asset.name;
+ // asset.image =
+ // (onchainMetadata &&
+ // onchainMetadata.image &&
+ // linkToSrc(convertMetadataPropToString(onchainMetadata.image))) ||
+ // (result.metadata &&
+ // result.metadata.logo &&
+ // linkToSrc(result.metadata.logo, true)) ||
+ // '';
+ // asset.decimals = (result.metadata && result.metadata.decimals) || 0;
+ // if (!asset.name) {
+ // if (asset.displayName) asset.name = asset.displayName[0];
+ // else asset.name = '-';
+ // }
+ // }
+ // asset.time = Date.now();
+ // assets[unit] = asset;
+ // window.assets = assets;
+ // localStorage.setItem(LOCAL_STORAGE.assets, JSON.stringify(assets));
+ // return asset;
+ // }
+};
+
+// export const updateBalance = async (currentAccount, network) => {
+// await Loader.load();
+// const assets = await getBalanceExtended();
+// const amount = await assetsToValue(assets);
+// await checkCollateral(currentAccount, network);
+
+// if (assets.length > 0) {
+// currentAccount[network.id].lovelace = assets.find(
+// (am) => am.unit === 'lovelace'
+// ).quantity;
+// currentAccount[network.id].assets = assets.filter(
+// (am) => am.unit !== 'lovelace'
+// );
+// if (currentAccount[network.id].assets.length > 0) {
+// const protocolParameters = await initTx();
+// const checkOutput = Loader.Cardano.TransactionOutput.new(
+// Loader.Cardano.Address.from_bech32(
+// currentAccount[network.id].paymentAddr
+// ),
+// amount
+// );
+// const minAda = Loader.Cardano.min_ada_required(
+// checkOutput,
+// Loader.Cardano.BigNum.from_str(protocolParameters.coinsPerUtxoWord)
+// ).to_str();
+// currentAccount[network.id].minAda = minAda;
+// } else {
+// currentAccount[network.id].minAda = 0;
+// }
+// } else {
+// currentAccount[network.id].lovelace = 0;
+// currentAccount[network.id].assets = [];
+// currentAccount[network.id].minAda = 0;
+// }
+// return true;
+// };
+
+// const updateTransactions = async (currentAccount, network) => {
+// const transactions = await getTransactions();
+// if (
+// transactions.length <= 0 ||
+// currentAccount[network.id].history.confirmed.includes(
+// transactions[0].txHash
+// )
+// )
+// return false;
+// let txHashes = transactions.map((tx) => tx.txHash);
+// txHashes = txHashes.concat(currentAccount[network.id].history.confirmed);
+// const txSet = new Set(txHashes);
+// currentAccount[network.id].history.confirmed = Array.from(txSet);
+// return true;
+// };
+
+export const setTransactions = async txs => {
+ // const currentIndex = await getCurrentAccountIndex();
+ // const network = await getNetwork();
+ // const accounts = await getStorage(STORAGE.accounts);
+ // accounts[currentIndex][network.id].history.confirmed = txs;
+ // return await setStorage({
+ // [STORAGE.accounts]: {
+ // ...accounts,
+ // },
+ // });
+};
+
+export const updateAccount = async (forceUpdate = false) => {
+ // const currentIndex = await getCurrentAccountIndex();
+ // const accounts = await getStorage(STORAGE.accounts);
+ // const currentAccount = accounts[currentIndex];
+ // const network = await getNetwork();
+ // await updateTransactions(currentAccount, network);
+ // if (
+ // currentAccount[network.id].history.confirmed[0] ==
+ // currentAccount[network.id].lastUpdate &&
+ // !forceUpdate &&
+ // !currentAccount[network.id].forceUpdate
+ // ) {
+ // if (currentAccount[network.id].lovelace == null) {
+ // // first initilization of account
+ // currentAccount[network.id].lovelace = '0';
+ // await setStorage({
+ // [STORAGE.accounts]: {
+ // ...accounts,
+ // },
+ // });
+ // }
+ // return;
+ // }
+ // // forcing acccount update for in case of breaking changes in an Nami update
+ // if (currentAccount[network.id].forceUpdate)
+ // delete currentAccount[network.id].forceUpdate;
+ // await updateBalance(currentAccount, network);
+ // currentAccount[network.id].lastUpdate =
+ // currentAccount[network.id].history.confirmed[0];
+ // return await setStorage({
+ // [STORAGE.accounts]: {
+ // ...accounts,
+ // },
+ // });
+};
+
+export const displayUnit = (quantity, decimals = 6) => {
+ return Number.parseInt(quantity) / 10 ** decimals;
+};
+
+export const toUnit = (amount, decimals = 6) => {
+ if (!amount) return '0';
+ let result = Number.parseFloat(
+ amount.toString().replace(/[\s,]/g, ''),
+ ).toLocaleString('en-EN', { minimumFractionDigits: decimals });
+ const split = result.split('.');
+ const front = split[0].replace(/[\s,]/g, '');
+ result =
+ (front == 0 ? '' : front) + (split[1] ? split[1].slice(0, decimals) : '');
+ if (!result) return '0';
+ else if (result == 'NaN') return '0';
+ return result;
+};
diff --git a/packages/nami/src/api/extension/wallet.mock.ts b/packages/nami/src/api/extension/wallet.mock.ts
new file mode 100644
index 000000000..ff52da679
--- /dev/null
+++ b/packages/nami/src/api/extension/wallet.mock.ts
@@ -0,0 +1,13 @@
+import { fn } from '@storybook/test';
+
+import * as actualApi from './wallet';
+
+export * from './wallet';
+
+export const initTx = fn(actualApi.initTx).mockName('initTx');
+
+export const buildTx = fn(actualApi.buildTx).mockName('buildTx');
+
+export const undelegateTx = fn(actualApi.undelegateTx).mockName('undelegateTx');
+
+export const withdrawalTx = fn(actualApi.withdrawalTx).mockName('withdrawalTx');
diff --git a/packages/nami/src/api/extension/wallet.ts b/packages/nami/src/api/extension/wallet.ts
new file mode 100644
index 000000000..6ec03d3a4
--- /dev/null
+++ b/packages/nami/src/api/extension/wallet.ts
@@ -0,0 +1,327 @@
+/* eslint-disable max-params */
+import { Cardano, Serialization } from '@cardano-sdk/core';
+import { firstValueFrom } from 'rxjs';
+
+import { ERROR, TX } from '../../config/config';
+// import { Loader } from '../loader';
+import { blockfrostRequest } from '../util';
+
+import { signTxHW, submitTx } from '.';
+
+import type { OutsideHandlesContextValue } from '../../ui';
+import type { UnwitnessedTx } from '@cardano-sdk/tx-construction';
+import type { Wallet } from '@lace/cardano';
+
+// const WEIGHTS = Uint32Array.from([
+// 200, // weight ideal > 100 inputs
+// 1000, // weight ideal < 100 inputs
+// 1500, // weight assets if plutus
+// 800, // weight assets if not plutus
+// 800, // weight distance if not plutus
+// 5000, // weight utxos
+// ]);
+
+export const initTx = async () => {
+ return await Promise.resolve({});
+ // const latest_block = await blockfrostRequest('/blocks/latest');
+ // const p = await blockfrostRequest(`/epochs/latest/parameters`);
+ // return {
+ // linearFee: {
+ // minFeeA: p.min_fee_a.toString(),
+ // minFeeB: p.min_fee_b.toString(),
+ // },
+ // minUtxo: '1000000', //p.min_utxo, minUTxOValue protocol paramter has been removed since Alonzo HF. Calulation of minADA works differently now, but 1 minADA still sufficient for now
+ // poolDeposit: p.pool_deposit,
+ // keyDeposit: p.key_deposit,
+ // coinsPerUtxoWord: p.coins_per_utxo_size.toString(),
+ // maxValSize: p.max_val_size,
+ // priceMem: p.price_mem,
+ // priceStep: p.price_step,
+ // maxTxSize: Number.parseInt(p.max_tx_size),
+ // slot: Number.parseInt(latest_block.slot),
+ // collateralPercentage: Number.parseInt(p.collateral_percent),
+ // maxCollateralInputs: Number.parseInt(p.max_collateral_inputs),
+ // };
+};
+
+export const buildTx = async (
+ output: Serialization.TransactionOutput,
+ auxiliaryData: Serialization.AuxiliaryData,
+ inMemoryWallet: Wallet.ObservableWallet,
+): Promise => {
+ const txBuilder = inMemoryWallet.createTxBuilder();
+ const metadata = auxiliaryData.metadata()?.toCore();
+ const tip = await firstValueFrom(inMemoryWallet.tip$);
+ txBuilder.addOutput(output.toCore());
+
+ if (metadata) {
+ txBuilder.metadata(metadata);
+ }
+
+ txBuilder.setValidityInterval({
+ invalidHereafter: Cardano.Slot(tip.slot + TX.invalid_hereafter),
+ });
+
+ const transaction = txBuilder.build();
+
+ return transaction;
+};
+
+export const signAndSubmit = async (
+ tx: UnwitnessedTx,
+ password: string,
+ withSignTxConfirmation: OutsideHandlesContextValue['withSignTxConfirmation'],
+ inMemoryWallet: Wallet.ObservableWallet,
+) =>
+ withSignTxConfirmation(async () => {
+ const { cbor: signedTx } = await tx.sign();
+
+ const txHash = await submitTx(signedTx, inMemoryWallet);
+
+ return txHash;
+ }, password);
+
+export const signAndSubmitHW = async (
+ tx: Serialization.Transaction,
+ {
+ keyHashes,
+ account,
+ hw,
+ partialSign,
+ }: Readonly<{ keyHashes: any; account: any; hw: any; partialSign?: boolean }>,
+) => {
+ const witnessSet = await signTxHW(
+ tx.toCbor(),
+ keyHashes,
+ account,
+ hw,
+ partialSign,
+ );
+
+ const transaction = new Serialization.Transaction(
+ tx.body(),
+ witnessSet,
+ tx.auxiliaryData(),
+ );
+
+ try {
+ const txHash = await submitTx(transaction.toCbor());
+ return txHash;
+ } catch {
+ throw ERROR.submit;
+ }
+};
+
+export const delegationTx = async (
+ account,
+ delegation,
+ protocolParameters,
+ poolKeyHash,
+) => {
+ return await Promise.resolve({});
+ // await Loader.load();
+
+ // const txBuilderConfig = Loader.Cardano.TransactionBuilderConfigBuilder.new()
+ // .coins_per_utxo_byte(
+ // Loader.Cardano.BigNum.from_str(protocolParameters.coinsPerUtxoWord)
+ // )
+ // .fee_algo(
+ // Loader.Cardano.LinearFee.new(
+ // Loader.Cardano.BigNum.from_str(protocolParameters.linearFee.minFeeA),
+ // Loader.Cardano.BigNum.from_str(protocolParameters.linearFee.minFeeB)
+ // )
+ // )
+ // .key_deposit(Loader.Cardano.BigNum.from_str(protocolParameters.keyDeposit))
+ // .pool_deposit(
+ // Loader.Cardano.BigNum.from_str(protocolParameters.poolDeposit)
+ // )
+ // .max_tx_size(protocolParameters.maxTxSize)
+ // .max_value_size(protocolParameters.maxValSize)
+ // .ex_unit_prices(Loader.Cardano.ExUnitPrices.from_float(0, 0))
+ // .collateral_percentage(protocolParameters.collateralPercentage)
+ // .max_collateral_inputs(protocolParameters.maxCollateralInputs)
+ // .build();
+
+ // const txBuilder = Loader.Cardano.TransactionBuilder.new(txBuilderConfig);
+
+ // if (!delegation.active)
+ // txBuilder.add_certificate(
+ // Loader.Cardano.Certificate.new_stake_registration(
+ // Loader.Cardano.StakeRegistration.new(
+ // Loader.Cardano.StakeCredential.from_keyhash(
+ // Loader.Cardano.Ed25519KeyHash.from_bytes(
+ // Buffer.from(account.stakeKeyHash, 'hex')
+ // )
+ // )
+ // )
+ // )
+ // );
+
+ // txBuilder.add_certificate(
+ // Loader.Cardano.Certificate.new_stake_delegation(
+ // Loader.Cardano.StakeDelegation.new(
+ // Loader.Cardano.StakeCredential.from_keyhash(
+ // Loader.Cardano.Ed25519KeyHash.from_bytes(
+ // Buffer.from(account.stakeKeyHash, 'hex')
+ // )
+ // ),
+ // Loader.Cardano.Ed25519KeyHash.from_bytes(
+ // Buffer.from(poolKeyHash, 'hex')
+ // )
+ // )
+ // )
+ // );
+
+ // txBuilder.set_ttl(
+ // Loader.Cardano.BigNum.from_str(
+ // (protocolParameters.slot + TX.invalid_hereafter).toString()
+ // )
+ // );
+
+ // const utxos = await getUtxos();
+
+ // const utxosCore = Loader.Cardano.TransactionUnspentOutputs.new();
+ // utxos.forEach((utxo) => utxosCore.add(utxo));
+
+ // txBuilder.add_inputs_from(
+ // utxosCore,
+ // Loader.Cardano.Address.from_bech32(account.paymentAddr),
+ // WEIGHTS
+ // );
+
+ // txBuilder.balance(Loader.Cardano.Address.from_bech32(account.paymentAddr));
+
+ // const transaction = await txBuilder.construct();
+
+ // return transaction;
+};
+
+export const withdrawalTx = async (account, delegation, protocolParameters) => {
+ return await Promise.resolve({});
+ // await Loader.load();
+
+ // const txBuilderConfig = Loader.Cardano.TransactionBuilderConfigBuilder.new()
+ // .coins_per_utxo_byte(
+ // Loader.Cardano.BigNum.from_str(protocolParameters.coinsPerUtxoWord)
+ // )
+ // .fee_algo(
+ // Loader.Cardano.LinearFee.new(
+ // Loader.Cardano.BigNum.from_str(protocolParameters.linearFee.minFeeA),
+ // Loader.Cardano.BigNum.from_str(protocolParameters.linearFee.minFeeB)
+ // )
+ // )
+ // .key_deposit(Loader.Cardano.BigNum.from_str(protocolParameters.keyDeposit))
+ // .pool_deposit(
+ // Loader.Cardano.BigNum.from_str(protocolParameters.poolDeposit)
+ // )
+ // .max_tx_size(protocolParameters.maxTxSize)
+ // .max_value_size(protocolParameters.maxValSize)
+ // .ex_unit_prices(Loader.Cardano.ExUnitPrices.from_float(0, 0))
+ // .collateral_percentage(protocolParameters.collateralPercentage)
+ // .max_collateral_inputs(protocolParameters.maxCollateralInputs)
+ // .build();
+
+ // const txBuilder = Loader.Cardano.TransactionBuilder.new(txBuilderConfig);
+
+ // txBuilder.add_withdrawal(
+ // Loader.Cardano.RewardAddress.from_address(
+ // Loader.Cardano.Address.from_bech32(account.rewardAddr)
+ // ),
+ // Loader.Cardano.BigNum.from_str(delegation.rewards)
+ // );
+
+ // txBuilder.set_ttl(
+ // Loader.Cardano.BigNum.from_str(
+ // (protocolParameters.slot + TX.invalid_hereafter).toString()
+ // )
+ // );
+
+ // const utxos = await getUtxos();
+
+ // const utxosCore = Loader.Cardano.TransactionUnspentOutputs.new();
+ // utxos.forEach((utxo) => utxosCore.add(utxo));
+
+ // txBuilder.add_inputs_from(
+ // utxosCore,
+ // Loader.Cardano.Address.from_bech32(account.paymentAddr),
+ // WEIGHTS
+ // );
+
+ // txBuilder.balance(Loader.Cardano.Address.from_bech32(account.paymentAddr));
+
+ // const transaction = await txBuilder.construct();
+
+ // return transaction;
+};
+
+export const undelegateTx = async (account, delegation, protocolParameters) => {
+ return await Promise.resolve({});
+ // await Loader.load();
+
+ // const txBuilderConfig = Loader.Cardano.TransactionBuilderConfigBuilder.new()
+ // .coins_per_utxo_byte(
+ // Loader.Cardano.BigNum.from_str(protocolParameters.coinsPerUtxoWord)
+ // )
+ // .fee_algo(
+ // Loader.Cardano.LinearFee.new(
+ // Loader.Cardano.BigNum.from_str(protocolParameters.linearFee.minFeeA),
+ // Loader.Cardano.BigNum.from_str(protocolParameters.linearFee.minFeeB)
+ // )
+ // )
+ // .key_deposit(Loader.Cardano.BigNum.from_str(protocolParameters.keyDeposit))
+ // .pool_deposit(
+ // Loader.Cardano.BigNum.from_str(protocolParameters.poolDeposit)
+ // )
+ // .max_tx_size(protocolParameters.maxTxSize)
+ // .max_value_size(protocolParameters.maxValSize)
+ // .ex_unit_prices(Loader.Cardano.ExUnitPrices.from_float(0, 0))
+ // .collateral_percentage(protocolParameters.collateralPercentage)
+ // .max_collateral_inputs(protocolParameters.maxCollateralInputs)
+ // .build();
+
+ // const txBuilder = Loader.Cardano.TransactionBuilder.new(txBuilderConfig);
+
+ // if (delegation.rewards > 0) {
+ // txBuilder.add_withdrawal(
+ // Loader.Cardano.RewardAddress.from_address(
+ // Loader.Cardano.Address.from_bech32(account.rewardAddr)
+ // ),
+ // Loader.Cardano.BigNum.from_str(delegation.rewards)
+ // );
+ // }
+
+ // txBuilder.add_certificate(
+ // Loader.Cardano.Certificate.new_stake_deregistration(
+ // Loader.Cardano.StakeDeregistration.new(
+ // Loader.Cardano.StakeCredential.from_keyhash(
+ // Loader.Cardano.Ed25519KeyHash.from_bytes(
+ // Buffer.from(account.stakeKeyHash, 'hex')
+ // )
+ // )
+ // )
+ // )
+ // );
+
+ // txBuilder.set_ttl(
+ // Loader.Cardano.BigNum.from_str(
+ // (protocolParameters.slot + TX.invalid_hereafter).toString()
+ // )
+ // );
+
+ // const utxos = await getUtxos();
+
+ // const utxosCore = Loader.Cardano.TransactionUnspentOutputs.new();
+ // utxos.forEach((utxo) => utxosCore.add(utxo));
+
+ // txBuilder.add_inputs_from(
+ // utxosCore,
+ // Loader.Cardano.Address.from_bech32(account.paymentAddr),
+ // WEIGHTS
+ // );
+
+ // txBuilder.balance(Loader.Cardano.Address.from_bech32(account.paymentAddr));
+
+ // const transaction = await txBuilder.construct();
+
+ // return transaction;
+};
diff --git a/packages/nami/src/api/loader.mock.ts b/packages/nami/src/api/loader.mock.ts
new file mode 100644
index 000000000..0dc90e992
--- /dev/null
+++ b/packages/nami/src/api/loader.mock.ts
@@ -0,0 +1,90 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/* eslint-disable functional/immutable-data */
+
+import { currentAccount } from '../mocks/account.mock';
+
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+export const Loader = (): any => void 0;
+Loader.load = async () => await Promise.resolve(true);
+Loader.Cardano = {
+ TransactionOutput: {
+ new: () => ({
+ add: () => void 0,
+ }),
+ },
+ TransactionOutputs: {
+ new: () => ({
+ add: () => void 0,
+ }),
+ },
+ Transaction: {
+ from_bytes: () => ({
+ body: () => ({
+ fee: () => ({ to_str: () => '214341' }),
+ outputs: () => ({
+ len: () => 1,
+ get: () => ({
+ datum: () => void 0,
+ amount: () => void 0,
+ address: () => ({
+ to_bytes: () => [
+ 0, 232, 252, 40, 72, 12, 115, 72, 109, 40, 128, 116, 197, 172,
+ 118, 96, 173, 6, 17, 174, 92, 229, 5, 222, 25, 67, 83, 70, 105,
+ 97, 234, 112, 175, 29, 231, 23, 149, 223, 82, 230, 45, 28, 15,
+ 44, 136, 23, 241, 59, 92, 212, 180, 14, 4, 202, 181, 173, 106,
+ ],
+ to_bech32: () => currentAccount.paymentKeyHashBech32,
+ }),
+ }),
+ }),
+ collateral: () => ({ len: () => 0 }),
+ certs: () => ({ len: () => 0 }),
+ withdrawals: () => ({ keys: () => ({ len: () => 0 }) }),
+ required_signers: () => ({ len: () => 0 }),
+ mint: () => ({ len: () => 0 }),
+ script_data_hash: () => ({ len: () => 0 }),
+ }),
+ witness_set: () => ({
+ native_scripts: () => ({ len: () => 0 }),
+ }),
+ auxiliary_data: () => ({
+ metadata: () => ({
+ get: () => void 0,
+ keys: () => ({ len: () => 1, get: () => ({ to_str: () => '674' }) }),
+ }),
+ }),
+ }),
+ },
+ AuxiliaryData: {
+ new: () => ({
+ set_metadata: () => void 0,
+ metadata: () => void 0,
+ }),
+ },
+ GeneralTransactionMetadata: {
+ new: () => ({
+ insert: () => void 0,
+ len: () => void 0,
+ }),
+ },
+ Address: {
+ from_bech32: () => void 0,
+ from_bytes: () => void 0,
+ },
+ TransactionUnspentOutput: {
+ from_bytes: () => void 0,
+ },
+ BigNum: {
+ from_str: () => void 0,
+ },
+ Value: {
+ zero: () => void 0,
+ new: () => ({
+ checked_add: () => void 0,
+ }),
+ },
+ encode_json_str_to_metadatum: () => void 0,
+ decode_metadatum_to_json_str: () => '{"msg":["Swap request"]}',
+};
+
+Loader.Message = {};
diff --git a/packages/nami/src/api/loader.ts b/packages/nami/src/api/loader.ts
new file mode 100644
index 000000000..60ebe280f
--- /dev/null
+++ b/packages/nami/src/api/loader.ts
@@ -0,0 +1,40 @@
+// import * as wasm from '../wasm/cardano_multiplatform_lib/cardano_multiplatform_lib.generated';
+// import * as wasm2 from '../wasm/cardano_message_signing/cardano_message_signing.generated';
+
+const wasm = {};
+const wasm2 = {};
+
+/**
+ * Loads the WASM modules
+ */
+
+class LoaderClass {
+ async load() {
+ if (this._wasm && this._wasm2) return;
+ try {
+ await wasm.instantiate();
+ await wasm2.instantiate();
+ } catch {
+ // Only happens when running with Jest (Node.js)
+ }
+ /**
+ * @private
+ */
+ this._wasm = wasm;
+ /**
+ * @private
+ */
+ this._wasm2 = wasm2;
+ }
+
+ get Cardano() {
+ return this._wasm;
+ }
+
+ get Message() {
+ return this._wasm2;
+ }
+}
+
+// export const Loader = new LoaderClass();
+export const Loader = () => void 0;
diff --git a/packages/nami/src/api/util.mock.ts b/packages/nami/src/api/util.mock.ts
new file mode 100644
index 000000000..6254d6876
--- /dev/null
+++ b/packages/nami/src/api/util.mock.ts
@@ -0,0 +1,17 @@
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+/* eslint-disable @typescript-eslint/no-unsafe-argument */
+import { fn } from '@storybook/test';
+
+import * as actualApi from './util';
+
+export * from './util';
+
+export const minAdaRequired = fn(actualApi.minAdaRequired).mockName(
+ 'minAdaRequired',
+);
+
+export const sumUtxos = fn(actualApi.sumUtxos).mockName('sumUtxos');
+
+export const valueToAssets = fn(actualApi.valueToAssets).mockName(
+ 'valueToAssets',
+);
diff --git a/packages/nami/src/api/util.ts b/packages/nami/src/api/util.ts
new file mode 100644
index 000000000..38826f30e
--- /dev/null
+++ b/packages/nami/src/api/util.ts
@@ -0,0 +1,1530 @@
+/* eslint-disable functional/no-throw-statements */
+/* eslint-disable @typescript-eslint/restrict-plus-operands */
+/* eslint-disable max-params */
+/* eslint-disable unicorn/no-null */
+/* eslint-disable @typescript-eslint/naming-convention */
+import {
+ AddressType,
+ CertificateType,
+ DatumType,
+ HARDENED,
+ PoolKeyType,
+ PoolOwnerType,
+ PoolRewardAccountType,
+ RelayType,
+ StakeCredentialParamsType,
+ TransactionSigningMode,
+ TxAuxiliaryDataType,
+ TxOutputDestinationType,
+ TxOutputFormat,
+ TxRequiredSignerType,
+} from '@cardano-foundation/ledgerjs-hw-app-cardano';
+import { Serialization } from '@cardano-sdk/core';
+import { minAdaRequired as minAdaRequiredSDK } from '@cardano-sdk/tx-construction';
+import AssetFingerprint from '@emurgo/cip14-js';
+import { PROTO } from '@trezor/connect-web';
+import { crc8 } from 'crc';
+
+import { CurrencyCode } from '../adapters/currency';
+import { NETWORK_ID } from '../config/config';
+import provider from '../config/provider';
+
+import { getNetwork } from './extension';
+import { Loader } from './loader';
+
+const {
+ CardanoAddressType,
+ CardanoCertificateType,
+ CardanoPoolRelayType,
+ CardanoTxSigningMode,
+} = PROTO;
+
+export const delay = async delayInMs =>
+ new Promise(resolve => {
+ setTimeout(() => {
+ resolve(null);
+ }, delayInMs);
+ });
+
+export const blockfrostRequest = async (
+ endpoint: string,
+ headers?: any,
+ body?: any,
+ signal?: any,
+) => {
+ const network = await getNetwork();
+ let result;
+
+ while (!result || result.status_code === 500) {
+ if (result) {
+ await delay(100);
+ }
+ const rawResult = await fetch(provider.api.base(network.node) + endpoint, {
+ headers: {
+ ...provider.api.key(network.name || network.id),
+ ...provider.api.header,
+ ...headers,
+ 'Cache-Control': 'no-cache',
+ },
+ method: body ? 'POST' : 'GET',
+ body,
+ signal,
+ });
+ result = await rawResult.json();
+ }
+
+ return result;
+};
+
+/**
+ *
+ * @param {string} currency - eg. usd
+ * @returns
+ */
+export const currencyToSymbol = (currency: CurrencyCode) => {
+ const currencyMap = {
+ [CurrencyCode.USD]: '$',
+ [CurrencyCode.EUR]: '€',
+ };
+ return currencyMap[currency];
+};
+
+// /**
+// *
+// * @param {string} hex
+// * @returns
+// */
+export const hexToAscii = hex => Buffer.from(hex, 'hex').toString();
+
+export const networkNameToId = name => {
+ const names = {
+ [NETWORK_ID.mainnet]: 1,
+ [NETWORK_ID.testnet]: 0,
+ [NETWORK_ID.preview]: 0,
+ [NETWORK_ID.preprod]: 0,
+ };
+ return names[name];
+};
+
+/**
+ *
+ * @param {MultiAsset} multiAsset
+ * @returns
+ */
+export const multiAssetCount = async multiAsset => {
+ await Loader.load();
+ if (!multiAsset) return 0;
+ let count = 0;
+ const policies = multiAsset.keys();
+ for (let j = 0; j < multiAsset.len(); j++) {
+ const policy = policies.get(j);
+ const policyAssets = multiAsset.get(policy);
+ const assetNames = policyAssets.keys();
+ for (let k = 0; k < assetNames.len(); k++) {
+ count++;
+ }
+ }
+ return count;
+};
+
+/**
+ * @typedef {Object} Amount - Unit/Quantity pair
+ * @property {string} unit - Token Type
+ * @property {int} quantity - Token Amount
+ */
+
+/**
+ * @typedef {Amount[]} AmountList - List of unit/quantity pair
+ */
+
+/**
+ * @typedef {Output[]} OutputList - List of Output
+ */
+
+/**
+ * @typedef {Object} Output - Outputs Format
+ * @property {string} address - Address Output
+ * @property {AmountList} amount - Amount (lovelace & Native Token)
+ */
+
+/**
+ * Compile all required output to a flat amount list
+ * @param {OutputList} outputList - The set of outputs requested for payment.
+ * @return {AmountList} - The compiled set of amounts requested for payment.
+ */
+export const compileOutputs = outputList => {
+ const compiledAmountList = [];
+
+ for (const output of outputList)
+ addAmounts(output.amount, compiledAmountList);
+
+ return compiledAmountList;
+};
+
+/**
+ * Add up an AmountList values to an other AmountList
+ * @param {AmountList} amountList - Set of amounts to be added.
+ * @param {AmountList} compiledAmountList - The compiled set of amounts.
+ */
+const addAmounts = (amountList, compiledAmountList) => {
+ for (const amount of amountList) {
+ const entry = compiledAmountList.find(
+ compiledAmount => compiledAmount.unit === amount.unit,
+ );
+
+ // 'Add to' or 'insert' in compiledOutputList
+ const am = JSON.parse(JSON.stringify(amount)); // Deep Copy
+ entry
+ ? (entry.quantity = (
+ BigInt(entry.quantity) + BigInt(amount.quantity)
+ ).toString())
+ : compiledAmountList.push(am);
+ }
+};
+
+/** Cardano metadata properties can hold a max of 64 bytes. The alternative is to use an array of strings. */
+export const convertMetadataPropToString = src => {
+ if (typeof src === 'string') return src;
+ else if (Array.isArray(src)) return src.join('');
+ return null;
+};
+
+export const linkToSrc = (link, base64 = false) => {
+ const base64regex =
+ /^([\d+/A-Za-z]{4})*(([\d+/A-Za-z]{2}==)|([\d+/A-Za-z]{3}=))?$/;
+ if (link.startsWith('https://')) return link;
+ else if (link.startsWith('ipfs://'))
+ return (
+ provider.api.ipfs +
+ '/' +
+ link.split('ipfs://')[1].split('ipfs/').slice(-1)[0]
+ );
+ else if (
+ (link.startsWith('Qm') && link.length === 46) ||
+ (link.startsWith('baf') && link.length === 59)
+ ) {
+ return provider.api.ipfs + '/' + link;
+ } else if (base64 && base64regex.test(link))
+ return 'data:image/png;base64,' + link;
+ else if (link.startsWith('data:image')) return link;
+ return null;
+};
+
+/**
+ *
+ * @param {JSON} output
+ * @param {BaseAddress} address
+ * @returns
+ */
+export const utxoFromJson = async (output, address) => {
+ await Loader.load();
+ return Loader.Cardano.TransactionUnspentOutput.new(
+ Loader.Cardano.TransactionInput.new(
+ Loader.Cardano.TransactionHash.from_bytes(
+ Buffer.from(output.tx_hash || output.txHash, 'hex'),
+ ),
+ Loader.Cardano.BigNum.from_str(
+ (output.output_index ?? output.txId).toString(),
+ ),
+ ),
+ Loader.Cardano.TransactionOutput.new(
+ Loader.Cardano.Address.from_bytes(Buffer.from(address, 'hex')),
+ assetsToValue(output.amount),
+ ),
+ );
+};
+
+/**
+ *
+ * @param {TransactionUnspentOutput[]} utxos
+ * @returns
+ */
+export const sumUtxos = utxos => {
+ let value = Loader.Cardano.Value.new(Loader.Cardano.BigNum.from_str('0'));
+ for (const utxo of utxos) value = value.checked_add(utxo.output().amount());
+ return value;
+};
+
+/**
+ *
+ *
+ *
+ * @param {TransactionUnspentOutput} utxo
+ * @returns
+ */
+export const utxoToJson = async utxo => {
+ await Loader.load();
+ const assets = await valueToAssets(utxo.output().amount());
+ return {
+ txHash: Buffer.from(
+ utxo.input().transaction_id().to_bytes(),
+ 'hex',
+ ).toString('hex'),
+ txId: utxo.input().index(),
+ amount: assets,
+ };
+};
+
+export const assetsToValue = assets => {
+ const tokenMap = new Map();
+ const lovelace = assets.find(asset => asset.unit === 'lovelace');
+ const policies = [
+ ...new Set(
+ assets
+ .filter(asset => asset.unit !== 'lovelace')
+ .map(asset => asset.unit.slice(0, 56)),
+ ),
+ ];
+ for (const policy of policies) {
+ const policyAssets = assets.filter(
+ asset => asset.unit.slice(0, 56) === policy,
+ );
+ for (const asset of policyAssets) {
+ if (tokenMap.has(asset.unit)) {
+ const quantity = tokenMap.get(asset.unit);
+ tokenMap.set(asset.unit, BigInt(asset.quantity) + quantity);
+ } else {
+ tokenMap.set(asset.unit, BigInt(asset.quantity));
+ }
+ }
+ }
+ const value = new Serialization.Value(
+ BigInt(lovelace ? lovelace.quantity : '0'),
+ );
+ if (assets.length > 1 || !lovelace) value.setMultiasset(tokenMap);
+ return value;
+};
+
+// /**
+// *
+// * @param {Value} value
+// */
+export const valueToAssets = async value => {
+ await Loader.load();
+ const assets = [];
+ assets.push({ unit: 'lovelace', quantity: value.coin().to_str() });
+ if (value.multiasset()) {
+ const multiAssets = value.multiasset().keys();
+ for (let j = 0; j < multiAssets.len(); j++) {
+ const policy = multiAssets.get(j);
+ const policyAssets = value.multiasset().get(policy);
+ const assetNames = policyAssets.keys();
+ for (let k = 0; k < assetNames.len(); k++) {
+ const policyAsset = assetNames.get(k);
+ const quantity = policyAssets.get(policyAsset);
+ const asset =
+ Buffer.from(policy.to_bytes(), 'hex').toString('hex') +
+ Buffer.from(policyAsset.name(), 'hex').toString('hex');
+ const _policy = asset.slice(0, 56);
+ const _name = asset.slice(56);
+ const fingerprint = AssetFingerprint.fromParts(
+ Buffer.from(_policy, 'hex'),
+ Buffer.from(_name, 'hex'),
+ ).fingerprint();
+ assets.push({
+ unit: asset,
+ quantity: quantity.to_str(),
+ policy: _policy,
+ name: hexToAscii(_name),
+ fingerprint,
+ });
+ }
+ }
+ }
+ // if (value.coin().to_str() == '0') return [];
+ return assets;
+};
+
+export const minAdaRequired = (output, coinsPerUtxoWord) => {
+ return minAdaRequiredSDK(output.toCore(), coinsPerUtxoWord).toString();
+};
+
+const outputsToTrezor = (outputs, address, index) => {
+ const trezorOutputs = [];
+ for (let i = 0; i < outputs.len(); i++) {
+ const output = outputs.get(i);
+ const multiAsset = output.amount().multiasset();
+ let tokenBundle = null;
+ if (multiAsset) {
+ tokenBundle = [];
+ for (let j = 0; j < multiAsset.keys().len(); j++) {
+ const policy = multiAsset.keys().get(j);
+ const assets = multiAsset.get(policy);
+ const tokens = [];
+ for (let k = 0; k < assets.keys().len(); k++) {
+ const assetName = assets.keys().get(k);
+ const amount = assets.get(assetName).to_str();
+ tokens.push({
+ assetNameBytes: Buffer.from(assetName.name()).toString('hex'),
+ amount,
+ });
+ }
+ // sort canonical
+ tokens.sort((a, b) => {
+ if (a.assetNameBytes.length == b.assetNameBytes.length) {
+ return a.assetNameBytes > b.assetNameBytes ? 1 : -1;
+ } else if (a.assetNameBytes.length > b.assetNameBytes.length)
+ return 1;
+ else return -1;
+ });
+ tokenBundle.push({
+ policyId: Buffer.from(policy.to_bytes()).toString('hex'),
+ tokenAmounts: tokens,
+ });
+ }
+ }
+ const outputAddress = Buffer.from(output.address().to_bytes()).toString(
+ 'hex',
+ );
+
+ const outputAddressHuman = (() => {
+ try {
+ return Loader.Cardano.BaseAddress.from_address(output.address())
+ .to_address()
+ .to_bech32();
+ } catch {}
+ try {
+ return Loader.Cardano.EnterpriseAddress.from_address(output.address())
+ .to_address()
+ .to_bech32();
+ } catch {}
+ try {
+ return Loader.Cardano.PointerAddress.from_address(output.address())
+ .to_address()
+ .to_bech32();
+ } catch {}
+ return Loader.Cardano.ByronAddress.from_address(
+ output.address(),
+ ).to_base58();
+ })();
+
+ const destination =
+ outputAddress == address
+ ? {
+ addressParameters: {
+ addressType: CardanoAddressType.BASE,
+ path: `m/1852'/1815'/${index}'/0/0`,
+ stakingPath: `m/1852'/1815'/${index}'/2/0`,
+ },
+ }
+ : {
+ address: outputAddressHuman,
+ };
+ const datumHash =
+ output.datum() && output.datum().kind() === 0
+ ? Buffer.from(output.datum().as_data_hash().to_bytes()).toString('hex')
+ : null;
+ const inlineDatum =
+ output.datum() && output.datum().kind() === 1
+ ? Buffer.from(output.datum().as_data().get().to_bytes()).toString('hex')
+ : null;
+ const referenceScript = output.script_ref()
+ ? Buffer.from(output.script_ref().get().to_bytes()).toString('hex')
+ : null;
+ const outputRes = {
+ amount: output.amount().coin().to_str(),
+ tokenBundle,
+ datumHash,
+ format: output.format(),
+ inlineDatum,
+ referenceScript,
+ ...destination,
+ };
+ if (!tokenBundle) delete outputRes.tokenBundle;
+ if (!datumHash) delete outputRes.datumHash;
+ if (!inlineDatum) delete outputRes.inlineDatum;
+ if (!referenceScript) delete outputRes.referenceScript;
+ trezorOutputs.push(outputRes);
+ }
+ return trezorOutputs;
+};
+
+/**
+ *
+ * @param {Transaction} tx
+ */
+export const txToTrezor = async (tx, network, keys, address, index) => {
+ await Loader.load();
+
+ let signingMode = CardanoTxSigningMode.ORDINARY_TRANSACTION;
+ const inputs = tx.body().inputs();
+ const trezorInputs = [];
+ for (let i = 0; i < inputs.len(); i++) {
+ const input = inputs.get(i);
+ trezorInputs.push({
+ prev_hash: Buffer.from(input.transaction_id().to_bytes()).toString('hex'),
+ prev_index: Number.parseInt(input.index().to_str()),
+ path: keys.payment.path, // needed to include payment key witness if available
+ });
+ }
+
+ const outputs = tx.body().outputs();
+ const trezorOutputs = outputsToTrezor(outputs, address, index);
+
+ let trezorCertificates = null;
+ const certificates = tx.body().certs();
+ if (certificates) {
+ trezorCertificates = [];
+ for (let i = 0; i < certificates.len(); i++) {
+ const cert = certificates.get(i);
+ const certificate = {};
+ if (cert.kind() === 0) {
+ const credential = cert.as_stake_registration().stake_credential();
+ certificate.type = CardanoCertificateType.STAKE_REGISTRATION;
+ if (credential.kind() === 0) {
+ certificate.path = keys.stake.path;
+ } else {
+ const scriptHash = Buffer.from(
+ credential.to_scripthash().to_bytes(),
+ ).toString('hex');
+ certificate.scriptHash = scriptHash;
+ }
+ } else if (cert.kind() === 1) {
+ const credential = cert.as_stake_deregistration().stake_credential();
+ certificate.type = CardanoCertificateType.STAKE_DEREGISTRATION;
+ if (credential.kind() === 0) {
+ certificate.path = keys.stake.path;
+ } else {
+ const scriptHash = Buffer.from(
+ credential.to_scripthash().to_bytes(),
+ ).toString('hex');
+ certificate.scriptHash = scriptHash;
+ }
+ } else if (cert.kind() === 2) {
+ const delegation = cert.as_stake_delegation();
+ const credential = delegation.stake_credential();
+ const poolKeyHashHex = Buffer.from(
+ delegation.pool_keyhash().to_bytes(),
+ ).toString('hex');
+ certificate.type = CardanoCertificateType.STAKE_DELEGATION;
+ if (credential.kind() === 0) {
+ certificate.path = keys.stake.path;
+ } else {
+ const scriptHash = Buffer.from(
+ credential.to_scripthash().to_bytes(),
+ ).toString('hex');
+ certificate.scriptHash = scriptHash;
+ }
+ certificate.pool = poolKeyHashHex;
+ } else if (cert.kind() === 3) {
+ const params = cert.as_pool_registration().pool_params();
+ certificate.type = CardanoCertificateType.STAKE_POOL_REGISTRATION;
+ const owners = params.pool_owners();
+ const poolOwners = [];
+ for (let i = 0; i < owners.len(); i++) {
+ const keyHash = Buffer.from(owners.get(i).to_bytes()).toString('hex');
+ if (keyHash == keys.stake.hash) {
+ signingMode = CardanoTxSigningMode.POOL_REGISTRATION_AS_OWNER;
+ poolOwners.push({
+ stakingKeyPath: keys.stake.path,
+ });
+ } else {
+ poolOwners.push({
+ stakingKeyHash: keyHash,
+ });
+ }
+ }
+ const relays = params.relays();
+ const trezorRelays = [];
+ for (let i = 0; i < relays.len(); i++) {
+ const relay = relays.get(i);
+ if (relay.kind() === 0) {
+ const singleHostAddr = relay.as_single_host_addr();
+ const type = CardanoPoolRelayType.SINGLE_HOST_IP;
+ const port = singleHostAddr.port();
+ const ipv4Address = singleHostAddr.ipv4()
+ ? bytesToIp(singleHostAddr.ipv4().ip())
+ : null;
+ const ipv6Address = singleHostAddr.ipv6()
+ ? bytesToIp(singleHostAddr.ipv6().ip())
+ : null;
+ trezorRelays.push({ type, port, ipv4Address, ipv6Address });
+ } else if (relay.kind() === 1) {
+ const type = CardanoPoolRelayType.SINGLE_HOST_NAME;
+ const singleHostName = relay.as_single_host_name();
+ const port = singleHostName.port();
+ const hostName = singleHostName.dns_name().record();
+ trezorRelays.push({
+ type,
+ port,
+ hostName,
+ });
+ } else if (relay.kind() === 2) {
+ const type = CardanoPoolRelayType.MULTIPLE_HOST_NAME;
+ const multiHostName = relay.as_multi_host_name();
+ const hostName = multiHostName.dns_name();
+ trezorRelays.push({
+ type,
+ hostName,
+ });
+ }
+ }
+ const cost = params.cost().to_str();
+ const margin = params.margin();
+ const pledge = params.pledge().to_str();
+ const poolId = Buffer.from(params.operator().to_bytes()).toString(
+ 'hex',
+ );
+ const metadata = params.pool_metadata()
+ ? {
+ url: params.pool_metadata().url().url(),
+ hash: Buffer.from(
+ params.pool_metadata().pool_metadata_hash().to_bytes(),
+ ).toString('hex'),
+ }
+ : null;
+ const rewardAccount = params.reward_account().to_address().to_bech32();
+ const vrfKeyHash = Buffer.from(
+ params.vrf_keyhash().to_bytes(),
+ ).toString('hex');
+
+ certificate.poolParameters = {
+ poolId,
+ vrfKeyHash,
+ pledge,
+ cost,
+ margin: {
+ numerator: margin.numerator().to_str(),
+ denominator: margin.denominator().to_str(),
+ },
+ rewardAccount,
+ owners: poolOwners,
+ relays: trezorRelays,
+ metadata,
+ };
+ }
+ trezorCertificates.push(certificate);
+ }
+ }
+ const fee = tx.body().fee().to_str();
+ const ttl = tx.body().ttl();
+ const withdrawals = tx.body().withdrawals();
+ let trezorWithdrawals = null;
+ if (withdrawals) {
+ trezorWithdrawals = [];
+ for (let i = 0; i < withdrawals.keys().len(); i++) {
+ const withdrawal = {};
+ const rewardAddress = withdrawals.keys().get(i);
+ if (rewardAddress.payment_cred().kind() === 0) {
+ withdrawal.path = keys.stake.path;
+ } else {
+ withdrawal.scriptHash = Buffer.from(
+ rewardAddress.payment_cred().to_scripthash().to_bytes(),
+ ).toString('hex');
+ }
+ withdrawal.amount = withdrawals.get(rewardAddress).to_str();
+ trezorWithdrawals.push(withdrawal);
+ }
+ }
+ const auxiliaryData = tx.body().auxiliary_data_hash()
+ ? {
+ hash: Buffer.from(tx.body().auxiliary_data_hash().to_bytes()).toString(
+ 'hex',
+ ),
+ }
+ : null;
+ const validityIntervalStart = tx.body().validity_start_interval()
+ ? tx.body().validity_start_interval().to_str()
+ : null;
+
+ const mint = tx.body().mint();
+ let additionalWitnessRequests = null;
+ let mintBundle = null;
+ if (mint) {
+ mintBundle = [];
+ for (let j = 0; j < mint.keys().len(); j++) {
+ const policy = mint.keys().get(j);
+ const assets = mint.get(policy);
+ const tokens = [];
+ for (let k = 0; k < assets.keys().len(); k++) {
+ const assetName = assets.keys().get(k);
+ const amount = assets.get(assetName);
+ tokens.push({
+ assetNameBytes: Buffer.from(assetName.name()).toString('hex'),
+ mintAmount: amount.is_positive()
+ ? amount.as_positive().to_str()
+ : '-' + amount.as_negative().to_str(),
+ });
+ }
+ // sort canonical
+ tokens.sort((a, b) => {
+ if (a.assetNameBytes.length == b.assetNameBytes.length) {
+ return a.assetNameBytes > b.assetNameBytes ? 1 : -1;
+ } else if (a.assetNameBytes.length > b.assetNameBytes.length) return 1;
+ else return -1;
+ });
+ mintBundle.push({
+ policyId: Buffer.from(policy.to_bytes()).toString('hex'),
+ tokenAmounts: tokens,
+ });
+ }
+ additionalWitnessRequests = [];
+ if (keys.payment.path) additionalWitnessRequests.push(keys.payment.path);
+ if (keys.stake.path) additionalWitnessRequests.push(keys.stake.path);
+ }
+
+ // Plutus
+ const scriptDataHash = tx.body().script_data_hash()
+ ? Buffer.from(tx.body().script_data_hash().to_bytes()).toString('hex')
+ : null;
+
+ let collateralInputs = null;
+ if (tx.body().collateral()) {
+ collateralInputs = [];
+ const coll = tx.body().collateral();
+ for (let i = 0; i < coll.len(); i++) {
+ const input = coll.get(i);
+ if (keys.payment.path) {
+ collateralInputs.push({
+ prev_hash: Buffer.from(input.transaction_id().to_bytes()).toString(
+ 'hex',
+ ),
+ prev_index: Number.parseInt(input.index().to_str()),
+ path: keys.payment.path, // needed to include payment key witness if available
+ });
+ } else {
+ collateralInputs.push({
+ prev_hash: Buffer.from(input.transaction_id().to_bytes()).toString(
+ 'hex',
+ ),
+ prev_index: Number.parseInt(input.index().to_str()),
+ });
+ }
+ signingMode = CardanoTxSigningMode.PLUTUS_TRANSACTION;
+ }
+ }
+
+ let requiredSigners = null;
+ if (tx.body().required_signers()) {
+ requiredSigners = [];
+ const r = tx.body().required_signers();
+ for (let i = 0; i < r.len(); i++) {
+ const signer = Buffer.from(r.get(i).to_bytes()).toString('hex');
+ if (signer === keys.payment.hash) {
+ requiredSigners.push({
+ keyPath: keys.payment.path,
+ });
+ } else if (signer === keys.stake.hash) {
+ requiredSigners.push({
+ keyPath: keys.stake.path,
+ });
+ } else {
+ requiredSigners.push({
+ keyHash: signer,
+ });
+ }
+ }
+ signingMode = CardanoTxSigningMode.PLUTUS_TRANSACTION;
+ }
+
+ let referenceInputs = null;
+ if (tx.body().reference_inputs()) {
+ referenceInputs = [];
+ const ri = tx.body().reference_inputs();
+ for (let i = 0; i < ri.len(); i++) {
+ referenceInputs.push({
+ prev_hash: ri.get(i).transaction_id().to_hex(),
+ prev_index: Number.parseInt(ri.get(i).index().to_str()),
+ });
+ }
+ signingMode = CardanoTxSigningMode.PLUTUS_TRANSACTION;
+ }
+
+ const totalCollateral = tx.body().total_collateral()
+ ? tx.body().total_collateral().to_str()
+ : null;
+
+ const collateralReturn = (() => {
+ if (tx.body().collateral_return()) {
+ const outputs = Loader.Cardano.TransactionOutputs.new();
+ outputs.add(tx.body().collateral_return());
+ const [out] = outputsToTrezor(outputs, address, index);
+ return out;
+ }
+ return null;
+ })();
+
+ const includeNetworkId = !!tx.body().network_id();
+
+ const trezorTx = {
+ signingMode,
+ inputs: trezorInputs,
+ outputs: trezorOutputs,
+ fee,
+ ttl: ttl ? ttl.to_str() : null,
+ validityIntervalStart,
+ certificates: trezorCertificates,
+ withdrawals: trezorWithdrawals,
+ auxiliaryData,
+ mint: mintBundle,
+ scriptDataHash,
+ collateralInputs,
+ requiredSigners,
+ protocolMagic: network === 1 ? 764_824_073 : 42,
+ networkId: network,
+ includeNetworkId,
+ additionalWitnessRequests,
+ collateralReturn,
+ totalCollateral,
+ referenceInputs,
+ };
+ for (const key of Object.keys(trezorTx))
+ !trezorTx[key] && trezorTx[key] != 0 && delete trezorTx[key];
+ return trezorTx;
+};
+
+const outputsToLedger = (outputs, address, index) => {
+ const ledgerOutputs = [];
+ for (let i = 0; i < outputs.len(); i++) {
+ const output = outputs.get(i);
+ const multiAsset = output.amount().multiasset();
+ let tokenBundle = null;
+ if (multiAsset) {
+ tokenBundle = [];
+ for (let j = 0; j < multiAsset.keys().len(); j++) {
+ const policy = multiAsset.keys().get(j);
+ const assets = multiAsset.get(policy);
+ const tokens = [];
+ for (let k = 0; k < assets.keys().len(); k++) {
+ const assetName = assets.keys().get(k);
+ const amount = assets.get(assetName).to_str();
+ tokens.push({
+ assetNameHex: Buffer.from(assetName.name()).toString('hex'),
+ amount,
+ });
+ }
+ // sort canonical
+ tokens.sort((a, b) => {
+ if (a.assetNameHex.length == b.assetNameHex.length) {
+ return a.assetNameHex > b.assetNameHex ? 1 : -1;
+ } else if (a.assetNameHex.length > b.assetNameHex.length) return 1;
+ else return -1;
+ });
+ tokenBundle.push({
+ policyIdHex: Buffer.from(policy.to_bytes()).toString('hex'),
+ tokens,
+ });
+ }
+ }
+
+ const outputAddress = Buffer.from(output.address().to_bytes()).toString(
+ 'hex',
+ );
+ const destination =
+ outputAddress == address
+ ? {
+ type: TxOutputDestinationType.DEVICE_OWNED,
+ params: {
+ type: AddressType.BASE_PAYMENT_KEY_STAKE_KEY,
+ params: {
+ spendingPath: [
+ HARDENED + 1852,
+ HARDENED + 1815,
+ HARDENED + index,
+ 0,
+ 0,
+ ],
+ stakingPath: [
+ HARDENED + 1852,
+ HARDENED + 1815,
+ HARDENED + index,
+ 2,
+ 0,
+ ],
+ },
+ },
+ }
+ : {
+ type: TxOutputDestinationType.THIRD_PARTY,
+ params: {
+ addressHex: outputAddress,
+ },
+ };
+ const datum = output.datum();
+ const refScript = output.script_ref();
+ const isBabbage = output.format();
+ const outputRes = isBabbage
+ ? {
+ format: TxOutputFormat.MAP_BABBAGE,
+ amount: output.amount().coin().to_str(),
+ tokenBundle,
+ destination,
+ datum: datum
+ ? datum.kind() === 0
+ ? {
+ type: DatumType.HASH,
+ datumHashHex: Buffer.from(
+ datum.as_data_hash().to_bytes(),
+ ).toString('hex'),
+ }
+ : {
+ type: DatumType.INLINE,
+ datumHex: Buffer.from(
+ datum.as_data().get().to_bytes(),
+ ).toString('hex'),
+ }
+ : null,
+ referenceScriptHex: refScript
+ ? Buffer.from(refScript.get().to_bytes()).toString('hex')
+ : null,
+ }
+ : {
+ format: TxOutputFormat.ARRAY_LEGACY,
+ amount: output.amount().coin().to_str(),
+ tokenBundle,
+ destination,
+ datumHashHex:
+ datum && datum.kind() === 0
+ ? Buffer.from(datum.as_data_hash().to_bytes()).toString('hex')
+ : null,
+ };
+ for (const key of Object.keys(outputRes)) {
+ if (!outputRes[key]) delete outputRes[key];
+ }
+ ledgerOutputs.push(outputRes);
+ }
+ return ledgerOutputs;
+};
+
+/**
+ *
+ * @param {Transaction} tx
+ */
+export const txToLedger = async (tx, network, keys, address, index) => {
+ await Loader.load();
+
+ let signingMode = TransactionSigningMode.ORDINARY_TRANSACTION;
+ const inputs = tx.body().inputs();
+ const ledgerInputs = [];
+ for (let i = 0; i < inputs.len(); i++) {
+ const input = inputs.get(i);
+ ledgerInputs.push({
+ txHashHex: Buffer.from(input.transaction_id().to_bytes()).toString('hex'),
+ outputIndex: Number.parseInt(input.index().to_str()),
+ path: keys.payment.path, // needed to include payment key witness if available
+ });
+ }
+
+ const ledgerOutputs = outputsToLedger(tx.body().outputs(), address, index);
+
+ let ledgerCertificates = null;
+ const certificates = tx.body().certs();
+ if (certificates) {
+ ledgerCertificates = [];
+ for (let i = 0; i < certificates.len(); i++) {
+ const cert = certificates.get(i);
+ const certificate = {};
+ if (cert.kind() === 0) {
+ const credential = cert.as_stake_registration().stake_credential();
+ certificate.type = CertificateType.STAKE_REGISTRATION;
+ if (credential.kind() === 0) {
+ certificate.params = {
+ stakeCredential: {
+ type: StakeCredentialParamsType.KEY_PATH,
+ keyPath: keys.stake.path,
+ },
+ };
+ } else {
+ const scriptHash = Buffer.from(
+ credential.to_scripthash().to_bytes(),
+ ).toString('hex');
+ certificate.params = {
+ stakeCredential: {
+ type: StakeCredentialParamsType.SCRIPT_HASH,
+ scriptHash,
+ },
+ };
+ }
+ } else if (cert.kind() === 1) {
+ const credential = cert.as_stake_deregistration().stake_credential();
+ certificate.type = CertificateType.STAKE_DEREGISTRATION;
+ if (credential.kind() === 0) {
+ certificate.params = {
+ stakeCredential: {
+ type: StakeCredentialParamsType.KEY_PATH,
+ keyPath: keys.stake.path,
+ },
+ };
+ } else {
+ const scriptHash = Buffer.from(
+ credential.to_scripthash().to_bytes(),
+ ).toString('hex');
+ certificate.params = {
+ stakeCredential: {
+ type: StakeCredentialParamsType.SCRIPT_HASH,
+ scriptHash,
+ },
+ };
+ }
+ } else if (cert.kind() === 2) {
+ const delegation = cert.as_stake_delegation();
+ const credential = delegation.stake_credential();
+ const poolKeyHashHex = Buffer.from(
+ delegation.pool_keyhash().to_bytes(),
+ ).toString('hex');
+ certificate.type = CertificateType.STAKE_DELEGATION;
+ if (credential.kind() === 0) {
+ certificate.params = {
+ stakeCredential: {
+ type: StakeCredentialParamsType.KEY_PATH,
+ keyPath: keys.stake.path,
+ },
+ };
+ } else {
+ const scriptHash = Buffer.from(
+ credential.to_scripthash().to_bytes(),
+ ).toString('hex');
+ certificate.params = {
+ stakeCredential: {
+ type: StakeCredentialParamsType.SCRIPT_HASH,
+ scriptHash,
+ },
+ };
+ }
+ certificate.params.poolKeyHashHex = poolKeyHashHex;
+ } else if (cert.kind() === 3) {
+ const params = cert.as_pool_registration().pool_params();
+ certificate.type = CertificateType.STAKE_POOL_REGISTRATION;
+ const owners = params.pool_owners();
+ const poolOwners = [];
+ for (let i = 0; i < owners.len(); i++) {
+ const keyHash = Buffer.from(owners.get(i).to_bytes()).toString('hex');
+ if (keyHash == keys.stake.hash) {
+ signingMode = TransactionSigningMode.POOL_REGISTRATION_AS_OWNER;
+ poolOwners.push({
+ type: PoolOwnerType.DEVICE_OWNED,
+ stakingPath: keys.stake.path,
+ });
+ } else {
+ poolOwners.push({
+ type: PoolOwnerType.THIRD_PARTY,
+ stakingKeyHashHex: keyHash,
+ });
+ }
+ }
+ const relays = params.relays();
+ const ledgerRelays = [];
+ for (let i = 0; i < relays.len(); i++) {
+ const relay = relays.get(i);
+ if (relay.kind() === 0) {
+ const singleHostAddr = relay.as_single_host_addr();
+ const type = RelayType.SINGLE_HOST_IP_ADDR;
+ const portNumber = singleHostAddr.port();
+ const ipv4 = singleHostAddr.ipv4()
+ ? bytesToIp(singleHostAddr.ipv4().ip())
+ : null;
+ const ipv6 = singleHostAddr.ipv6()
+ ? bytesToIp(singleHostAddr.ipv6().ip())
+ : null;
+ ledgerRelays.push({ type, params: { portNumber, ipv4, ipv6 } });
+ } else if (relay.kind() === 1) {
+ const type = RelayType.SINGLE_HOST_HOSTNAME;
+ const singleHostName = relay.as_single_host_name();
+ const portNumber = singleHostName.port();
+ const dnsName = singleHostName.dns_name().record();
+ ledgerRelays.push({
+ type,
+ params: { portNumber, dnsName },
+ });
+ } else if (relay.kind() === 2) {
+ const type = RelayType.MULTI_HOST;
+ const multiHostName = relay.as_multi_host_name();
+ const dnsName = multiHostName.dns_name();
+ ledgerRelays.push({
+ type,
+ params: { dnsName },
+ });
+ }
+ }
+ const cost = params.cost().to_str();
+ const margin = params.margin();
+ const pledge = params.pledge().to_str();
+ const operator = Buffer.from(params.operator().to_bytes()).toString(
+ 'hex',
+ );
+ let poolKey;
+ if (operator == keys.stake.hash) {
+ signingMode = TransactionSigningMode.POOL_REGISTRATION_AS_OPERATOR;
+ poolKey = {
+ type: PoolKeyType.DEVICE_OWNED,
+ params: { path: keys.stake.path },
+ };
+ } else {
+ poolKey = {
+ type: PoolKeyType.THIRD_PARTY,
+ params: { keyHashHex: operator },
+ };
+ }
+ const metadata = params.pool_metadata()
+ ? {
+ metadataUrl: params.pool_metadata().url().url(),
+ metadataHashHex: Buffer.from(
+ params.pool_metadata().pool_metadata_hash().to_bytes(),
+ ).toString('hex'),
+ }
+ : null;
+ const rewardAccountHex = Buffer.from(
+ params.reward_account().to_address().to_bytes(),
+ ).toString('hex');
+ const rewardAccount =
+ rewardAccountHex == address
+ ? {
+ type: PoolRewardAccountType.DEVICE_OWNED,
+ params: { path: keys.stake.path },
+ }
+ : {
+ type: PoolRewardAccountType.THIRD_PARTY,
+ params: { rewardAccountHex },
+ };
+ const vrfKeyHashHex = Buffer.from(
+ params.vrf_keyhash().to_bytes(),
+ ).toString('hex');
+
+ certificate.params = {
+ poolKey,
+ vrfKeyHashHex,
+ pledge,
+ cost,
+ margin: {
+ numerator: margin.numerator().to_str(),
+ denominator: margin.denominator().to_str(),
+ },
+ rewardAccount,
+ poolOwners,
+ relays: ledgerRelays,
+ metadata,
+ };
+ }
+ ledgerCertificates.push(certificate);
+ }
+ }
+ const fee = tx.body().fee().to_str();
+ const ttl = tx.body().ttl() ? tx.body().ttl().to_str() : null;
+ const withdrawals = tx.body().withdrawals();
+ let ledgerWithdrawals = null;
+ if (withdrawals) {
+ ledgerWithdrawals = [];
+ for (let i = 0; i < withdrawals.keys().len(); i++) {
+ const withdrawal = { stakeCredential: {} };
+ const rewardAddress = withdrawals.keys().get(i);
+ if (rewardAddress.payment_cred().kind() === 0) {
+ withdrawal.stakeCredential.type = StakeCredentialParamsType.KEY_PATH;
+ withdrawal.stakeCredential.keyPath = keys.stake.path;
+ } else {
+ withdrawal.stakeCredential.type = StakeCredentialParamsType.SCRIPT_HASH;
+ withdrawal.stakeCredential.scriptHash = Buffer.from(
+ rewardAddress.payment_cred().to_scripthash().to_bytes(),
+ ).toString('hex');
+ }
+ withdrawal.amount = withdrawals.get(rewardAddress).to_str();
+ ledgerWithdrawals.push(withdrawal);
+ }
+ }
+ const auxiliaryData = tx.body().auxiliary_data_hash()
+ ? {
+ type: TxAuxiliaryDataType.ARBITRARY_HASH,
+ params: {
+ hashHex: Buffer.from(
+ tx.body().auxiliary_data_hash().to_bytes(),
+ ).toString('hex'),
+ },
+ }
+ : null;
+ const validityIntervalStart = tx.body().validity_start_interval()
+ ? tx.body().validity_start_interval().to_str()
+ : null;
+
+ const mint = tx.body().mint();
+ let additionalWitnessPaths = null;
+ let mintBundle = null;
+ if (mint) {
+ mintBundle = [];
+ for (let j = 0; j < mint.keys().len(); j++) {
+ const policy = mint.keys().get(j);
+ const assets = mint.get(policy);
+ const tokens = [];
+ for (let k = 0; k < assets.keys().len(); k++) {
+ const assetName = assets.keys().get(k);
+ const amount = assets.get(assetName);
+ tokens.push({
+ assetNameHex: Buffer.from(assetName.name()).toString('hex'),
+ amount: amount.is_positive()
+ ? amount.as_positive().to_str()
+ : '-' + amount.as_negative().to_str(),
+ });
+ }
+ // sort canonical
+ tokens.sort((a, b) => {
+ if (a.assetNameHex.length == b.assetNameHex.length) {
+ return a.assetNameHex > b.assetNameHex ? 1 : -1;
+ } else if (a.assetNameHex.length > b.assetNameHex.length) return 1;
+ else return -1;
+ });
+ mintBundle.push({
+ policyIdHex: Buffer.from(policy.to_bytes()).toString('hex'),
+ tokens,
+ });
+ }
+ }
+ additionalWitnessPaths = [];
+ if (keys.payment.path) additionalWitnessPaths.push(keys.payment.path);
+ if (keys.stake.path) additionalWitnessPaths.push(keys.stake.path);
+
+ // Plutus
+ const scriptDataHashHex = tx.body().script_data_hash()
+ ? Buffer.from(tx.body().script_data_hash().to_bytes()).toString('hex')
+ : null;
+
+ let collateralInputs = null;
+ if (tx.body().collateral()) {
+ collateralInputs = [];
+ const coll = tx.body().collateral();
+ for (let i = 0; i < coll.len(); i++) {
+ const input = coll.get(i);
+ if (keys.payment.path) {
+ collateralInputs.push({
+ txHashHex: Buffer.from(input.transaction_id().to_bytes()).toString(
+ 'hex',
+ ),
+ outputIndex: Number.parseInt(input.index().to_str()),
+ path: keys.payment.path, // needed to include payment key witness if available
+ });
+ } else {
+ collateralInputs.push({
+ txHashHex: Buffer.from(input.transaction_id().to_bytes()).toString(
+ 'hex',
+ ),
+ outputIndex: Number.parseInt(input.index().to_str()),
+ });
+ }
+ signingMode = TransactionSigningMode.PLUTUS_TRANSACTION;
+ }
+ }
+
+ const collateralOutput = (() => {
+ if (tx.body().collateral_return()) {
+ const outputs = Loader.Cardano.TransactionOutputs.new();
+ outputs.add(tx.body().collateral_return());
+ const [out] = outputsToLedger(outputs, address, index);
+ return out;
+ }
+ return null;
+ })();
+
+ const totalCollateral = tx.body().total_collateral()
+ ? tx.body().total_collateral().to_str()
+ : null;
+
+ let referenceInputs = null;
+ if (tx.body().reference_inputs()) {
+ referenceInputs = [];
+ const refInputs = tx.body().reference_inputs();
+ for (let i = 0; i < refInputs.len(); i++) {
+ const input = refInputs.get(i);
+ referenceInputs.push({
+ txHashHex: input.transaction_id().to_hex(),
+ outputIndex: Number.parseInt(input.index().to_str()),
+ path: null,
+ });
+ }
+ }
+
+ let requiredSigners = null;
+ if (tx.body().required_signers()) {
+ requiredSigners = [];
+ const r = tx.body().required_signers();
+ for (let i = 0; i < r.len(); i++) {
+ const signer = Buffer.from(r.get(i).to_bytes()).toString('hex');
+ if (signer === keys.payment.hash) {
+ requiredSigners.push({
+ type: TxRequiredSignerType.PATH,
+ path: keys.payment.path,
+ });
+ } else if (signer === keys.stake.hash) {
+ requiredSigners.push({
+ type: TxRequiredSignerType.PATH,
+ path: keys.stake.path,
+ });
+ } else {
+ requiredSigners.push({
+ type: TxRequiredSignerType.HASH,
+ hashHex: signer,
+ });
+ }
+ }
+ signingMode = TransactionSigningMode.PLUTUS_TRANSACTION;
+ }
+
+ const includeNetworkId = !!tx.body().network_id();
+
+ const ledgerTx = {
+ network: {
+ protocolMagic: network === 1 ? 764_824_073 : 42,
+ networkId: network,
+ },
+ inputs: ledgerInputs,
+ outputs: ledgerOutputs,
+ fee,
+ ttl,
+ certificates: ledgerCertificates,
+ withdrawals: ledgerWithdrawals,
+ auxiliaryData,
+ validityIntervalStart,
+ mint: mintBundle,
+ scriptDataHashHex,
+ collateralInputs,
+ requiredSigners,
+ includeNetworkId,
+ collateralOutput,
+ totalCollateral,
+ referenceInputs,
+ };
+
+ for (const key of Object.keys(ledgerTx))
+ !ledgerTx[key] && ledgerTx[key] != 0 && delete ledgerTx[key];
+
+ const fullTx = {
+ signingMode,
+ tx: ledgerTx,
+ additionalWitnessPaths,
+ };
+ for (const key of Object.keys(fullTx))
+ !fullTx[key] && fullTx[key] != 0 && delete fullTx[key];
+
+ return fullTx;
+};
+
+const bytesToIp = bytes => {
+ if (!bytes) return null;
+ if (bytes.length === 4) {
+ return { ipv4: bytes.join('.') };
+ } else if (bytes.length === 16) {
+ let ipv6 = '';
+ for (let i = 0; i < bytes.length; i += 2) {
+ ipv6 += bytes[i].toString(16) + bytes[i + 1].toString(16) + ':';
+ }
+ ipv6 = ipv6.slice(0, -1);
+ return { ipv6 };
+ }
+ return null;
+};
+
+const checksum = num =>
+ crc8(Buffer.from(num, 'hex')).toString(16).padStart(2, '0');
+
+// export function toLabel(num) {
+// if (num < 0 || num > 65535) {
+// throw new Error(
+// `Label ${num} out of range: min label 1 - max label 65535.`
+// );
+// }
+// const numHex = num.toString(16).padStart(4, '0');
+// return '0' + numHex + checksum(numHex) + '0';
+// }
+
+export const fromLabel = label => {
+ if (label.length !== 8 || !(label[0] === '0' && label[7] === '0')) {
+ return null;
+ }
+ const numHex = label.slice(1, 5);
+ const num = Number.parseInt(numHex, 16);
+ const check = label.slice(5, 7);
+ return check === checksum(numHex) ? num : null;
+};
+
+export const toAssetUnit = (policyId, name, label) => {
+ const hexLabel = Number.isInteger(label) ? toLabel(label) : '';
+ const n = name ?? '';
+ if ((n + hexLabel).length > 64) {
+ throw new Error('Asset name size exceeds 32 bytes.');
+ }
+ if (policyId.length !== 56) {
+ throw new Error(`Policy id invalid: ${policyId}.`);
+ }
+ return policyId + hexLabel + n;
+};
+
+export const fromAssetUnit = unit => {
+ const policyId = unit.slice(0, 56);
+ const label = fromLabel(unit.slice(56, 64));
+ const name = (() => {
+ const hexName = Number.isInteger(label) ? unit.slice(64) : unit.slice(56);
+ return unit.length === 56 ? '' : hexName || null;
+ })();
+ return { policyId, name, label };
+};
+
+export class Constr {
+ constructor(index, fields) {
+ this.index = index;
+ this.fields = fields;
+ }
+}
+
+export const Data = {
+ /** Convert PlutusData to Cbor encoded data */
+ to: async plutusData => {
+ await Loader.load();
+ const serialize = data => {
+ try {
+ if (
+ typeof data === 'bigint' ||
+ typeof data === 'number' ||
+ (typeof data === 'string' &&
+ !Number.isNaN(Number.parseInt(data)) &&
+ data.endsWith('n'))
+ ) {
+ const bigint =
+ typeof data === 'string' ? BigInt(data.slice(0, -1)) : data;
+ return Loader.Cardano.PlutusData.new_integer(
+ Loader.Cardano.BigInt.from_str(bigint.toString()),
+ );
+ } else if (typeof data === 'string') {
+ return Loader.Cardano.PlutusData.new_bytes(Buffer.from(data, 'hex'));
+ } else if (data instanceof Uint8Array) {
+ return Loader.Cardano.PlutusData.new_bytes(data);
+ } else if (data instanceof Constr) {
+ const { index, fields } = data;
+ const plutusList = Loader.Cardano.PlutusList.new();
+
+ for (const field of fields) plutusList.add(serialize(field));
+
+ return Loader.Cardano.PlutusData.new_constr_plutus_data(
+ Loader.Cardano.ConstrPlutusData.new(
+ Loader.Cardano.BigNum.from_str(index.toString()),
+ plutusList,
+ ),
+ );
+ } else if (Array.isArray(data)) {
+ const plutusList = Loader.Cardano.PlutusList.new();
+
+ for (const arg of data) plutusList.add(serialize(arg));
+
+ return Loader.Cardano.PlutusData.new_list(plutusList);
+ } else if (data instanceof Map) {
+ const plutusMap = Loader.Cardano.PlutusMap.new();
+
+ for (const [key, value] of data.entries()) {
+ plutusMap.insert(serialize(key), serialize(value));
+ }
+
+ return Loader.Cardano.PlutusData.new_map(plutusMap);
+ }
+ throw new Error('Unsupported type');
+ } catch (error) {
+ throw new Error('Could not serialize the data: ' + error);
+ }
+ };
+ return toHex(serialize(plutusData).to_bytes());
+ },
+
+ /** Convert Cbor encoded data to PlutusData */
+ from: async data => {
+ await Loader.load();
+ const plutusData = Loader.Cardano.PlutusData.from_bytes(
+ Buffer.from(data, 'hex'),
+ );
+ const deserialize = data => {
+ if (data.kind() === 0) {
+ const constr = data.as_constr_plutus_data();
+ const l = constr.data();
+ const desL = [];
+ for (let i = 0; i < l.len(); i++) {
+ desL.push(deserialize(l.get(i)));
+ }
+ return new Constr(Number.parseInt(constr.alternative().to_str()), desL);
+ } else if (data.kind() === 1) {
+ const m = data.as_map();
+ const desM = new Map();
+ const keys = m.keys();
+ for (let i = 0; i < keys.len(); i++) {
+ desM.set(deserialize(keys.get(i)), deserialize(m.get(keys.get(i))));
+ }
+ return desM;
+ } else if (data.kind() === 2) {
+ const l = data.as_list();
+ const desL = [];
+ for (let i = 0; i < l.len(); i++) {
+ desL.push(deserialize(l.get(i)));
+ }
+ return desL;
+ } else if (data.kind() === 3) {
+ return BigInt(data.as_integer().to_str());
+ } else if (data.kind() === 4) {
+ return Buffer.from(data.as_bytes()).toString('hex');
+ }
+ throw new Error('Unsupported type');
+ };
+ return deserialize(plutusData);
+ },
+
+ /**
+ * Convert conveniently a Json object (e.g. Metadata) to PlutusData.
+ * Note: Constructor cannot be used here.
+ */
+ fromJson: json => {
+ const toPlutusData = json => {
+ if (typeof json === 'string') {
+ return Buffer.from(json);
+ }
+ if (typeof json === 'number') return BigInt(json);
+ if (typeof json === 'bigint') return json;
+ if (Array.isArray(json)) return json.map(v => toPlutusData(v));
+ if (json instanceof Object) {
+ const tempMap = new Map();
+ for (const [key, value] of Object.entries(json)) {
+ tempMap.set(toPlutusData(key), toPlutusData(value));
+ }
+ return tempMap;
+ }
+ throw new Error('Unsupported type');
+ };
+ return toPlutusData(json);
+ },
+
+ /**
+ * Convert PlutusData to a Json object.
+ * Note: Constructor cannot be used here, also only bytes/integers as Json keys.
+ */
+ toJson: plutusData => {
+ const fromPlutusData = data => {
+ if (
+ typeof data === 'bigint' ||
+ typeof data === 'number' ||
+ (typeof data === 'string' &&
+ !Number.isNaN(Number.parseInt(data)) &&
+ data.endsWith('n'))
+ ) {
+ const bigint =
+ typeof data === 'string' ? BigInt(data.slice(0, -1)) : data;
+ return Number.parseInt(bigint.toString());
+ }
+ if (typeof data === 'string') {
+ return Buffer.from(data, 'hex').toString();
+ }
+ if (Array.isArray(data)) return data.map(v => fromPlutusData(v));
+ if (data instanceof Map) {
+ const tempJson = {};
+ for (const [key, value] of data.entries()) {
+ const convertedKey = fromPlutusData(key);
+ if (
+ typeof convertedKey !== 'string' &&
+ typeof convertedKey !== 'number'
+ ) {
+ throw new TypeError(
+ 'Unsupported type (Note: Only bytes or integers can be keys of a JSON object)',
+ );
+ }
+ tempJson[convertedKey] = fromPlutusData(value);
+ }
+ return tempJson;
+ }
+ throw new Error(
+ 'Unsupported type (Note: Constructor cannot be converted to JSON)',
+ );
+ };
+ return fromPlutusData(plutusData);
+ },
+
+ empty: () => 'd87980',
+};
diff --git a/packages/nami/src/assets/img/ada.png b/packages/nami/src/assets/img/ada.png
new file mode 100644
index 000000000..5d0d305b3
Binary files /dev/null and b/packages/nami/src/assets/img/ada.png differ
diff --git a/packages/nami/src/assets/img/bannerBlack.svg b/packages/nami/src/assets/img/bannerBlack.svg
new file mode 100644
index 000000000..0e40d4bdf
--- /dev/null
+++ b/packages/nami/src/assets/img/bannerBlack.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/nami/src/assets/img/bannerWhite.svg b/packages/nami/src/assets/img/bannerWhite.svg
new file mode 100644
index 000000000..3f468fa83
--- /dev/null
+++ b/packages/nami/src/assets/img/bannerWhite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/nami/src/assets/img/icon-128.png b/packages/nami/src/assets/img/icon-128.png
new file mode 100644
index 000000000..ca0998439
Binary files /dev/null and b/packages/nami/src/assets/img/icon-128.png differ
diff --git a/packages/nami/src/assets/img/icon-34.png b/packages/nami/src/assets/img/icon-34.png
new file mode 100644
index 000000000..899987699
Binary files /dev/null and b/packages/nami/src/assets/img/icon-34.png differ
diff --git a/packages/nami/src/assets/img/iohk.svg b/packages/nami/src/assets/img/iohk.svg
new file mode 100644
index 000000000..eea48673a
--- /dev/null
+++ b/packages/nami/src/assets/img/iohk.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/nami/src/assets/img/iohkWhite.svg b/packages/nami/src/assets/img/iohkWhite.svg
new file mode 100644
index 000000000..870be69ef
--- /dev/null
+++ b/packages/nami/src/assets/img/iohkWhite.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/nami/src/assets/img/lace.svg b/packages/nami/src/assets/img/lace.svg
new file mode 100644
index 000000000..828a67453
--- /dev/null
+++ b/packages/nami/src/assets/img/lace.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/nami/src/assets/img/laceGradientBackground.png b/packages/nami/src/assets/img/laceGradientBackground.png
new file mode 100644
index 000000000..8e78be9ac
Binary files /dev/null and b/packages/nami/src/assets/img/laceGradientBackground.png differ
diff --git a/packages/nami/src/assets/img/ledgerLogo.svg b/packages/nami/src/assets/img/ledgerLogo.svg
new file mode 100644
index 000000000..94b186137
--- /dev/null
+++ b/packages/nami/src/assets/img/ledgerLogo.svg
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/nami/src/assets/img/logo.svg b/packages/nami/src/assets/img/logo.svg
new file mode 100644
index 000000000..b26d269dc
--- /dev/null
+++ b/packages/nami/src/assets/img/logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/nami/src/assets/img/logoWhite.svg b/packages/nami/src/assets/img/logoWhite.svg
new file mode 100644
index 000000000..f9b74e511
--- /dev/null
+++ b/packages/nami/src/assets/img/logoWhite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/nami/src/assets/img/trezorLogo.svg b/packages/nami/src/assets/img/trezorLogo.svg
new file mode 100644
index 000000000..b8d85e3af
--- /dev/null
+++ b/packages/nami/src/assets/img/trezorLogo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/nami/src/assets/video/laceVideoBackground.mp4 b/packages/nami/src/assets/video/laceVideoBackground.mp4
new file mode 100644
index 000000000..7638b8d98
Binary files /dev/null and b/packages/nami/src/assets/video/laceVideoBackground.mp4 differ
diff --git a/packages/nami/src/config/config.ts b/packages/nami/src/config/config.ts
new file mode 100644
index 000000000..58b926661
--- /dev/null
+++ b/packages/nami/src/config/config.ts
@@ -0,0 +1,192 @@
+export const TARGET = 'nami-wallet';
+export const SENDER = { extension: 'extension', webpage: 'webpage' };
+export const METHOD = {
+ isWhitelisted: 'isWhitelisted',
+ enable: 'enable',
+ isEnabled: 'isEnabled',
+ currentWebpage: 'currentWebpage',
+ getNetworkId: 'getNetworkId',
+ getBalance: 'getBalance',
+ getDelegation: 'getDelegation',
+ getUtxos: 'getUtxos',
+ getCollateral: 'getCollateral',
+ getRewardAddress: 'getRewardAddress',
+ getAddress: 'getAddress',
+ signData: 'signData',
+ signTx: 'signTx',
+ submitTx: 'submitTx',
+ //internal
+ requestData: 'requestData',
+ returnData: 'returnData',
+};
+
+/*
+
+localStorage = {
+ whitelisted: [string],
+ encryptedKey: encrypted string
+ accounts: {
+ [accountIndex]: {
+ index: accountIndex,
+ paymentKeyHash: cbor string,
+ stakeKeyHash cbor string,
+ name: string,
+ mainnet: {
+ lovelace: 0,
+ assets: [],
+ history: {},
+ },
+ testnet: {
+ lovelace: 0,
+ assets: [],
+ history: {},
+ },
+ avatar: Math.random().toString(),
+ },
+ },
+ currentAccount: accountIndex,
+ network: {id: "mainnet" | "testnet", node: "https://blockfrost..."}
+}
+*/
+
+export const STORAGE = {
+ whitelisted: 'whitelisted',
+ encryptedKey: 'encryptedKey',
+ accounts: 'accounts',
+ currentAccount: 'currentAccount',
+ network: 'network',
+ currency: 'currency',
+ migration: 'migration',
+ analyticsConsent: 'analytics',
+ userId: 'userId',
+ acceptedLegalDocsVersion: 'acceptedLegalDocsVersion',
+};
+
+export const LOCAL_STORAGE = {
+ assets: 'assets',
+};
+
+export const NODE = {
+ mainnet: 'https://cardano-mainnet.blockfrost.io/api/v0',
+ testnet: 'https://cardano-testnet.blockfrost.io/api/v0',
+ preview: 'https://cardano-preview.blockfrost.io/api/v0',
+ preprod: 'https://cardano-preprod.blockfrost.io/api/v0',
+};
+
+export const NETWORK_ID = {
+ mainnet: 'mainnet',
+ testnet: 'testnet',
+ preview: 'preview',
+ preprod: 'preprod',
+};
+
+export const NETWORKD_ID_NUMBER = {
+ mainnet: 1,
+ testnet: 0,
+ preview: 0,
+ preprod: 0,
+};
+
+export const POPUP = {
+ main: 'mainPopup',
+ internal: 'internalPopup',
+};
+
+export const TAB = {
+ hw: 'hwTab',
+ createWallet: 'createWalletTab',
+ trezorTx: 'trezorTx',
+};
+
+export const HW = {
+ trezor: 'trezor',
+ ledger: 'ledger',
+};
+
+export const POPUP_WINDOW = {
+ top: 50,
+ left: 100,
+ width: 400,
+ height: 600,
+};
+
+export const ERROR = {
+ accessDenied: 'Access denied',
+ wrongPassword: 'Wrong password',
+ txTooBig: 'Transaction too big',
+ txNotPossible: 'Transaction not possible',
+ storeNotEmpty: 'Storage key is already set',
+ onlyOneAccount: 'Only one account exist in the wallet',
+ fullMempool: 'fullMempool',
+ submit: 'submit',
+};
+
+export const TX = {
+ invalid_hereafter: 3600 * 6, //6h from current slot
+};
+
+export const EVENT = {
+ accountChange: 'accountChange',
+ networkChange: 'networkChange',
+ // TODO
+ // connect: 'connect',
+ // disconnect: 'disconnect',
+ // utxoChange: 'utxoChange',
+};
+
+export const ADA_HANDLE = {
+ mainnet: 'f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a',
+ testnet: '8d18d786e92776c824607fd8e193ec535c79dc61ea2405ddf3b09fe3',
+};
+
+// Errors dApp Connector
+export const APIError = {
+ InvalidRequest: {
+ code: -1,
+ info: 'Inputs do not conform to this spec or are otherwise invalid.',
+ },
+ InternalError: {
+ code: -2,
+ info: 'An error occurred during execution of this API call.',
+ },
+ Refused: {
+ code: -3,
+ info: 'The request was refused due to lack of access - e.g. wallet disconnects.',
+ },
+ AccountChange: {
+ code: -4,
+ info: 'The account has changed. The dApp should call `wallet.enable()` to reestablish connection to the new account. The wallet should not ask for confirmation as the user was the one who initiated the account change in the first place.',
+ },
+};
+
+export const DataSignError = {
+ ProofGeneration: {
+ code: 1,
+ info: 'Wallet could not sign the data (e.g. does not have the secret key associated with the address).',
+ },
+ AddressNotPK: {
+ code: 2,
+ info: 'Address was not a P2PK address or Reward address and thus had no SK associated with it.',
+ },
+ UserDeclined: { code: 3, info: 'User declined to sign the data.' },
+ InvalidFormat: {
+ code: 4,
+ info: 'If a wallet enforces data format requirements, this error signifies that the data did not conform to valid formats.',
+ },
+};
+
+export const TxSendError = {
+ Refused: {
+ code: 1,
+ info: 'Wallet refuses to send the tx (could be rate limiting).',
+ },
+ Failure: { code: 2, info: 'Wallet could not send the tx.' },
+};
+
+export const TxSignError = {
+ ProofGeneration: {
+ code: 1,
+ info: 'User has accepted the transaction sign, but the wallet was unable to sign the transaction (e.g. not having some of the private keys).',
+ },
+ UserDeclined: { code: 2, info: 'User declined to sign the transaction.' },
+};
diff --git a/packages/nami/src/config/provider.ts b/packages/nami/src/config/provider.ts
new file mode 100644
index 000000000..30e429bdc
--- /dev/null
+++ b/packages/nami/src/config/provider.ts
@@ -0,0 +1,47 @@
+import { version } from '../../package.json';
+import { Network } from '../types';
+import { NetworkType } from '../types';
+
+import { NODE } from './config';
+
+// import secrets from 'secrets';
+
+const networkToProjectId = {
+ mainnet: 'preprodT2ItX4qKPEJOiJ7HH3q4zOFMZK4wQrtH',
+ testnet: 'preprodT2ItX4qKPEJOiJ7HH3q4zOFMZK4wQrtH',
+ preprod: 'preprodT2ItX4qKPEJOiJ7HH3q4zOFMZK4wQrtH',
+ preview: 'preprodT2ItX4qKPEJOiJ7HH3q4zOFMZK4wQrtH',
+};
+
+interface PriceData {
+ cardano: Record;
+}
+
+interface keyType {
+ project_id: string;
+}
+
+// eslint-disable-next-line import/no-default-export
+export default {
+ api: {
+ ipfs: 'https://ipfs.blockfrost.dev/ipfs',
+ base: (node = NODE.mainnet) => node,
+ header: { ['secrets.NAMI_HEADER' || 'dummy']: version },
+ key: (network: NetworkType = NetworkType.MAINNET): keyType => ({
+ project_id: networkToProjectId[network],
+ }),
+ price: async (currency = 'usd'): Promise => {
+ const response = await fetch(
+ `https://api.coingecko.com/api/v3/simple/price?ids=cardano&vs_currencies=${currency}`,
+ );
+
+ if (!response.ok) {
+ throw new Error('Network response was not ok');
+ }
+
+ const data: PriceData = await response.json();
+
+ return data.cardano[currency];
+ },
+ },
+};
diff --git a/packages/nami/src/features/ada-handle/config.ts b/packages/nami/src/features/ada-handle/config.ts
new file mode 100644
index 000000000..b9f80a415
--- /dev/null
+++ b/packages/nami/src/features/ada-handle/config.ts
@@ -0,0 +1,11 @@
+import { Cardano } from '@cardano-sdk/core';
+import { Wallet } from '@lace/cardano';
+
+export const ADA_HANDLE_POLICY_ID = Wallet.ADA_HANDLE_POLICY_ID;
+export const isAdaHandleEnabled = process.env.USE_ADA_HANDLE === 'true';
+export const HANDLE_SERVER_URLS: Record = {
+ [Cardano.NetworkMagics.Mainnet]: process.env.ADA_HANDLE_URL_MAINNET,
+ [Cardano.NetworkMagics.Preprod]: process.env.ADA_HANDLE_URL_PREPROD,
+ [Cardano.NetworkMagics.Preview]: process.env.ADA_HANDLE_URL_PREVIEW,
+ [Cardano.NetworkMagics.Sanchonet]: process.env.ADA_HANDLE_URL_SANCHONET,
+};
diff --git a/packages/nami/src/features/ada-handle/useHandleResolver.ts b/packages/nami/src/features/ada-handle/useHandleResolver.ts
new file mode 100644
index 000000000..d823f0985
--- /dev/null
+++ b/packages/nami/src/features/ada-handle/useHandleResolver.ts
@@ -0,0 +1,19 @@
+import { useMemo } from 'react';
+
+import { handleHttpProvider } from '@cardano-sdk/cardano-services-client';
+
+import { HANDLE_SERVER_URLS } from './config';
+
+import type { Cardano, HandleProvider } from '@cardano-sdk/core';
+
+export const useHandleResolver = (
+ networkMagic: Cardano.NetworkMagics,
+): HandleProvider => {
+ return useMemo(() => {
+ const serverUrl = HANDLE_SERVER_URLS[networkMagic];
+ return handleHttpProvider({
+ baseUrl: serverUrl,
+ logger: console,
+ });
+ }, [networkMagic]);
+};
diff --git a/packages/nami/src/features/analytics/events.ts b/packages/nami/src/features/analytics/events.ts
new file mode 100644
index 000000000..f90b693f2
--- /dev/null
+++ b/packages/nami/src/features/analytics/events.ts
@@ -0,0 +1,111 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+export enum Events {
+ PageView = '$pageview',
+
+ // create wallet
+ OnboardingCreateClick = 'nami mode | onboarding | new wallet | create | click',
+ OnboardingCreateWritePassphraseNextClick = 'nami mode | onboarding | new wallet | write passphrase | next | click',
+ OnboardingCreateEnterPassphraseNextClick = 'nami mode | onboarding | new wallet | enter passphrase | next | click',
+ OnboardingCreateEnterPassphraseSkipClick = 'nami mode | onboarding | new wallet | enter passphrase | skip | click',
+ OnboardingCreateWalletNamePasswordNextClick = 'nami mode | onboarding | new wallet | wallet name & password | next | click',
+
+ // import wallet
+ OnboardingRestoreClick = 'nami mode | onboarding | restore wallet | click',
+ OnboardingRestoreEnterPassphraseNextClick = 'nami mode | onboarding | restore wallet | enter passphrase | next | click',
+ OnboardingRestoreWalletNamePasswordNextClick = 'nami mode | onboarding | restore wallet | wallet name & password | next | click',
+
+ // receive
+ ReceiveClick = 'nami mode | receive | receive | click',
+ ReceiveCopyAddressIconClick = 'nami mode | receive | receive | copy address icon | click',
+
+ // send
+ SendClick = 'nami mode | send | send | click',
+ SendTransactionDataReviewTransactionClick = 'nami mode | send | transaction data | review transaction | click',
+ SendTransactionConfirmationConfirmClick = 'nami mode | send | transaction confirmation | confirm | click',
+ SendTransactionConfirmed = 'nami mode | send | transaction confirmed',
+
+ // settings
+ SettingsNetworkPreviewClick = 'nami mode | settings | network | preview | click',
+ SettingsNetworkPreprodClick = 'nami mode | settings | network | preprod | click',
+ SettingsNetworkMainnetClick = 'nami mode | settings | network | mainnet | click',
+ SettingsNetworkCustomNodeClick = 'nami mode | settings | network | custom node | click',
+
+ SettingsRemoveWalletClick = 'nami mode | settings | remove wallet | click',
+ SettingsHoldUpRemoveWalletClick = 'nami mode | settings | hold up | remove wallet | click',
+ SettingsHoldUpBackClick = 'nami mode | settings | hold up | back | click',
+
+ SettingsThemeLightModeClick = 'nami mode | settings | theme | light mode | click',
+ SettingsThemeDarkModeClick = 'nami mode | settings | theme | dark mode | click',
+
+ SettingsChangePasswordClick = 'nami mode | settings | change password | click',
+ SettingsChangePasswordConfirm = 'nami mode | settings | change password | confirm',
+
+ SettingsChangeAvatarClick = 'nami mode | settings | change avatar | click',
+
+ SettingsCollateralClick = 'nami mode | settings | collateral | click',
+ SettingsCollateralConfirmClick = 'nami mode | settings | collateral | confirm | click',
+ SettingsCollateralReclaimCollateralClick = 'nami mode | settings | collateral | reclaim collateral | click',
+ SettingsCollateralXClick = 'nami mode | settings | collateral | x | click',
+
+ SettingsTermsAndConditionsClick = 'nami mode | settings | terms and conditions | click',
+ SettingsTermsAndConditionsXClick = 'nami mode | settings | terms and conditions | x | click',
+
+ SettingsNewAccountClick = 'nami mode | settings | new account | click',
+ SettingsNewAccountConfirmClick = 'nami mode | settings | new account | confirm | click',
+ SettingsNewAccountXClick = 'nami mode | settings | new account | x | click',
+
+ SettingsAuthorizedDappsClick = 'nami mode | settings | authorized dapps | click',
+ SettingsAuthorizedDappsTrashBinIconClick = 'nami mode | settings | authorized dapps | trash bin icon | click',
+
+ SettingsSwitchToLaceModeClick = 'nami mode | settings | switch to lace mode | click',
+
+ // switch to lace mode banner
+ SwitchToLaceModeBannerClick = 'nami mode | switch to lace mode banner | click',
+ SwitchToLaceModeBannerActivateLaceButtonClick = 'nami mode | switch to lace mode banner | activate lace button | click',
+ SwitchToLaceModeBannerMaybeLaterButtonClick = 'nami mode | switch to lace mode banner | maybe later | click',
+
+ // account
+ AccountDeleteClick = 'nami mode | account | delete | click',
+ AccountDeleteConfirmClick = 'nami mode | account | delete | confirm | click',
+
+ // dapp
+ DappConnectorAuthorizeDappAuthorizeClick = 'nami mode | dapp connector | authorize dapp | authorize | click',
+ DappConnectorAuthorizeDappCancelClick = 'nami mode | dapp connector | authorize dapp | cancel | click',
+ DappConnectorDappTxSignClick = 'nami mode | dapp connector | tx | sign | click',
+ DappConnectorDappTxConfirmClick = 'nami mode | dapp connector | tx | confirm | click',
+ DappConnectorDappTxCancelClick = 'nami mode | dapp connector | tx | cancel | click',
+ DappConnectorDappDataSignClick = 'nami mode | dapp connector | data | sign | click',
+ DappConnectorDappDataConfirmClick = 'nami mode | dapp connector | data | confirm | click',
+ DappConnectorDappDataCancelClick = 'nami mode | dapp connector | data | cancel | click',
+
+ // hw
+ HWConnectClick = 'nami mode | hardware wallet | connect | click',
+ HWConnectNextClick = 'nami mode | hardware wallet | connect hw | next | click',
+ HWSelectAccountNextClick = 'nami mode | hardware wallet | select hw account | next | click',
+ HWDoneGoToWallet = 'nami mode | onboarding | hardware wallet | all done | go to my wallet | click',
+
+ // nfts
+ NFTsClick = 'nami mode | nft | nfts | click',
+ NFTsImageClick = 'nami mode | nft | nfts | nft image | click',
+
+ // assets
+ AssetsClick = 'nami mode | asset | assets | click',
+
+ // activity
+ ActivityActivityClick = 'nami mode | activity | activity | click',
+ ActivityActivityActivityRowClick = 'nami mode | activity | activity | activity row | click',
+ ActivityActivityDetailTransactionHashClick = 'nami mode | activity | activity detail | transaction hash | click',
+
+ // staking
+ StakingClick = 'nami mode | staking | staking | click',
+ StakingConfirmClick = 'nami mode | staking | staking | confirm | click',
+ StakingUnstakeClick = 'nami mode | staking | staking | unstake | click',
+ StakingUnstakeConfirmClick = 'nami mode | staking | staking | unstake | confirm | click',
+}
+
+export type Property =
+ | Record
+ | Record[]
+ | boolean
+ | string;
+export type Properties = Record;
diff --git a/packages/nami/src/features/analytics/hooks.ts b/packages/nami/src/features/analytics/hooks.ts
new file mode 100644
index 000000000..e3f91da25
--- /dev/null
+++ b/packages/nami/src/features/analytics/hooks.ts
@@ -0,0 +1,12 @@
+/* eslint-disable functional/no-throw-statements */
+import { useOutsideHandles } from '../../ui';
+
+import type { Events } from './events';
+
+export const useCaptureEvent = () => {
+ const { sendEventToPostHog } = useOutsideHandles();
+
+ return async (event: Events): Promise => {
+ await sendEventToPostHog(event);
+ };
+};
diff --git a/packages/nami/src/features/outside-handles-provider/OutsideHandlesProvider.tsx b/packages/nami/src/features/outside-handles-provider/OutsideHandlesProvider.tsx
new file mode 100644
index 000000000..d9aa31e97
--- /dev/null
+++ b/packages/nami/src/features/outside-handles-provider/OutsideHandlesProvider.tsx
@@ -0,0 +1,20 @@
+import type { PropsWithChildren } from 'react';
+import React, { useMemo } from 'react';
+
+import { Provider } from './context';
+
+import type { OutsideHandlesContextValue } from './types';
+
+type OutsideHandlesProviderProps =
+ PropsWithChildren;
+
+export const OutsideHandlesProvider = ({
+ children,
+ ...props
+}: Readonly): React.ReactElement => {
+ const contextValue = useMemo(
+ () => props,
+ Object.values(props),
+ );
+ return {children} ;
+};
diff --git a/packages/nami/src/features/outside-handles-provider/context.ts b/packages/nami/src/features/outside-handles-provider/context.ts
new file mode 100644
index 000000000..031c05d64
--- /dev/null
+++ b/packages/nami/src/features/outside-handles-provider/context.ts
@@ -0,0 +1,8 @@
+import { createContext } from 'react';
+
+import type { OutsideHandlesContextValue } from './types';
+
+// eslint-disable-next-line unicorn/no-null
+export const context = createContext(null);
+
+export const { Provider } = context;
diff --git a/packages/nami/src/features/outside-handles-provider/index.ts b/packages/nami/src/features/outside-handles-provider/index.ts
new file mode 100644
index 000000000..3ff3111b0
--- /dev/null
+++ b/packages/nami/src/features/outside-handles-provider/index.ts
@@ -0,0 +1,3 @@
+export { OutsideHandlesProvider } from './OutsideHandlesProvider';
+export { useOutsideHandles } from './useOutsideHandles';
+export * from './types';
diff --git a/packages/nami/src/features/outside-handles-provider/types.ts b/packages/nami/src/features/outside-handles-provider/types.ts
new file mode 100644
index 000000000..89323ed07
--- /dev/null
+++ b/packages/nami/src/features/outside-handles-provider/types.ts
@@ -0,0 +1,98 @@
+import type { Events } from '../../features/analytics/events';
+import type { CreateWalletParams } from '../../types/wallet';
+import type {
+ AnyBip32Wallet,
+ WalletManagerActivateProps,
+ WalletManagerApi,
+ WalletRepositoryApi,
+} from '@cardano-sdk/web-extension';
+import type { Wallet } from '@lace/cardano';
+import type { PasswordObj as Password } from '@lace/core';
+export interface IAssetDetails {
+ id: string;
+ logo: string;
+ name: string;
+ ticker: string;
+ price: string;
+ variation: string;
+ balance: string;
+ fiatBalance: string;
+}
+
+export interface WalletManagerAddAccountProps {
+ wallet: AnyBip32Wallet;
+ metadata: Wallet.AccountMetadata;
+ accountIndex: number;
+ passphrase?: Uint8Array;
+}
+
+export interface OutsideHandlesContextValue {
+ collateralFee: bigint;
+ isInitializingCollateral: boolean;
+ initializeCollateralTx: () => Promise;
+ submitCollateralTx: () => Promise;
+ addAccount: (props: Readonly) => Promise;
+ removeDapp: (origin: string) => Promise;
+ connectedDapps: Wallet.DappInfo[];
+ isAnalyticsOptIn: boolean;
+ handleAnalyticsChoice: (isOptIn: boolean) => Promise;
+ sendEventToPostHog: (action: Events) => Promise;
+ createWallet: (
+ args: Readonly,
+ ) => Promise;
+ getMnemonic: (passphrase: Uint8Array) => Promise;
+ deleteWallet: (
+ isForgotPasswordFlow?: boolean,
+ ) => Promise;
+ fiatCurrency: string;
+ setFiatCurrency: (fiatCurrency: string) => void;
+ theme: 'dark' | 'light';
+ setTheme: (theme: 'dark' | 'light') => void;
+ walletAddress: string;
+ inMemoryWallet: Wallet.ObservableWallet;
+ currentChain: Wallet.Cardano.ChainId;
+ cardanoPrice: number;
+ walletManager: WalletManagerApi;
+ walletRepository: WalletRepositoryApi<
+ Wallet.WalletMetadata,
+ Wallet.AccountMetadata
+ >;
+ withSignTxConfirmation: (
+ action: () => Promise,
+ password?: string,
+ ) => Promise;
+ switchNetwork: (chainName: Wallet.ChainName) => Promise;
+ environmentName: Wallet.ChainName;
+ availableChains: Wallet.ChainName[];
+ enableCustomNode: (network: Wallet.ChainName, value: string) => Promise;
+ getCustomSubmitApiForNetwork: (network: Wallet.ChainName) => {
+ status: boolean;
+ url: string;
+ };
+ defaultSubmitApi: string;
+ cardanoCoin: Wallet.CoinId;
+ isValidURL: (link: string) => boolean;
+ setAvatar: (image: string) => void;
+ buildDelegation: (
+ hexId?: Readonly,
+ ) => Promise;
+ signAndSubmitTransaction: () => Promise;
+ passwordUtil: {
+ clearSecrets: () => void;
+ password: Partial;
+ setPassword: (pw: Readonly>) => void;
+ };
+ delegationTxFee: string;
+ setSelectedStakePool: (
+ pool: Readonly,
+ ) => void;
+ isBuildingTx: boolean;
+ stakingError: string;
+ getStakePoolInfo: (
+ id: Readonly,
+ ) => Promise;
+ resetDelegationState: () => void;
+ hasNoFunds: boolean;
+ switchWalletMode: () => Promise;
+ openExternalLink: (url: string) => void;
+}
diff --git a/packages/nami/src/features/outside-handles-provider/useOutsideHandles.mock.ts b/packages/nami/src/features/outside-handles-provider/useOutsideHandles.mock.ts
new file mode 100644
index 000000000..31c66d1bc
--- /dev/null
+++ b/packages/nami/src/features/outside-handles-provider/useOutsideHandles.mock.ts
@@ -0,0 +1,11 @@
+/* eslint-disable @typescript-eslint/no-unsafe-member-access */
+/* eslint-disable @typescript-eslint/no-unsafe-argument */
+import { fn } from '@storybook/test';
+
+import * as actualApi from './useOutsideHandles';
+
+export * from './useOutsideHandles';
+
+export const useOutsideHandles = fn(actualApi.useOutsideHandles).mockName(
+ 'useOutsideHandles',
+);
diff --git a/packages/nami/src/features/outside-handles-provider/useOutsideHandles.ts b/packages/nami/src/features/outside-handles-provider/useOutsideHandles.ts
new file mode 100644
index 000000000..ad3b89c79
--- /dev/null
+++ b/packages/nami/src/features/outside-handles-provider/useOutsideHandles.ts
@@ -0,0 +1,12 @@
+/* eslint-disable functional/no-throw-statements */
+import { useContext } from 'react';
+
+import { context } from './context';
+
+import type { OutsideHandlesContextValue } from './types';
+
+export const useOutsideHandles = (): OutsideHandlesContextValue | never => {
+ const contextValue = useContext(context);
+ if (!contextValue) throw new Error('OutsideHandles context not defined');
+ return contextValue;
+};
diff --git a/packages/nami/src/features/settings/legal/LegalSettings.tsx b/packages/nami/src/features/settings/legal/LegalSettings.tsx
new file mode 100644
index 000000000..1c552d0b6
--- /dev/null
+++ b/packages/nami/src/features/settings/legal/LegalSettings.tsx
@@ -0,0 +1,121 @@
+import React, { useRef } from 'react';
+
+import { ChevronRightIcon, InfoOutlineIcon } from '@chakra-ui/icons';
+import {
+ Box,
+ Button,
+ Flex,
+ Link,
+ Popover,
+ PopoverArrow,
+ PopoverBody,
+ PopoverContent,
+ PopoverTrigger,
+ Spacer,
+ Switch,
+ Text,
+} from '@chakra-ui/react';
+
+import { Events } from '../../../features/analytics/events';
+import { useCaptureEvent } from '../../../features/analytics/hooks';
+import { useOutsideHandles } from '../../../features/outside-handles-provider/useOutsideHandles';
+import PrivacyPolicy from '../../../ui/app/components/privacyPolicy';
+import TermsOfUse from '../../../ui/app/components/termsOfUse';
+
+interface Props {
+ isAnalyticsOptIn: boolean;
+ handleAnalyticsChoice: (isOptedIn: boolean) => Promise;
+}
+
+export const LegalSettings = ({
+ isAnalyticsOptIn,
+ handleAnalyticsChoice,
+}: Readonly) => {
+ const capture = useCaptureEvent();
+ const { openExternalLink } = useOutsideHandles();
+ const termsReference = useRef<{ openModal: () => void }>();
+ const privacyPolicyReference = useRef<{ openModal: () => void }>();
+ return (
+ <>
+
+
+ Legal
+
+
+
+
+ Analytics
+
+
+
+
+
+
+
+
+ We collect anonymous information from your browser extension
+ to help us improve the quality and performance of Nami. This
+ may include data about how you use our service, your
+ preferences and information about your system. Read more
+ {
+ openExternalLink('https://namiwallet.io');
+ }}
+ textDecoration="underline"
+ >
+ here
+
+ .
+
+
+
+
+
+
+ {
+ void handleAnalyticsChoice(!isAnalyticsOptIn);
+ }}
+ />
+
+
+ }
+ variant="ghost"
+ onClick={() => {
+ void capture(Events.SettingsTermsAndConditionsClick);
+ termsReference.current?.openModal();
+ }}
+ >
+ Terms of Use
+
+
+ }
+ variant="ghost"
+ onClick={() => privacyPolicyReference.current?.openModal()}
+ >
+ Privacy Policy
+
+
+
+ >
+ );
+};
diff --git a/packages/nami/src/index.ts b/packages/nami/src/index.ts
new file mode 100644
index 000000000..6c851e4ec
--- /dev/null
+++ b/packages/nami/src/index.ts
@@ -0,0 +1,2 @@
+export * from './ui';
+export { Events as NamiModeActions } from './features/analytics/events';
diff --git a/packages/nami/src/mocks/account.mock.ts b/packages/nami/src/mocks/account.mock.ts
new file mode 100644
index 000000000..70068a75b
--- /dev/null
+++ b/packages/nami/src/mocks/account.mock.ts
@@ -0,0 +1,2328 @@
+/* eslint-disable @typescript-eslint/no-unsafe-assignment */
+import { network } from './network.mock';
+
+import type { Account } from 'ui/app/pages/wallet.types';
+
+/* eslint-disable unicorn/no-null */
+export const account = {
+ avatar: '0.32533156086782333',
+ index: 0,
+ mainnet: {
+ assets: [],
+ history: { confirmed: [], details: {} },
+ lovelace: '0',
+ minAda: 0,
+ paymentAddr:
+ 'addr1q850c2zgp3e5smfgsp6vttrkvzksvydwtnjsthsegdf5v6tpafc27808z72a75hx95wq7tygzlcnkhx5ks8qfj44444qjgrvm5',
+ rewardAddr: 'stake1u9s75u90rhn309wl2tnz68q09jyp0ufmtn2tgrsye26666ss54s67',
+ },
+ name: 'nami',
+ paymentKeyHash: 'e8fc28480c73486d288074c5ac7660ad0611ae5ce505de1943534669',
+ paymentKeyHashBech32:
+ 'addr_vkh1ar7zsjqvwdyx62yqwnz6canq45rprtjuu5zaux2r2drxjlwvm2w',
+ collateral: {
+ lovelace: '5000000',
+ txHash: 'a399e301967b7b69d4d422300db14310ebe4db2355d350949be37bda5ec3e311',
+ txId: 0,
+ },
+ preprod: {
+ assets: [
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: '0b23996b05afb3a76cc802dcb1d854a2b3596b208bf775c162cec2d34e6f6e5371756172654e66743230',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: '0b23996b05afb3a76cc802dcb1d854a2b3596b208bf775c162cec2d34e6f6e5371756172654e66743235',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '10',
+ unit: '212a16adbc2aec5cab350fc8e8a32defae6d766f7a774142d5ae995f4d657368546f6b656e',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: false,
+ quantity: '9',
+ unit: '212a16adbc2aec5cab350fc8e8a32defae6d766f7a774142d5ae995f54657374546f6b656e',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: '2660d5a40acd9d93945c5f44352d34867241826ffbc7bdcaa6a30bea574e4654',
+ },
+ {
+ decimals: 6,
+ has_nft_onchain_metadata: false,
+ quantity: '9000000',
+ unit: '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff198444149',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: false,
+ quantity: '10999999',
+ unit: '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff198446a6564',
+ },
+ {
+ decimals: 6,
+ has_nft_onchain_metadata: false,
+ quantity: '4000000',
+ unit: '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff19855534443',
+ },
+ {
+ decimals: 6,
+ has_nft_onchain_metadata: false,
+ quantity: '9000000',
+ unit: '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff19855534454',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: false,
+ quantity: '10999999',
+ unit: '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff19869555344',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: 'c4c0005b4e9ae69cd30bcfd8c3d2c953ac5d12f7255e319aba8f19ea546573744275647a50726570726f645f31',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: 'c4c0005b4e9ae69cd30bcfd8c3d2c953ac5d12f7255e319aba8f19ea546573744275647a50726570726f645f313030',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: 'c4c0005b4e9ae69cd30bcfd8c3d2c953ac5d12f7255e319aba8f19ea546573744275647a50726570726f645f3233',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: 'c4c0005b4e9ae69cd30bcfd8c3d2c953ac5d12f7255e319aba8f19ea546573744275647a50726570726f645f3234',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: 'c4c0005b4e9ae69cd30bcfd8c3d2c953ac5d12f7255e319aba8f19ea546573744275647a50726570726f645f3238',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: 'c4c0005b4e9ae69cd30bcfd8c3d2c953ac5d12f7255e319aba8f19ea546573744275647a50726570726f645f3338',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: 'c4c0005b4e9ae69cd30bcfd8c3d2c953ac5d12f7255e319aba8f19ea546573744275647a50726570726f645f3435',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: 'c4c0005b4e9ae69cd30bcfd8c3d2c953ac5d12f7255e319aba8f19ea546573744275647a50726570726f645f3437',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: 'e517b38693b633f1bc0dd3eb69cb1ad0f0c198c67188405901ae63a3001bc28068616e646c65735f6e61747572652d6c616b65',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: 'f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a000de1406b6c6f73',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: 'f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a000de1406e69747069636b6572',
+ },
+ {
+ decimals: 0,
+ has_nft_onchain_metadata: false,
+ quantity: '101',
+ unit: 'f6f49b186751e61f1fb8c64e7504e771f968cea9f4d11f5222b169e374484f534b59',
+ },
+ {
+ decimals: 6,
+ has_nft_onchain_metadata: false,
+ quantity: '22471977',
+ unit: 'f6f49b186751e61f1fb8c64e7504e771f968cea9f4d11f5222b169e3744d494e',
+ },
+ ],
+ history: {
+ confirmed: [
+ '05dfe1e6d96f93f47d78c9a5df771efa6a57cfa357e8c36842c1068311a9354f',
+ 'e69ef6b084344f1e2601a31a9406e3f903763f3eae61c1963b958de600760396',
+ 'ca309a05c952d9101ac6ef8e9a75e4c0ded267c8c86e24bec684347b1e7f2ad7',
+ 'd4dc677aea911dddf7a631937dd3f2984648cb112f1eec25da9fdcdf5e3dc4e6',
+ '41a327218abf9d572695c6835afe62008f83c6a131107a8fbc6f085cee6bde20',
+ ],
+ details: {},
+ },
+ lastUpdate:
+ '05dfe1e6d96f93f47d78c9a5df771efa6a57cfa357e8c36842c1068311a9354f',
+ lovelace: '12737296152',
+ minAda: '3521270',
+ paymentAddr:
+ 'addr_test1qr50c2zgp3e5smfgsp6vttrkvzksvydwtnjsthsegdf5v6tpafc27808z72a75hx95wq7tygzlcnkhx5ks8qfj44444q377vht',
+ rewardAddr:
+ 'stake_test1ups75u90rhn309wl2tnz68q09jyp0ufmtn2tgrsye26666sh7lj7r',
+ recentSendToAddresses: [
+ 'addr_test1qznkfw45dhtkr6f60hgw6rktmza7ll7achyv2w7vsx2khhcvec23vqjpq7wzwfq78j44xkyy6rg6435skpst6ju0j4tqfcx0ze',
+ ],
+ },
+ preview: {
+ assets: [],
+ history: { confirmed: [], details: {} },
+ lovelace: null,
+ minAda: 0,
+ paymentAddr:
+ 'addr_test1qr50c2zgp3e5smfgsp6vttrkvzksvydwtnjsthsegdf5v6tpafc27808z72a75hx95wq7tygzlcnkhx5ks8qfj44444q377vht',
+ rewardAddr:
+ 'stake_test1ups75u90rhn309wl2tnz68q09jyp0ufmtn2tgrsye26666sh7lj7r',
+ },
+ publicKey:
+ '39e5f17597c93447e77d2ec9e1f76d7b9be9bf21eacc92b695d39a3c0d85d3d2b1c7cb7d73ae1ae31adbb133ceced87f71cd41398994a8684129f8e1d20f1f74',
+ stakeKeyHash: '61ea70af1de71795df52e62d1c0f2c8817f13b5cd4b40e04cab5ad6a',
+ testnet: {
+ assets: [],
+ history: { confirmed: [], details: {} },
+ lovelace: null,
+ minAda: 0,
+ paymentAddr:
+ 'addr_test1qr50c2zgp3e5smfgsp6vttrkvzksvydwtnjsthsegdf5v6tpafc27808z72a75hx95wq7tygzlcnkhx5ks8qfj44444q377vht',
+ rewardAddr:
+ 'stake_test1ups75u90rhn309wl2tnz68q09jyp0ufmtn2tgrsye26666sh7lj7r',
+ },
+ paymentAddr:
+ 'addr_test1qr50c2zgp3e5smfgsp6vttrkvzksvydwtnjsthsegdf5v6tpafc27808z72a75hx95wq7tygzlcnkhx5ks8qfj44444q377vht',
+ rewardAddr:
+ 'stake_test1ups75u90rhn309wl2tnz68q09jyp0ufmtn2tgrsye26666sh7lj7r',
+ assets: [
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: '0b23996b05afb3a76cc802dcb1d854a2b3596b208bf775c162cec2d34e6f6e5371756172654e66743230',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: '0b23996b05afb3a76cc802dcb1d854a2b3596b208bf775c162cec2d34e6f6e5371756172654e66743235',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '10',
+ unit: '212a16adbc2aec5cab350fc8e8a32defae6d766f7a774142d5ae995f4d657368546f6b656e',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: false,
+ quantity: '9',
+ unit: '212a16adbc2aec5cab350fc8e8a32defae6d766f7a774142d5ae995f54657374546f6b656e',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: '2660d5a40acd9d93945c5f44352d34867241826ffbc7bdcaa6a30bea574e4654',
+ },
+ {
+ decimals: 6,
+ has_nft_onchain_metadata: false,
+ quantity: '9000000',
+ unit: '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff198444149',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: false,
+ quantity: '10999999',
+ unit: '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff198446a6564',
+ },
+ {
+ decimals: 6,
+ has_nft_onchain_metadata: false,
+ quantity: '4000000',
+ unit: '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff19855534443',
+ },
+ {
+ decimals: 6,
+ has_nft_onchain_metadata: false,
+ quantity: '9000000',
+ unit: '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff19855534454',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: false,
+ quantity: '10999999',
+ unit: '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff19869555344',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: 'c4c0005b4e9ae69cd30bcfd8c3d2c953ac5d12f7255e319aba8f19ea546573744275647a50726570726f645f31',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: 'c4c0005b4e9ae69cd30bcfd8c3d2c953ac5d12f7255e319aba8f19ea546573744275647a50726570726f645f313030',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: 'c4c0005b4e9ae69cd30bcfd8c3d2c953ac5d12f7255e319aba8f19ea546573744275647a50726570726f645f3233',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: 'c4c0005b4e9ae69cd30bcfd8c3d2c953ac5d12f7255e319aba8f19ea546573744275647a50726570726f645f3234',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: 'c4c0005b4e9ae69cd30bcfd8c3d2c953ac5d12f7255e319aba8f19ea546573744275647a50726570726f645f3238',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: 'c4c0005b4e9ae69cd30bcfd8c3d2c953ac5d12f7255e319aba8f19ea546573744275647a50726570726f645f3338',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: 'c4c0005b4e9ae69cd30bcfd8c3d2c953ac5d12f7255e319aba8f19ea546573744275647a50726570726f645f3435',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: 'c4c0005b4e9ae69cd30bcfd8c3d2c953ac5d12f7255e319aba8f19ea546573744275647a50726570726f645f3437',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: 'e517b38693b633f1bc0dd3eb69cb1ad0f0c198c67188405901ae63a3001bc28068616e646c65735f6e61747572652d6c616b65',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: 'f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a000de1406b6c6f73',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: true,
+ quantity: '1',
+ unit: 'f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a000de1406e69747069636b6572',
+ },
+ {
+ decimals: 0,
+ has_nft_onchain_metadata: false,
+ quantity: '101',
+ unit: 'f6f49b186751e61f1fb8c64e7504e771f968cea9f4d11f5222b169e374484f534b59',
+ },
+ {
+ decimals: 6,
+ has_nft_onchain_metadata: false,
+ quantity: '22471977',
+ unit: 'f6f49b186751e61f1fb8c64e7504e771f968cea9f4d11f5222b169e3744d494e',
+ },
+ ],
+ lovelace: '12737296152',
+ minAda: '3521270',
+ history: {
+ confirmed: [
+ '05dfe1e6d96f93f47d78c9a5df771efa6a57cfa357e8c36842c1068311a9354f',
+ 'e69ef6b084344f1e2601a31a9406e3f903763f3eae61c1963b958de600760396',
+ 'ca309a05c952d9101ac6ef8e9a75e4c0ded267c8c86e24bec684347b1e7f2ad7',
+ 'd4dc677aea911dddf7a631937dd3f2984648cb112f1eec25da9fdcdf5e3dc4e6',
+ '41a327218abf9d572695c6835afe62008f83c6a131107a8fbc6f085cee6bde20',
+ ],
+ details: {},
+ },
+} as unknown as Account;
+
+export const account1 = {
+ avatar: '0.5083171879555981',
+ index: 1,
+ mainnet: {
+ assets: [],
+ history: {
+ confirmed: [],
+ details: {},
+ },
+ lovelace: null,
+ minAda: 0,
+ paymentAddr:
+ 'addr1qxnkfw45dhtkr6f60hgw6rktmza7ll7achyv2w7vsx2khhcvec23vqjpq7wzwfq78j44xkyy6rg6435skpst6ju0j4tq2wm0wx',
+ rewardAddr: 'stake1uyxvu9gkqfqs08p8ys0re26ntzzdp5d2c6gtqc9afw8e24suyq8pf',
+ },
+ name: 'account 2',
+ paymentKeyHash: 'a764bab46dd761e93a7dd0ed0ecbd8bbefffddc5c8c53bcc81956bdf',
+ paymentKeyHashBech32:
+ 'addr_vkh15ajt4drd6as7jwna6rksaj7ch0hllhw9erznhnypj44a70y6zuk',
+ preprod: {
+ lovelace: '9996976475',
+ assets: [],
+ history: {
+ confirmed: [],
+ details: {},
+ },
+ minAda: 0,
+ paymentAddr:
+ 'addr_test1qznkfw45dhtkr6f60hgw6rktmza7ll7achyv2w7vsx2khhcvec23vqjpq7wzwfq78j44xkyy6rg6435skpst6ju0j4tqfcx0ze',
+ rewardAddr:
+ 'stake_test1uqxvu9gkqfqs08p8ys0re26ntzzdp5d2c6gtqc9afw8e24smw2995',
+ },
+ preview: {
+ assets: [],
+ history: {
+ confirmed: [],
+ details: {},
+ },
+ lovelace: null,
+ minAda: 0,
+ paymentAddr:
+ 'addr_test1qznkfw45dhtkr6f60hgw6rktmza7ll7achyv2w7vsx2khhcvec23vqjpq7wzwfq78j44xkyy6rg6435skpst6ju0j4tqfcx0ze',
+ rewardAddr:
+ 'stake_test1uqxvu9gkqfqs08p8ys0re26ntzzdp5d2c6gtqc9afw8e24smw2995',
+ },
+ publicKey:
+ 'b6d36cedfc237e41921d586865864474564826daacfef9e1f605c79470bea4a0f1b8ab532c1760832e99e59f0c582723d631e120bb17659159cea8e180f0d0df',
+ stakeKeyHash: '0cce15160241079c27241e3cab535884d0d1aac690b060bd4b8f9556',
+ testnet: {
+ assets: [],
+ history: {
+ confirmed: [],
+ details: {},
+ },
+ lovelace: null,
+ minAda: 0,
+ paymentAddr:
+ 'addr_test1qznkfw45dhtkr6f60hgw6rktmza7ll7achyv2w7vsx2khhcvec23vqjpq7wzwfq78j44xkyy6rg6435skpst6ju0j4tqfcx0ze',
+ rewardAddr:
+ 'stake_test1uqxvu9gkqfqs08p8ys0re26ntzzdp5d2c6gtqc9afw8e24smw2995',
+ },
+ paymentAddr:
+ 'addr_test1qznkfw45dhtkr6f60hgw6rktmza7ll7achyv2w7vsx2khhcvec23vqjpq7wzwfq78j44xkyy6rg6435skpst6ju0j4tqfcx0ze',
+ rewardAddr:
+ 'stake_test1uqxvu9gkqfqs08p8ys0re26ntzzdp5d2c6gtqc9afw8e24smw2995',
+ assets: [],
+ lovelace: '9996976475',
+ minAda: 0,
+ history: {
+ confirmed: [],
+ details: {},
+ },
+};
+
+export const accountHW = {
+ avatar: '0.5609736852739162',
+ index: 'ledger-20501-2',
+ mainnet: {
+ assets: [],
+ history: {
+ confirmed: [],
+ details: {},
+ },
+ lovelace: null,
+ minAda: 0,
+ paymentAddr:
+ 'addr1q850c2zgp3e5smfgsp6vttrkvzksvydwtnjsthsegdf5v6tpafc27808z72a75hx95wq7tygzlcnkhx5ks8qfj44444qjgrvm5',
+ rewardAddr: 'stake1u9s75u90rhn309wl2tnz68q09jyp0ufmtn2tgrsye26666ss54s67',
+ },
+ name: 'Ledger 3',
+ paymentKeyHash: 'a764bab46dd761e93a7dd0ed0ecbd8bbefffddc5c8c53bcc81956bdf',
+ paymentKeyHashBech32:
+ 'addr_vkh15ajt4drd6as7jwna6rksaj7ch0hllhw9erznhnypj44a70y6zuk',
+ preprod: {
+ assets: [],
+ history: {
+ confirmed: [],
+ details: {},
+ },
+ lovelace: null,
+ minAda: 0,
+ paymentAddr:
+ 'addr_test1qr50c2zgp3e5smfgsp6vttrkvzksvydwtnjsthsegdf5v6tpafc27808z72a75hx95wq7tygzlcnkhx5ks8qfj44444q377vht',
+ rewardAddr:
+ 'stake_test1ups75u90rhn309wl2tnz68q09jyp0ufmtn2tgrsye26666sh7lj7r',
+ },
+ preview: {
+ assets: [],
+ history: {
+ confirmed: [],
+ details: {},
+ },
+ lovelace: null,
+ minAda: 0,
+ paymentAddr:
+ 'addr_test1qr50c2zgp3e5smfgsp6vttrkvzksvydwtnjsthsegdf5v6tpafc27808z72a75hx95wq7tygzlcnkhx5ks8qfj44444q377vht',
+ rewardAddr:
+ 'stake_test1ups75u90rhn309wl2tnz68q09jyp0ufmtn2tgrsye26666sh7lj7r',
+ },
+ publicKey:
+ '39e5f17597c93447e77d2ec9e1f76d7b9be9bf21eacc92b695d39a3c0d85d3d2b1c7cb7d73ae1ae31adbb133ceced87f71cd41398994a8684129f8e1d20f1f74',
+ stakeKeyHash: '61ea70af1de71795df52e62d1c0f2c8817f13b5cd4b40e04cab5ad6a',
+ testnet: {
+ assets: [],
+ history: {
+ confirmed: [],
+ details: {},
+ },
+ lovelace: null,
+ minAda: 0,
+ paymentAddr:
+ 'addr_test1qr50c2zgp3e5smfgsp6vttrkvzksvydwtnjsthsegdf5v6tpafc27808z72a75hx95wq7tygzlcnkhx5ks8qfj44444q377vht',
+ rewardAddr:
+ 'stake_test1ups75u90rhn309wl2tnz68q09jyp0ufmtn2tgrsye26666sh7lj7r',
+ },
+ paymentAddr:
+ 'addr_test1qr50c2zgp3e5smfgsp6vttrkvzksvydwtnjsthsegdf5v6tpafc27808z72a75hx95wq7tygzlcnkhx5ks8qfj44444q377vht',
+ rewardAddr:
+ 'stake_test1ups75u90rhn309wl2tnz68q09jyp0ufmtn2tgrsye26666sh7lj7r',
+ assets: [],
+ lovelace: null,
+ minAda: 0,
+ history: {
+ confirmed: [],
+ details: {},
+ },
+};
+
+export const currentAccount = {
+ ...account,
+ assets: account[network.id].assets,
+ lovelace: account[network.id].lovelace,
+ history: account[network.id].history,
+ minAda: account[network.id].minAda,
+ collateral: account[network.id].collateral,
+ recentSendToAddresses: account[network.id].recentSendToAddresses,
+ paymentAddr: account[network.id].paymentAddr,
+ rewardAddr: account[network.id].rewardAddr,
+};
+
+export const account2 = {
+ avatar: '0.7267421825241898',
+ index: 0,
+ name: 'Test',
+ paymentKeyHash: '37f60ad7e24cf496ac02d30da7be208cb703e7474fd05096d70a744e',
+ paymentKeyHashBech32:
+ 'addr_vkh1xlmq44lzfn6fdtqz6vx6003q3jms8e68flg9p9khpf6yu6uewvu',
+ assets: [
+ {
+ decimals: null,
+ has_nft_onchain_metadata: false,
+ quantity: '2',
+ unit: '093e1dd222241dabb60ec25e98026d68ff45bd4e7c6a86bca0f59d3853505f714a61653270',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: false,
+ quantity: '1',
+ unit: '359289937f6cd0478f2c0737eed4ba879725c09d9d80caeeadf4a67f6261746d616e',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: false,
+ quantity: '1',
+ unit: '4298bc56195ebed886f2172eb0352a26611ce34f4482a3ee3cc0f39574657374',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: false,
+ quantity: '2',
+ unit: '666816b289a3c7a6427333703dc6cfd4b9c544f97bd70dfd913a778a53505f4d464b497961',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: false,
+ quantity: '2',
+ unit: '6736988a80b3e42c1940e48d5ab2de52c626acb22d21c13b5ff5c86253505f47516674726f',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: false,
+ quantity: '2',
+ unit: 'b2ab960cf45de24f65d7abe9ee6ac7ed03453a8953fe2421eba0d32553505f676471663775',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: false,
+ quantity: '8704538763',
+ unit: 'c82a4452eaebccb82aced501b3c94d3662cf6cd2915ad7148b459aec41584f',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: false,
+ quantity: '67280096',
+ unit: 'e16c2dc8ae937e8d3790c7fd7168d7b994621ba14ca11415f39fed724d494e',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: false,
+ quantity: '2',
+ unit: 'fa39bd793aed73c0a2d30451e616e298320cb8ada00987370d2dcd0453505f464556484e4a',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: false,
+ quantity: '2253633',
+ unit: 'fbaec8dd4d4405a4a42aec11ce5a0160c01e488f3918b082ccbab70544da9f788bef996b9adbefa7d3d9cbb616d7e8174a1ffaf320270db7bf561b05',
+ },
+ ],
+ history: {
+ confirmed: [
+ 'c79f37caa73e2db87367c8ca9a802d4c50032f21c7057757659f39ba3b68a224',
+ '35df1fdedc85bc84fab4d4aa112e6a3e6322d23c8ef1dd0401a5a6afeeb00f80',
+ '3eafd8a177c0806c89835601fde8797d467ee199df83cbe07632781a5cc7d6d3',
+ '3e0e7f8ae7732277ec14c87b58b7c6bbc641aa1b70bfe198f30d74733aeaf189',
+ '49acbc6d2d30e7ba7a829d93ce0d32f26d8046765d6a1b50c8380b1595fb5c21',
+ ],
+ details: {
+ '35df1fdedc85bc84fab4d4aa112e6a3e6322d23c8ef1dd0401a5a6afeeb00f80': {
+ block: {
+ block_vrf:
+ 'vrf_vk1sn75n6qtvl3shetj7cv4tly7jsphu48pf832vxadklvgd0tf3cks4mqvdf',
+ confirmations: 102_751,
+ epoch: 145,
+ epoch_slot: 216_495,
+ fees: '173861',
+ hash: '8db33ca759f111dba30a07b2cee0c6fcab1db613579c46c9ff8b31a3afcb28bc',
+ height: 2_293_683,
+ next_block:
+ '0f2ce5434f4b92140150da8d1dc1dd7c872ae27ba06b4d7d15674f07045a5bdd',
+ op_cert:
+ 'f2b4e41d64d8f502b16b31a51c60862fe3118b97452d924569ba45e91aff6d5e',
+ op_cert_counter: '2',
+ output: '2621219',
+ previous_block:
+ '022f736a0c92ab1508f01c7c68c76dc328fd0fcacb5eba81a9094ac4301ee395',
+ size: 419,
+ slot: 61_214_895,
+ slot_leader:
+ 'pool1pzdqdxrv0k74p4q33y98f2u7vzaz95et7mjeedjcfy0jcgk754f',
+ time: 1_716_898_095,
+ tx_count: 1,
+ },
+ info: {
+ asset_mint_or_burn_count: 0,
+ block:
+ '8db33ca759f111dba30a07b2cee0c6fcab1db613579c46c9ff8b31a3afcb28bc',
+ block_height: 2_293_683,
+ block_time: 1_716_898_095,
+ delegation_count: 0,
+ deposit: '0',
+ fees: '173861',
+ hash: '35df1fdedc85bc84fab4d4aa112e6a3e6322d23c8ef1dd0401a5a6afeeb00f80',
+ index: 0,
+ invalid_before: null,
+ invalid_hereafter: '61222067',
+ mir_cert_count: 0,
+ output_amount: [
+ {
+ quantity: '2621219',
+ unit: 'lovelace',
+ },
+ ],
+ pool_retire_count: 0,
+ pool_update_count: 0,
+ redeemer_count: 0,
+ size: 415,
+ slot: 61_214_895,
+ stake_cert_count: 0,
+ utxo_count: 4,
+ valid_contract: true,
+ withdrawal_count: 0,
+ },
+ metadata: [
+ {
+ json_metadata: {
+ msg: 'Hey',
+ },
+ label: '674',
+ },
+ ],
+ utxos: {
+ hash: '35df1fdedc85bc84fab4d4aa112e6a3e6322d23c8ef1dd0401a5a6afeeb00f80',
+ inputs: [
+ {
+ address:
+ 'addr_test1qqmlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gn593a82g73tm9n0l6vehusxn3fxwwxhrssgmvnwnlaa6p4qdgdrwh',
+ amount: [
+ {
+ quantity: '1155080',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 0,
+ reference: false,
+ reference_script_hash: null,
+ tx_hash:
+ '3eafd8a177c0806c89835601fde8797d467ee199df83cbe07632781a5cc7d6d3',
+ },
+ {
+ address:
+ 'addr_test1qqmlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gn593a82g73tm9n0l6vehusxn3fxwwxhrssgmvnwnlaa6p4qdgdrwh',
+ amount: [
+ {
+ quantity: '1640000',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 0,
+ reference: false,
+ reference_script_hash: null,
+ tx_hash:
+ '49acbc6d2d30e7ba7a829d93ce0d32f26d8046765d6a1b50c8380b1595fb5c21',
+ },
+ ],
+ outputs: [
+ {
+ address:
+ 'addr_test1qqmlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gn593a82g73tm9n0l6vehusxn3fxwwxhrssgmvnwnlaa6p4qdgdrwh',
+ amount: [
+ {
+ quantity: '1000000',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 0,
+ reference_script_hash: null,
+ },
+ {
+ address:
+ 'addr_test1qqmlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gnkqvts2ek6wpe8jcjvmdyl7jkuqzycmjzjscs7d2d3rha7szwq6my',
+ amount: [
+ {
+ quantity: '1621219',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 1,
+ reference_script_hash: null,
+ },
+ ],
+ },
+ },
+ '3e0e7f8ae7732277ec14c87b58b7c6bbc641aa1b70bfe198f30d74733aeaf189': {
+ block: {
+ block_vrf:
+ 'vrf_vk17ktudgvcvmq9yydx0xfd0e0luc7h6h8eupsmwasyrzv0l4cn3qhsgcmq4r',
+ confirmations: 148_320,
+ epoch: 143,
+ epoch_slot: 49_651,
+ fees: '173729',
+ hash: '272b49a143df975fd588c77ccbabc08c4872b324905b5567b66c09d4cb589c89',
+ height: 2_248_114,
+ next_block:
+ 'eb693c753ec7a4d452868a8e72dc68bb80c712c36664ee22e0e6f5585ce66a4c',
+ op_cert:
+ 'b40fb753f7d498a38fa5b37463fce241366f5e05b573afb080f42338e52b91ca',
+ op_cert_counter: '7',
+ output: '20831931',
+ previous_block:
+ '6ce6739a0c719f2026a5d6170cea8c685d651a0d71f864f1c486fdf43b8920e7',
+ size: 416,
+ slot: 60_184_051,
+ slot_leader:
+ 'pool1e0arfuamnymdkmjztvkryasxv9d8u8key27ajgc4mquz2nr8mk9',
+ time: 1_715_867_251,
+ tx_count: 1,
+ },
+ info: {
+ asset_mint_or_burn_count: 0,
+ block:
+ '272b49a143df975fd588c77ccbabc08c4872b324905b5567b66c09d4cb589c89',
+ block_height: 2_248_114,
+ block_time: 1_715_867_251,
+ delegation_count: 0,
+ deposit: '0',
+ fees: '173729',
+ hash: '3e0e7f8ae7732277ec14c87b58b7c6bbc641aa1b70bfe198f30d74733aeaf189',
+ index: 0,
+ invalid_before: null,
+ invalid_hereafter: '60191164',
+ mir_cert_count: 0,
+ output_amount: [
+ {
+ quantity: '20831931',
+ unit: 'lovelace',
+ },
+ ],
+ pool_retire_count: 0,
+ pool_update_count: 0,
+ redeemer_count: 0,
+ size: 412,
+ slot: 60_184_051,
+ stake_cert_count: 0,
+ utxo_count: 4,
+ valid_contract: true,
+ withdrawal_count: 0,
+ },
+ metadata: [],
+ utxos: {
+ hash: '3e0e7f8ae7732277ec14c87b58b7c6bbc641aa1b70bfe198f30d74733aeaf189',
+ inputs: [
+ {
+ address:
+ 'addr_test1qpp6zag0q4hdt7th0gy4k0tjlv3s5xewt5443um8gp29schy5a403uhhz384ru5ppln88zjqvg7kjxtz8upmkl8rpqgq40rhq4',
+ amount: [
+ {
+ quantity: '18830011',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 1,
+ reference: false,
+ reference_script_hash: null,
+ tx_hash:
+ 'cd71358d9a22f4e80049dc3fba8b2326da3309af1cd3c82b5a9ffa214cef78eb',
+ },
+ {
+ address:
+ 'addr_test1qpp6zag0q4hdt7th0gy4k0tjlv3s5xewt5443um8gp29schy5a403uhhz384ru5ppln88zjqvg7kjxtz8upmkl8rpqgq40rhq4',
+ amount: [
+ {
+ quantity: '2175649',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 1,
+ reference: false,
+ reference_script_hash: null,
+ tx_hash:
+ 'f54b26026041d7c22558b308d32bbe764a7bc1645381ea5e131294480afbe31b',
+ },
+ ],
+ outputs: [
+ {
+ address:
+ 'addr_test1qqmlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gn593a82g73tm9n0l6vehusxn3fxwwxhrssgmvnwnlaa6p4qdgdrwh',
+ amount: [
+ {
+ quantity: '1180940',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 0,
+ reference_script_hash: null,
+ },
+ {
+ address:
+ 'addr_test1qpp6zag0q4hdt7th0gy4k0tjlv3s5xewt5443um8gp29schy5a403uhhz384ru5ppln88zjqvg7kjxtz8upmkl8rpqgq40rhq4',
+ amount: [
+ {
+ quantity: '19650991',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 1,
+ reference_script_hash: null,
+ },
+ ],
+ },
+ },
+ '3eafd8a177c0806c89835601fde8797d467ee199df83cbe07632781a5cc7d6d3': {
+ block: {
+ block_vrf:
+ 'vrf_vk1xn0x245vgc9rszy4wmk90glqfrrtr4v55tc4n975l6lrm935w56sddm59f',
+ confirmations: 148_317,
+ epoch: 143,
+ epoch_slot: 49_687,
+ fees: '173465',
+ hash: '5b4653e515d592318976e22e718d6408948f85c1b2a56f26fe751f696c1c677d',
+ height: 2_248_117,
+ next_block:
+ '04c583875f9821b9a66d10b619f535eafd0a4368438846faca6573d1ec2fa70f',
+ op_cert:
+ 'ed3a94b721049068abcd5e5d0fabf23404f5d0bc9d348a2181a5d3017ce65d54',
+ op_cert_counter: '6',
+ output: '12981615',
+ previous_block:
+ '3660eb4ef01bf9bdfb335567f0c716e71707b1509eacfd8c417d908c82f1ae91',
+ size: 410,
+ slot: 60_184_087,
+ slot_leader:
+ 'pool1egfg26w0syqly9qc65hz33gqv2qrzyka8tfue3ccsk3c73a56jp',
+ time: 1_715_867_287,
+ tx_count: 1,
+ },
+ info: {
+ asset_mint_or_burn_count: 0,
+ block:
+ '5b4653e515d592318976e22e718d6408948f85c1b2a56f26fe751f696c1c677d',
+ block_height: 2_248_117,
+ block_time: 1_715_867_287,
+ delegation_count: 0,
+ deposit: '0',
+ fees: '173465',
+ hash: '3eafd8a177c0806c89835601fde8797d467ee199df83cbe07632781a5cc7d6d3',
+ index: 0,
+ invalid_before: null,
+ invalid_hereafter: '60191251',
+ mir_cert_count: 0,
+ output_amount: [
+ {
+ quantity: '12981615',
+ unit: 'lovelace',
+ },
+ ],
+ pool_retire_count: 0,
+ pool_update_count: 0,
+ redeemer_count: 0,
+ size: 406,
+ slot: 60_184_087,
+ stake_cert_count: 0,
+ utxo_count: 4,
+ valid_contract: true,
+ withdrawal_count: 0,
+ },
+ metadata: [],
+ utxos: {
+ hash: '3eafd8a177c0806c89835601fde8797d467ee199df83cbe07632781a5cc7d6d3',
+ inputs: [
+ {
+ address:
+ 'addr_test1qpp6zag0q4hdt7th0gy4k0tjlv3s5xewt5443um8gp29schy5a403uhhz384ru5ppln88zjqvg7kjxtz8upmkl8rpqgq40rhq4',
+ amount: [
+ {
+ quantity: '12000000',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 0,
+ reference: false,
+ reference_script_hash: null,
+ tx_hash:
+ '87c3e3e91ca613e1b0687704112d6de386c1635d596cbc9bd21326a3f9a00589',
+ },
+ {
+ address:
+ 'addr_test1qpp6zag0q4hdt7th0gy4k0tjlv3s5xewt5443um8gp29schy5a403uhhz384ru5ppln88zjqvg7kjxtz8upmkl8rpqgq40rhq4',
+ amount: [
+ {
+ quantity: '1155080',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 0,
+ reference: false,
+ reference_script_hash: null,
+ tx_hash:
+ '928cc1453d0f956fca3b4359aad057e8e3872befc8ec92988bdc9f91c1e40271',
+ },
+ ],
+ outputs: [
+ {
+ address:
+ 'addr_test1qqmlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gn593a82g73tm9n0l6vehusxn3fxwwxhrssgmvnwnlaa6p4qdgdrwh',
+ amount: [
+ {
+ quantity: '1155080',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 0,
+ reference_script_hash: null,
+ },
+ {
+ address:
+ 'addr_test1qpp6zag0q4hdt7th0gy4k0tjlv3s5xewt5443um8gp29schy5a403uhhz384ru5ppln88zjqvg7kjxtz8upmkl8rpqgq40rhq4',
+ amount: [
+ {
+ quantity: '11826535',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 1,
+ reference_script_hash: null,
+ },
+ ],
+ },
+ },
+ c79f37caa73e2db87367c8ca9a802d4c50032f21c7057757659f39ba3b68a224: {
+ block: {
+ block_vrf:
+ 'vrf_vk1zqpmsxhsn39vk4fwp7an8llpys2nvreggtqwqg93rvyuhqhhchjq0gqduw',
+ confirmations: 98_318,
+ epoch: 145,
+ epoch_slot: 313_636,
+ fees: '1070472',
+ hash: '932f3f1422722ff85b96d5d74ae9b567f09302c18c12dc8c6c051563b896bc82',
+ height: 2_298_116,
+ next_block:
+ '5963f363e2dba7cf3a7cbd25219aaf449b9040e2d98f3ae16dac2ea99d6c1e44',
+ op_cert:
+ '4a75bb54a40c6f3267c88aa4dc791c15b8ddaae44765b9dfb397458895568dbd',
+ op_cert_counter: '5',
+ output: '20520857236',
+ previous_block:
+ 'e6594b265b304a781337fcd0a87202105082724773d3ccd7e4d2fd028a79fc1c',
+ size: 3584,
+ slot: 61_312_036,
+ slot_leader:
+ 'pool13m26ky08vz205232k20u8ft5nrg8u68klhn0xfsk9m4gsqsc44v',
+ time: 1_716_995_236,
+ tx_count: 3,
+ },
+ info: {
+ asset_mint_or_burn_count: 0,
+ block:
+ '932f3f1422722ff85b96d5d74ae9b567f09302c18c12dc8c6c051563b896bc82',
+ block_height: 2_298_116,
+ block_time: 1_716_995_236,
+ delegation_count: 0,
+ deposit: '0',
+ fees: '210161',
+ hash: 'c79f37caa73e2db87367c8ca9a802d4c50032f21c7057757659f39ba3b68a224',
+ index: 2,
+ invalid_before: null,
+ invalid_hereafter: '61319212',
+ mir_cert_count: 0,
+ output_amount: [
+ {
+ quantity: '19211824041',
+ unit: 'lovelace',
+ },
+ {
+ quantity: '2',
+ unit: '093e1dd222241dabb60ec25e98026d68ff45bd4e7c6a86bca0f59d3853505f714a61653270',
+ },
+ {
+ quantity: '1',
+ unit: '359289937f6cd0478f2c0737eed4ba879725c09d9d80caeeadf4a67f6261746d616e',
+ },
+ {
+ quantity: '1',
+ unit: '4298bc56195ebed886f2172eb0352a26611ce34f4482a3ee3cc0f39574657374',
+ },
+ {
+ quantity: '2',
+ unit: '666816b289a3c7a6427333703dc6cfd4b9c544f97bd70dfd913a778a53505f4d464b497961',
+ },
+ {
+ quantity: '2',
+ unit: '6736988a80b3e42c1940e48d5ab2de52c626acb22d21c13b5ff5c86253505f47516674726f',
+ },
+ {
+ quantity: '2',
+ unit: 'b2ab960cf45de24f65d7abe9ee6ac7ed03453a8953fe2421eba0d32553505f676471663775',
+ },
+ {
+ quantity: '8704538763',
+ unit: 'c82a4452eaebccb82aced501b3c94d3662cf6cd2915ad7148b459aec41584f',
+ },
+ {
+ quantity: '67280096',
+ unit: 'e16c2dc8ae937e8d3790c7fd7168d7b994621ba14ca11415f39fed724d494e',
+ },
+ {
+ quantity: '2',
+ unit: 'fa39bd793aed73c0a2d30451e616e298320cb8ada00987370d2dcd0453505f464556484e4a',
+ },
+ {
+ quantity: '2253633',
+ unit: 'fbaec8dd4d4405a4a42aec11ce5a0160c01e488f3918b082ccbab70544da9f788bef996b9adbefa7d3d9cbb616d7e8174a1ffaf320270db7bf561b05',
+ },
+ ],
+ pool_retire_count: 0,
+ pool_update_count: 0,
+ redeemer_count: 0,
+ size: 1240,
+ slot: 61_312_036,
+ stake_cert_count: 0,
+ utxo_count: 4,
+ valid_contract: true,
+ withdrawal_count: 0,
+ },
+ metadata: [],
+ utxos: {
+ hash: 'c79f37caa73e2db87367c8ca9a802d4c50032f21c7057757659f39ba3b68a224',
+ inputs: [
+ {
+ address:
+ 'addr_test1qqmlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gnkqvts2ek6wpe8jcjvmdyl7jkuqzycmjzjscs7d2d3rha7szwq6my',
+ amount: [
+ {
+ quantity: '1621219',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 1,
+ reference: false,
+ reference_script_hash: null,
+ tx_hash:
+ '35df1fdedc85bc84fab4d4aa112e6a3e6322d23c8ef1dd0401a5a6afeeb00f80',
+ },
+ {
+ address:
+ 'addr_test1qqmlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gn593a82g73tm9n0l6vehusxn3fxwwxhrssgmvnwnlaa6p4qdgdrwh',
+ amount: [
+ {
+ quantity: '19210412983',
+ unit: 'lovelace',
+ },
+ {
+ quantity: '2',
+ unit: '093e1dd222241dabb60ec25e98026d68ff45bd4e7c6a86bca0f59d3853505f714a61653270',
+ },
+ {
+ quantity: '1',
+ unit: '359289937f6cd0478f2c0737eed4ba879725c09d9d80caeeadf4a67f6261746d616e',
+ },
+ {
+ quantity: '1',
+ unit: '4298bc56195ebed886f2172eb0352a26611ce34f4482a3ee3cc0f39574657374',
+ },
+ {
+ quantity: '2',
+ unit: '666816b289a3c7a6427333703dc6cfd4b9c544f97bd70dfd913a778a53505f4d464b497961',
+ },
+ {
+ quantity: '2',
+ unit: '6736988a80b3e42c1940e48d5ab2de52c626acb22d21c13b5ff5c86253505f47516674726f',
+ },
+ {
+ quantity: '2',
+ unit: 'b2ab960cf45de24f65d7abe9ee6ac7ed03453a8953fe2421eba0d32553505f676471663775',
+ },
+ {
+ quantity: '8704538763',
+ unit: 'c82a4452eaebccb82aced501b3c94d3662cf6cd2915ad7148b459aec41584f',
+ },
+ {
+ quantity: '67280096',
+ unit: 'e16c2dc8ae937e8d3790c7fd7168d7b994621ba14ca11415f39fed724d494e',
+ },
+ {
+ quantity: '2',
+ unit: 'fa39bd793aed73c0a2d30451e616e298320cb8ada00987370d2dcd0453505f464556484e4a',
+ },
+ {
+ quantity: '2253633',
+ unit: 'fbaec8dd4d4405a4a42aec11ce5a0160c01e488f3918b082ccbab70544da9f788bef996b9adbefa7d3d9cbb616d7e8174a1ffaf320270db7bf561b05',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 0,
+ reference: false,
+ reference_script_hash: null,
+ tx_hash:
+ '8bf11536a35d9a2fa6a5189a926c53a5c9484c016fdc0ccd2363a8a661898115',
+ },
+ ],
+ outputs: [
+ {
+ address:
+ 'addr_test1qr6a0lr6atpagle72hx6g5c5v27hxn4k27qcwzv26r7ednvzj3tw59zn62kup4fwx2zhl454anu8wtalrusr77s4q4gsat87h2',
+ amount: [
+ {
+ quantity: '2000000000',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 0,
+ reference_script_hash: null,
+ },
+ {
+ address:
+ 'addr_test1qqmlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gnkqvts2ek6wpe8jcjvmdyl7jkuqzycmjzjscs7d2d3rha7szwq6my',
+ amount: [
+ {
+ quantity: '17211824041',
+ unit: 'lovelace',
+ },
+ {
+ quantity: '2',
+ unit: '093e1dd222241dabb60ec25e98026d68ff45bd4e7c6a86bca0f59d3853505f714a61653270',
+ },
+ {
+ quantity: '1',
+ unit: '359289937f6cd0478f2c0737eed4ba879725c09d9d80caeeadf4a67f6261746d616e',
+ },
+ {
+ quantity: '1',
+ unit: '4298bc56195ebed886f2172eb0352a26611ce34f4482a3ee3cc0f39574657374',
+ },
+ {
+ quantity: '2',
+ unit: '666816b289a3c7a6427333703dc6cfd4b9c544f97bd70dfd913a778a53505f4d464b497961',
+ },
+ {
+ quantity: '2',
+ unit: '6736988a80b3e42c1940e48d5ab2de52c626acb22d21c13b5ff5c86253505f47516674726f',
+ },
+ {
+ quantity: '2',
+ unit: 'b2ab960cf45de24f65d7abe9ee6ac7ed03453a8953fe2421eba0d32553505f676471663775',
+ },
+ {
+ quantity: '8704538763',
+ unit: 'c82a4452eaebccb82aced501b3c94d3662cf6cd2915ad7148b459aec41584f',
+ },
+ {
+ quantity: '67280096',
+ unit: 'e16c2dc8ae937e8d3790c7fd7168d7b994621ba14ca11415f39fed724d494e',
+ },
+ {
+ quantity: '2',
+ unit: 'fa39bd793aed73c0a2d30451e616e298320cb8ada00987370d2dcd0453505f464556484e4a',
+ },
+ {
+ quantity: '2253633',
+ unit: 'fbaec8dd4d4405a4a42aec11ce5a0160c01e488f3918b082ccbab70544da9f788bef996b9adbefa7d3d9cbb616d7e8174a1ffaf320270db7bf561b05',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 1,
+ reference_script_hash: null,
+ },
+ ],
+ },
+ },
+ '49acbc6d2d30e7ba7a829d93ce0d32f26d8046765d6a1b50c8380b1595fb5c21': {
+ block: {
+ block_vrf:
+ 'vrf_vk18xtl3n0yawku3xflkrnnez98qk8vpzdxxywvr7fs8z9ez98u8jvs99znjq',
+ confirmations: 169_687,
+ epoch: 141,
+ epoch_slot: 405_690,
+ fees: '1167560',
+ hash: '78cb8b75d0fe9ead9fae1d588f3f8d306f5d1d55579b98c0a7c8af6434ad25b4',
+ height: 2_226_747,
+ next_block:
+ '7dfa05e5405562d41e81774cdbfec1ef99428710b64d2442eec0efcf6315052d',
+ op_cert:
+ '75d4f6f7a12cd70d6aafdaa272ca27b5606c9a16a560e696cc7f1aa551491a9d',
+ op_cert_counter: '6',
+ output: '48773920',
+ previous_block:
+ 'f9541f5b0d316cb8d3f8c3cbf3888f3cd6a73369f2fedc61fa7398d02e14d427',
+ size: 11_760,
+ slot: 59_676_090,
+ slot_leader:
+ 'pool13la5erny3srx9u4fz9tujtl2490350f89r4w4qjhk0vdjmuv78v',
+ time: 1_715_359_290,
+ tx_count: 3,
+ },
+ info: {
+ asset_mint_or_burn_count: 0,
+ block:
+ '78cb8b75d0fe9ead9fae1d588f3f8d306f5d1d55579b98c0a7c8af6434ad25b4',
+ block_height: 2_226_747,
+ block_time: 1_715_359_290,
+ delegation_count: 0,
+ deposit: '0',
+ fees: '177557',
+ hash: '49acbc6d2d30e7ba7a829d93ce0d32f26d8046765d6a1b50c8380b1595fb5c21',
+ index: 2,
+ invalid_before: null,
+ invalid_hereafter: '59683243',
+ mir_cert_count: 0,
+ output_amount: [
+ {
+ quantity: '3199212',
+ unit: 'lovelace',
+ },
+ {
+ quantity: '10000',
+ unit: '25561d09e55d60b64525b9cdb3cfbec23c94c0634320fec2eaddde584c616365436f696e33',
+ },
+ {
+ quantity: '1',
+ unit: '5c677ba4dd295d9286e0e22786fea9ed735a6ae9c07e7a45ae4d95c84372696d696e616c50756e6b73204c6f6f74',
+ },
+ {
+ quantity: '2',
+ unit: 'f6f49b186751e61f1fb8c64e7504e771f968cea9f4d11f5222b169e374484f534b59',
+ },
+ ],
+ pool_retire_count: 0,
+ pool_update_count: 0,
+ redeemer_count: 0,
+ size: 499,
+ slot: 59_676_090,
+ stake_cert_count: 0,
+ utxo_count: 4,
+ valid_contract: true,
+ withdrawal_count: 0,
+ },
+ metadata: [],
+ utxos: {
+ hash: '49acbc6d2d30e7ba7a829d93ce0d32f26d8046765d6a1b50c8380b1595fb5c21',
+ inputs: [
+ {
+ address:
+ 'addr_test1qzp4yryr67cvx2q0ulzrzr8yxmvtl0vcmx6l2edtwppv4wc88gdwkrravwyeznhncgq6c3tzxu3ql4khjsenqu2juuqqxc6c3w',
+ amount: [
+ {
+ quantity: '2407019',
+ unit: 'lovelace',
+ },
+ {
+ quantity: '10000',
+ unit: '25561d09e55d60b64525b9cdb3cfbec23c94c0634320fec2eaddde584c616365436f696e33',
+ },
+ {
+ quantity: '1',
+ unit: '5c677ba4dd295d9286e0e22786fea9ed735a6ae9c07e7a45ae4d95c84372696d696e616c50756e6b73204c6f6f74',
+ },
+ {
+ quantity: '2',
+ unit: 'f6f49b186751e61f1fb8c64e7504e771f968cea9f4d11f5222b169e374484f534b59',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 1,
+ reference: false,
+ reference_script_hash: null,
+ tx_hash:
+ '1114419daf15ba0be827ae88e29bf0ae8101a66913e510f5716ca9117bc17f6e',
+ },
+ {
+ address:
+ 'addr_test1qzp4yryr67cvx2q0ulzrzr8yxmvtl0vcmx6l2edtwppv4wc88gdwkrravwyeznhncgq6c3tzxu3ql4khjsenqu2juuqqxc6c3w',
+ amount: [
+ {
+ quantity: '969750',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 0,
+ reference: false,
+ reference_script_hash: null,
+ tx_hash:
+ '2b98c1e09195aa75fda3cd3ada85fdc33947e9eab1f4532efe7e080604a9da7d',
+ },
+ ],
+ outputs: [
+ {
+ address:
+ 'addr_test1qqmlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gn593a82g73tm9n0l6vehusxn3fxwwxhrssgmvnwnlaa6p4qdgdrwh',
+ amount: [
+ {
+ quantity: '1640000',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 0,
+ reference_script_hash: null,
+ },
+ {
+ address:
+ 'addr_test1qzp4yryr67cvx2q0ulzrzr8yxmvtl0vcmx6l2edtwppv4wc88gdwkrravwyeznhncgq6c3tzxu3ql4khjsenqu2juuqqxc6c3w',
+ amount: [
+ {
+ quantity: '1559212',
+ unit: 'lovelace',
+ },
+ {
+ quantity: '10000',
+ unit: '25561d09e55d60b64525b9cdb3cfbec23c94c0634320fec2eaddde584c616365436f696e33',
+ },
+ {
+ quantity: '1',
+ unit: '5c677ba4dd295d9286e0e22786fea9ed735a6ae9c07e7a45ae4d95c84372696d696e616c50756e6b73204c6f6f74',
+ },
+ {
+ quantity: '2',
+ unit: 'f6f49b186751e61f1fb8c64e7504e771f968cea9f4d11f5222b169e374484f534b59',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 1,
+ reference_script_hash: null,
+ },
+ ],
+ },
+ },
+ },
+ },
+ preprod: {
+ assets: [
+ {
+ decimals: null,
+ has_nft_onchain_metadata: false,
+ quantity: '2',
+ unit: '093e1dd222241dabb60ec25e98026d68ff45bd4e7c6a86bca0f59d3853505f714a61653270',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: false,
+ quantity: '1',
+ unit: '359289937f6cd0478f2c0737eed4ba879725c09d9d80caeeadf4a67f6261746d616e',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: false,
+ quantity: '1',
+ unit: '4298bc56195ebed886f2172eb0352a26611ce34f4482a3ee3cc0f39574657374',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: false,
+ quantity: '2',
+ unit: '666816b289a3c7a6427333703dc6cfd4b9c544f97bd70dfd913a778a53505f4d464b497961',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: false,
+ quantity: '2',
+ unit: '6736988a80b3e42c1940e48d5ab2de52c626acb22d21c13b5ff5c86253505f47516674726f',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: false,
+ quantity: '2',
+ unit: 'b2ab960cf45de24f65d7abe9ee6ac7ed03453a8953fe2421eba0d32553505f676471663775',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: false,
+ quantity: '8704538763',
+ unit: 'c82a4452eaebccb82aced501b3c94d3662cf6cd2915ad7148b459aec41584f',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: false,
+ quantity: '67280096',
+ unit: 'e16c2dc8ae937e8d3790c7fd7168d7b994621ba14ca11415f39fed724d494e',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: false,
+ quantity: '2',
+ unit: 'fa39bd793aed73c0a2d30451e616e298320cb8ada00987370d2dcd0453505f464556484e4a',
+ },
+ {
+ decimals: null,
+ has_nft_onchain_metadata: false,
+ quantity: '2253633',
+ unit: 'fbaec8dd4d4405a4a42aec11ce5a0160c01e488f3918b082ccbab70544da9f788bef996b9adbefa7d3d9cbb616d7e8174a1ffaf320270db7bf561b05',
+ },
+ ],
+ history: {
+ confirmed: [
+ 'c79f37caa73e2db87367c8ca9a802d4c50032f21c7057757659f39ba3b68a224',
+ '35df1fdedc85bc84fab4d4aa112e6a3e6322d23c8ef1dd0401a5a6afeeb00f80',
+ '3eafd8a177c0806c89835601fde8797d467ee199df83cbe07632781a5cc7d6d3',
+ '3e0e7f8ae7732277ec14c87b58b7c6bbc641aa1b70bfe198f30d74733aeaf189',
+ '49acbc6d2d30e7ba7a829d93ce0d32f26d8046765d6a1b50c8380b1595fb5c21',
+ ],
+ details: {
+ '35df1fdedc85bc84fab4d4aa112e6a3e6322d23c8ef1dd0401a5a6afeeb00f80': {
+ block: {
+ block_vrf:
+ 'vrf_vk1sn75n6qtvl3shetj7cv4tly7jsphu48pf832vxadklvgd0tf3cks4mqvdf',
+ confirmations: 102_751,
+ epoch: 145,
+ epoch_slot: 216_495,
+ fees: '173861',
+ hash: '8db33ca759f111dba30a07b2cee0c6fcab1db613579c46c9ff8b31a3afcb28bc',
+ height: 2_293_683,
+ next_block:
+ '0f2ce5434f4b92140150da8d1dc1dd7c872ae27ba06b4d7d15674f07045a5bdd',
+ op_cert:
+ 'f2b4e41d64d8f502b16b31a51c60862fe3118b97452d924569ba45e91aff6d5e',
+ op_cert_counter: '2',
+ output: '2621219',
+ previous_block:
+ '022f736a0c92ab1508f01c7c68c76dc328fd0fcacb5eba81a9094ac4301ee395',
+ size: 419,
+ slot: 61_214_895,
+ slot_leader:
+ 'pool1pzdqdxrv0k74p4q33y98f2u7vzaz95et7mjeedjcfy0jcgk754f',
+ time: 1_716_898_095,
+ tx_count: 1,
+ },
+ info: {
+ asset_mint_or_burn_count: 0,
+ block:
+ '8db33ca759f111dba30a07b2cee0c6fcab1db613579c46c9ff8b31a3afcb28bc',
+ block_height: 2_293_683,
+ block_time: 1_716_898_095,
+ delegation_count: 0,
+ deposit: '0',
+ fees: '173861',
+ hash: '35df1fdedc85bc84fab4d4aa112e6a3e6322d23c8ef1dd0401a5a6afeeb00f80',
+ index: 0,
+ invalid_before: null,
+ invalid_hereafter: '61222067',
+ mir_cert_count: 0,
+ output_amount: [
+ {
+ quantity: '2621219',
+ unit: 'lovelace',
+ },
+ ],
+ pool_retire_count: 0,
+ pool_update_count: 0,
+ redeemer_count: 0,
+ size: 415,
+ slot: 61_214_895,
+ stake_cert_count: 0,
+ utxo_count: 4,
+ valid_contract: true,
+ withdrawal_count: 0,
+ },
+ metadata: [
+ {
+ json_metadata: {
+ msg: 'Hey',
+ },
+ label: '674',
+ },
+ ],
+ utxos: {
+ hash: '35df1fdedc85bc84fab4d4aa112e6a3e6322d23c8ef1dd0401a5a6afeeb00f80',
+ inputs: [
+ {
+ address:
+ 'addr_test1qqmlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gn593a82g73tm9n0l6vehusxn3fxwwxhrssgmvnwnlaa6p4qdgdrwh',
+ amount: [
+ {
+ quantity: '1155080',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 0,
+ reference: false,
+ reference_script_hash: null,
+ tx_hash:
+ '3eafd8a177c0806c89835601fde8797d467ee199df83cbe07632781a5cc7d6d3',
+ },
+ {
+ address:
+ 'addr_test1qqmlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gn593a82g73tm9n0l6vehusxn3fxwwxhrssgmvnwnlaa6p4qdgdrwh',
+ amount: [
+ {
+ quantity: '1640000',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 0,
+ reference: false,
+ reference_script_hash: null,
+ tx_hash:
+ '49acbc6d2d30e7ba7a829d93ce0d32f26d8046765d6a1b50c8380b1595fb5c21',
+ },
+ ],
+ outputs: [
+ {
+ address:
+ 'addr_test1qqmlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gn593a82g73tm9n0l6vehusxn3fxwwxhrssgmvnwnlaa6p4qdgdrwh',
+ amount: [
+ {
+ quantity: '1000000',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 0,
+ reference_script_hash: null,
+ },
+ {
+ address:
+ 'addr_test1qqmlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gnkqvts2ek6wpe8jcjvmdyl7jkuqzycmjzjscs7d2d3rha7szwq6my',
+ amount: [
+ {
+ quantity: '1621219',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 1,
+ reference_script_hash: null,
+ },
+ ],
+ },
+ },
+ '3e0e7f8ae7732277ec14c87b58b7c6bbc641aa1b70bfe198f30d74733aeaf189': {
+ block: {
+ block_vrf:
+ 'vrf_vk17ktudgvcvmq9yydx0xfd0e0luc7h6h8eupsmwasyrzv0l4cn3qhsgcmq4r',
+ confirmations: 148_320,
+ epoch: 143,
+ epoch_slot: 49_651,
+ fees: '173729',
+ hash: '272b49a143df975fd588c77ccbabc08c4872b324905b5567b66c09d4cb589c89',
+ height: 2_248_114,
+ next_block:
+ 'eb693c753ec7a4d452868a8e72dc68bb80c712c36664ee22e0e6f5585ce66a4c',
+ op_cert:
+ 'b40fb753f7d498a38fa5b37463fce241366f5e05b573afb080f42338e52b91ca',
+ op_cert_counter: '7',
+ output: '20831931',
+ previous_block:
+ '6ce6739a0c719f2026a5d6170cea8c685d651a0d71f864f1c486fdf43b8920e7',
+ size: 416,
+ slot: 60_184_051,
+ slot_leader:
+ 'pool1e0arfuamnymdkmjztvkryasxv9d8u8key27ajgc4mquz2nr8mk9',
+ time: 1_715_867_251,
+ tx_count: 1,
+ },
+ info: {
+ asset_mint_or_burn_count: 0,
+ block:
+ '272b49a143df975fd588c77ccbabc08c4872b324905b5567b66c09d4cb589c89',
+ block_height: 2_248_114,
+ block_time: 1_715_867_251,
+ delegation_count: 0,
+ deposit: '0',
+ fees: '173729',
+ hash: '3e0e7f8ae7732277ec14c87b58b7c6bbc641aa1b70bfe198f30d74733aeaf189',
+ index: 0,
+ invalid_before: null,
+ invalid_hereafter: '60191164',
+ mir_cert_count: 0,
+ output_amount: [
+ {
+ quantity: '20831931',
+ unit: 'lovelace',
+ },
+ ],
+ pool_retire_count: 0,
+ pool_update_count: 0,
+ redeemer_count: 0,
+ size: 412,
+ slot: 60_184_051,
+ stake_cert_count: 0,
+ utxo_count: 4,
+ valid_contract: true,
+ withdrawal_count: 0,
+ },
+ metadata: [],
+ utxos: {
+ hash: '3e0e7f8ae7732277ec14c87b58b7c6bbc641aa1b70bfe198f30d74733aeaf189',
+ inputs: [
+ {
+ address:
+ 'addr_test1qpp6zag0q4hdt7th0gy4k0tjlv3s5xewt5443um8gp29schy5a403uhhz384ru5ppln88zjqvg7kjxtz8upmkl8rpqgq40rhq4',
+ amount: [
+ {
+ quantity: '18830011',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 1,
+ reference: false,
+ reference_script_hash: null,
+ tx_hash:
+ 'cd71358d9a22f4e80049dc3fba8b2326da3309af1cd3c82b5a9ffa214cef78eb',
+ },
+ {
+ address:
+ 'addr_test1qpp6zag0q4hdt7th0gy4k0tjlv3s5xewt5443um8gp29schy5a403uhhz384ru5ppln88zjqvg7kjxtz8upmkl8rpqgq40rhq4',
+ amount: [
+ {
+ quantity: '2175649',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 1,
+ reference: false,
+ reference_script_hash: null,
+ tx_hash:
+ 'f54b26026041d7c22558b308d32bbe764a7bc1645381ea5e131294480afbe31b',
+ },
+ ],
+ outputs: [
+ {
+ address:
+ 'addr_test1qqmlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gn593a82g73tm9n0l6vehusxn3fxwwxhrssgmvnwnlaa6p4qdgdrwh',
+ amount: [
+ {
+ quantity: '1180940',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 0,
+ reference_script_hash: null,
+ },
+ {
+ address:
+ 'addr_test1qpp6zag0q4hdt7th0gy4k0tjlv3s5xewt5443um8gp29schy5a403uhhz384ru5ppln88zjqvg7kjxtz8upmkl8rpqgq40rhq4',
+ amount: [
+ {
+ quantity: '19650991',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 1,
+ reference_script_hash: null,
+ },
+ ],
+ },
+ },
+ '3eafd8a177c0806c89835601fde8797d467ee199df83cbe07632781a5cc7d6d3': {
+ block: {
+ block_vrf:
+ 'vrf_vk1xn0x245vgc9rszy4wmk90glqfrrtr4v55tc4n975l6lrm935w56sddm59f',
+ confirmations: 148_317,
+ epoch: 143,
+ epoch_slot: 49_687,
+ fees: '173465',
+ hash: '5b4653e515d592318976e22e718d6408948f85c1b2a56f26fe751f696c1c677d',
+ height: 2_248_117,
+ next_block:
+ '04c583875f9821b9a66d10b619f535eafd0a4368438846faca6573d1ec2fa70f',
+ op_cert:
+ 'ed3a94b721049068abcd5e5d0fabf23404f5d0bc9d348a2181a5d3017ce65d54',
+ op_cert_counter: '6',
+ output: '12981615',
+ previous_block:
+ '3660eb4ef01bf9bdfb335567f0c716e71707b1509eacfd8c417d908c82f1ae91',
+ size: 410,
+ slot: 60_184_087,
+ slot_leader:
+ 'pool1egfg26w0syqly9qc65hz33gqv2qrzyka8tfue3ccsk3c73a56jp',
+ time: 1_715_867_287,
+ tx_count: 1,
+ },
+ info: {
+ asset_mint_or_burn_count: 0,
+ block:
+ '5b4653e515d592318976e22e718d6408948f85c1b2a56f26fe751f696c1c677d',
+ block_height: 2_248_117,
+ block_time: 1_715_867_287,
+ delegation_count: 0,
+ deposit: '0',
+ fees: '173465',
+ hash: '3eafd8a177c0806c89835601fde8797d467ee199df83cbe07632781a5cc7d6d3',
+ index: 0,
+ invalid_before: null,
+ invalid_hereafter: '60191251',
+ mir_cert_count: 0,
+ output_amount: [
+ {
+ quantity: '12981615',
+ unit: 'lovelace',
+ },
+ ],
+ pool_retire_count: 0,
+ pool_update_count: 0,
+ redeemer_count: 0,
+ size: 406,
+ slot: 60_184_087,
+ stake_cert_count: 0,
+ utxo_count: 4,
+ valid_contract: true,
+ withdrawal_count: 0,
+ },
+ metadata: [],
+ utxos: {
+ hash: '3eafd8a177c0806c89835601fde8797d467ee199df83cbe07632781a5cc7d6d3',
+ inputs: [
+ {
+ address:
+ 'addr_test1qpp6zag0q4hdt7th0gy4k0tjlv3s5xewt5443um8gp29schy5a403uhhz384ru5ppln88zjqvg7kjxtz8upmkl8rpqgq40rhq4',
+ amount: [
+ {
+ quantity: '12000000',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 0,
+ reference: false,
+ reference_script_hash: null,
+ tx_hash:
+ '87c3e3e91ca613e1b0687704112d6de386c1635d596cbc9bd21326a3f9a00589',
+ },
+ {
+ address:
+ 'addr_test1qpp6zag0q4hdt7th0gy4k0tjlv3s5xewt5443um8gp29schy5a403uhhz384ru5ppln88zjqvg7kjxtz8upmkl8rpqgq40rhq4',
+ amount: [
+ {
+ quantity: '1155080',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 0,
+ reference: false,
+ reference_script_hash: null,
+ tx_hash:
+ '928cc1453d0f956fca3b4359aad057e8e3872befc8ec92988bdc9f91c1e40271',
+ },
+ ],
+ outputs: [
+ {
+ address:
+ 'addr_test1qqmlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gn593a82g73tm9n0l6vehusxn3fxwwxhrssgmvnwnlaa6p4qdgdrwh',
+ amount: [
+ {
+ quantity: '1155080',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 0,
+ reference_script_hash: null,
+ },
+ {
+ address:
+ 'addr_test1qpp6zag0q4hdt7th0gy4k0tjlv3s5xewt5443um8gp29schy5a403uhhz384ru5ppln88zjqvg7kjxtz8upmkl8rpqgq40rhq4',
+ amount: [
+ {
+ quantity: '11826535',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 1,
+ reference_script_hash: null,
+ },
+ ],
+ },
+ },
+ c79f37caa73e2db87367c8ca9a802d4c50032f21c7057757659f39ba3b68a224: {
+ block: {
+ block_vrf:
+ 'vrf_vk1zqpmsxhsn39vk4fwp7an8llpys2nvreggtqwqg93rvyuhqhhchjq0gqduw',
+ confirmations: 98_318,
+ epoch: 145,
+ epoch_slot: 313_636,
+ fees: '1070472',
+ hash: '932f3f1422722ff85b96d5d74ae9b567f09302c18c12dc8c6c051563b896bc82',
+ height: 2_298_116,
+ next_block:
+ '5963f363e2dba7cf3a7cbd25219aaf449b9040e2d98f3ae16dac2ea99d6c1e44',
+ op_cert:
+ '4a75bb54a40c6f3267c88aa4dc791c15b8ddaae44765b9dfb397458895568dbd',
+ op_cert_counter: '5',
+ output: '20520857236',
+ previous_block:
+ 'e6594b265b304a781337fcd0a87202105082724773d3ccd7e4d2fd028a79fc1c',
+ size: 3584,
+ slot: 61_312_036,
+ slot_leader:
+ 'pool13m26ky08vz205232k20u8ft5nrg8u68klhn0xfsk9m4gsqsc44v',
+ time: 1_716_995_236,
+ tx_count: 3,
+ },
+ info: {
+ asset_mint_or_burn_count: 0,
+ block:
+ '932f3f1422722ff85b96d5d74ae9b567f09302c18c12dc8c6c051563b896bc82',
+ block_height: 2_298_116,
+ block_time: 1_716_995_236,
+ delegation_count: 0,
+ deposit: '0',
+ fees: '210161',
+ hash: 'c79f37caa73e2db87367c8ca9a802d4c50032f21c7057757659f39ba3b68a224',
+ index: 2,
+ invalid_before: null,
+ invalid_hereafter: '61319212',
+ mir_cert_count: 0,
+ output_amount: [
+ {
+ quantity: '19211824041',
+ unit: 'lovelace',
+ },
+ {
+ quantity: '2',
+ unit: '093e1dd222241dabb60ec25e98026d68ff45bd4e7c6a86bca0f59d3853505f714a61653270',
+ },
+ {
+ quantity: '1',
+ unit: '359289937f6cd0478f2c0737eed4ba879725c09d9d80caeeadf4a67f6261746d616e',
+ },
+ {
+ quantity: '1',
+ unit: '4298bc56195ebed886f2172eb0352a26611ce34f4482a3ee3cc0f39574657374',
+ },
+ {
+ quantity: '2',
+ unit: '666816b289a3c7a6427333703dc6cfd4b9c544f97bd70dfd913a778a53505f4d464b497961',
+ },
+ {
+ quantity: '2',
+ unit: '6736988a80b3e42c1940e48d5ab2de52c626acb22d21c13b5ff5c86253505f47516674726f',
+ },
+ {
+ quantity: '2',
+ unit: 'b2ab960cf45de24f65d7abe9ee6ac7ed03453a8953fe2421eba0d32553505f676471663775',
+ },
+ {
+ quantity: '8704538763',
+ unit: 'c82a4452eaebccb82aced501b3c94d3662cf6cd2915ad7148b459aec41584f',
+ },
+ {
+ quantity: '67280096',
+ unit: 'e16c2dc8ae937e8d3790c7fd7168d7b994621ba14ca11415f39fed724d494e',
+ },
+ {
+ quantity: '2',
+ unit: 'fa39bd793aed73c0a2d30451e616e298320cb8ada00987370d2dcd0453505f464556484e4a',
+ },
+ {
+ quantity: '2253633',
+ unit: 'fbaec8dd4d4405a4a42aec11ce5a0160c01e488f3918b082ccbab70544da9f788bef996b9adbefa7d3d9cbb616d7e8174a1ffaf320270db7bf561b05',
+ },
+ ],
+ pool_retire_count: 0,
+ pool_update_count: 0,
+ redeemer_count: 0,
+ size: 1240,
+ slot: 61_312_036,
+ stake_cert_count: 0,
+ utxo_count: 4,
+ valid_contract: true,
+ withdrawal_count: 0,
+ },
+ metadata: [],
+ utxos: {
+ hash: 'c79f37caa73e2db87367c8ca9a802d4c50032f21c7057757659f39ba3b68a224',
+ inputs: [
+ {
+ address:
+ 'addr_test1qqmlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gnkqvts2ek6wpe8jcjvmdyl7jkuqzycmjzjscs7d2d3rha7szwq6my',
+ amount: [
+ {
+ quantity: '1621219',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 1,
+ reference: false,
+ reference_script_hash: null,
+ tx_hash:
+ '35df1fdedc85bc84fab4d4aa112e6a3e6322d23c8ef1dd0401a5a6afeeb00f80',
+ },
+ {
+ address:
+ 'addr_test1qqmlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gn593a82g73tm9n0l6vehusxn3fxwwxhrssgmvnwnlaa6p4qdgdrwh',
+ amount: [
+ {
+ quantity: '19210412983',
+ unit: 'lovelace',
+ },
+ {
+ quantity: '2',
+ unit: '093e1dd222241dabb60ec25e98026d68ff45bd4e7c6a86bca0f59d3853505f714a61653270',
+ },
+ {
+ quantity: '1',
+ unit: '359289937f6cd0478f2c0737eed4ba879725c09d9d80caeeadf4a67f6261746d616e',
+ },
+ {
+ quantity: '1',
+ unit: '4298bc56195ebed886f2172eb0352a26611ce34f4482a3ee3cc0f39574657374',
+ },
+ {
+ quantity: '2',
+ unit: '666816b289a3c7a6427333703dc6cfd4b9c544f97bd70dfd913a778a53505f4d464b497961',
+ },
+ {
+ quantity: '2',
+ unit: '6736988a80b3e42c1940e48d5ab2de52c626acb22d21c13b5ff5c86253505f47516674726f',
+ },
+ {
+ quantity: '2',
+ unit: 'b2ab960cf45de24f65d7abe9ee6ac7ed03453a8953fe2421eba0d32553505f676471663775',
+ },
+ {
+ quantity: '8704538763',
+ unit: 'c82a4452eaebccb82aced501b3c94d3662cf6cd2915ad7148b459aec41584f',
+ },
+ {
+ quantity: '67280096',
+ unit: 'e16c2dc8ae937e8d3790c7fd7168d7b994621ba14ca11415f39fed724d494e',
+ },
+ {
+ quantity: '2',
+ unit: 'fa39bd793aed73c0a2d30451e616e298320cb8ada00987370d2dcd0453505f464556484e4a',
+ },
+ {
+ quantity: '2253633',
+ unit: 'fbaec8dd4d4405a4a42aec11ce5a0160c01e488f3918b082ccbab70544da9f788bef996b9adbefa7d3d9cbb616d7e8174a1ffaf320270db7bf561b05',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 0,
+ reference: false,
+ reference_script_hash: null,
+ tx_hash:
+ '8bf11536a35d9a2fa6a5189a926c53a5c9484c016fdc0ccd2363a8a661898115',
+ },
+ ],
+ outputs: [
+ {
+ address:
+ 'addr_test1qr6a0lr6atpagle72hx6g5c5v27hxn4k27qcwzv26r7ednvzj3tw59zn62kup4fwx2zhl454anu8wtalrusr77s4q4gsat87h2',
+ amount: [
+ {
+ quantity: '2000000000',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 0,
+ reference_script_hash: null,
+ },
+ {
+ address:
+ 'addr_test1qqmlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gnkqvts2ek6wpe8jcjvmdyl7jkuqzycmjzjscs7d2d3rha7szwq6my',
+ amount: [
+ {
+ quantity: '17211824041',
+ unit: 'lovelace',
+ },
+ {
+ quantity: '2',
+ unit: '093e1dd222241dabb60ec25e98026d68ff45bd4e7c6a86bca0f59d3853505f714a61653270',
+ },
+ {
+ quantity: '1',
+ unit: '359289937f6cd0478f2c0737eed4ba879725c09d9d80caeeadf4a67f6261746d616e',
+ },
+ {
+ quantity: '1',
+ unit: '4298bc56195ebed886f2172eb0352a26611ce34f4482a3ee3cc0f39574657374',
+ },
+ {
+ quantity: '2',
+ unit: '666816b289a3c7a6427333703dc6cfd4b9c544f97bd70dfd913a778a53505f4d464b497961',
+ },
+ {
+ quantity: '2',
+ unit: '6736988a80b3e42c1940e48d5ab2de52c626acb22d21c13b5ff5c86253505f47516674726f',
+ },
+ {
+ quantity: '2',
+ unit: 'b2ab960cf45de24f65d7abe9ee6ac7ed03453a8953fe2421eba0d32553505f676471663775',
+ },
+ {
+ quantity: '8704538763',
+ unit: 'c82a4452eaebccb82aced501b3c94d3662cf6cd2915ad7148b459aec41584f',
+ },
+ {
+ quantity: '67280096',
+ unit: 'e16c2dc8ae937e8d3790c7fd7168d7b994621ba14ca11415f39fed724d494e',
+ },
+ {
+ quantity: '2',
+ unit: 'fa39bd793aed73c0a2d30451e616e298320cb8ada00987370d2dcd0453505f464556484e4a',
+ },
+ {
+ quantity: '2253633',
+ unit: 'fbaec8dd4d4405a4a42aec11ce5a0160c01e488f3918b082ccbab70544da9f788bef996b9adbefa7d3d9cbb616d7e8174a1ffaf320270db7bf561b05',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 1,
+ reference_script_hash: null,
+ },
+ ],
+ },
+ },
+ '49acbc6d2d30e7ba7a829d93ce0d32f26d8046765d6a1b50c8380b1595fb5c21': {
+ block: {
+ block_vrf:
+ 'vrf_vk18xtl3n0yawku3xflkrnnez98qk8vpzdxxywvr7fs8z9ez98u8jvs99znjq',
+ confirmations: 169_687,
+ epoch: 141,
+ epoch_slot: 405_690,
+ fees: '1167560',
+ hash: '78cb8b75d0fe9ead9fae1d588f3f8d306f5d1d55579b98c0a7c8af6434ad25b4',
+ height: 2_226_747,
+ next_block:
+ '7dfa05e5405562d41e81774cdbfec1ef99428710b64d2442eec0efcf6315052d',
+ op_cert:
+ '75d4f6f7a12cd70d6aafdaa272ca27b5606c9a16a560e696cc7f1aa551491a9d',
+ op_cert_counter: '6',
+ output: '48773920',
+ previous_block:
+ 'f9541f5b0d316cb8d3f8c3cbf3888f3cd6a73369f2fedc61fa7398d02e14d427',
+ size: 11_760,
+ slot: 59_676_090,
+ slot_leader:
+ 'pool13la5erny3srx9u4fz9tujtl2490350f89r4w4qjhk0vdjmuv78v',
+ time: 1_715_359_290,
+ tx_count: 3,
+ },
+ info: {
+ asset_mint_or_burn_count: 0,
+ block:
+ '78cb8b75d0fe9ead9fae1d588f3f8d306f5d1d55579b98c0a7c8af6434ad25b4',
+ block_height: 2_226_747,
+ block_time: 1_715_359_290,
+ delegation_count: 0,
+ deposit: '0',
+ fees: '177557',
+ hash: '49acbc6d2d30e7ba7a829d93ce0d32f26d8046765d6a1b50c8380b1595fb5c21',
+ index: 2,
+ invalid_before: null,
+ invalid_hereafter: '59683243',
+ mir_cert_count: 0,
+ output_amount: [
+ {
+ quantity: '3199212',
+ unit: 'lovelace',
+ },
+ {
+ quantity: '10000',
+ unit: '25561d09e55d60b64525b9cdb3cfbec23c94c0634320fec2eaddde584c616365436f696e33',
+ },
+ {
+ quantity: '1',
+ unit: '5c677ba4dd295d9286e0e22786fea9ed735a6ae9c07e7a45ae4d95c84372696d696e616c50756e6b73204c6f6f74',
+ },
+ {
+ quantity: '2',
+ unit: 'f6f49b186751e61f1fb8c64e7504e771f968cea9f4d11f5222b169e374484f534b59',
+ },
+ ],
+ pool_retire_count: 0,
+ pool_update_count: 0,
+ redeemer_count: 0,
+ size: 499,
+ slot: 59_676_090,
+ stake_cert_count: 0,
+ utxo_count: 4,
+ valid_contract: true,
+ withdrawal_count: 0,
+ },
+ metadata: [],
+ utxos: {
+ hash: '49acbc6d2d30e7ba7a829d93ce0d32f26d8046765d6a1b50c8380b1595fb5c21',
+ inputs: [
+ {
+ address:
+ 'addr_test1qzp4yryr67cvx2q0ulzrzr8yxmvtl0vcmx6l2edtwppv4wc88gdwkrravwyeznhncgq6c3tzxu3ql4khjsenqu2juuqqxc6c3w',
+ amount: [
+ {
+ quantity: '2407019',
+ unit: 'lovelace',
+ },
+ {
+ quantity: '10000',
+ unit: '25561d09e55d60b64525b9cdb3cfbec23c94c0634320fec2eaddde584c616365436f696e33',
+ },
+ {
+ quantity: '1',
+ unit: '5c677ba4dd295d9286e0e22786fea9ed735a6ae9c07e7a45ae4d95c84372696d696e616c50756e6b73204c6f6f74',
+ },
+ {
+ quantity: '2',
+ unit: 'f6f49b186751e61f1fb8c64e7504e771f968cea9f4d11f5222b169e374484f534b59',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 1,
+ reference: false,
+ reference_script_hash: null,
+ tx_hash:
+ '1114419daf15ba0be827ae88e29bf0ae8101a66913e510f5716ca9117bc17f6e',
+ },
+ {
+ address:
+ 'addr_test1qzp4yryr67cvx2q0ulzrzr8yxmvtl0vcmx6l2edtwppv4wc88gdwkrravwyeznhncgq6c3tzxu3ql4khjsenqu2juuqqxc6c3w',
+ amount: [
+ {
+ quantity: '969750',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 0,
+ reference: false,
+ reference_script_hash: null,
+ tx_hash:
+ '2b98c1e09195aa75fda3cd3ada85fdc33947e9eab1f4532efe7e080604a9da7d',
+ },
+ ],
+ outputs: [
+ {
+ address:
+ 'addr_test1qqmlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gn593a82g73tm9n0l6vehusxn3fxwwxhrssgmvnwnlaa6p4qdgdrwh',
+ amount: [
+ {
+ quantity: '1640000',
+ unit: 'lovelace',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 0,
+ reference_script_hash: null,
+ },
+ {
+ address:
+ 'addr_test1qzp4yryr67cvx2q0ulzrzr8yxmvtl0vcmx6l2edtwppv4wc88gdwkrravwyeznhncgq6c3tzxu3ql4khjsenqu2juuqqxc6c3w',
+ amount: [
+ {
+ quantity: '1559212',
+ unit: 'lovelace',
+ },
+ {
+ quantity: '10000',
+ unit: '25561d09e55d60b64525b9cdb3cfbec23c94c0634320fec2eaddde584c616365436f696e33',
+ },
+ {
+ quantity: '1',
+ unit: '5c677ba4dd295d9286e0e22786fea9ed735a6ae9c07e7a45ae4d95c84372696d696e616c50756e6b73204c6f6f74',
+ },
+ {
+ quantity: '2',
+ unit: 'f6f49b186751e61f1fb8c64e7504e771f968cea9f4d11f5222b169e374484f534b59',
+ },
+ ],
+ collateral: false,
+ data_hash: null,
+ inline_datum: null,
+ output_index: 1,
+ reference_script_hash: null,
+ },
+ ],
+ },
+ },
+ },
+ },
+ lastUpdate:
+ 'c79f37caa73e2db87367c8ca9a802d4c50032f21c7057757659f39ba3b68a224',
+ lovelace: '17214004981',
+ minAda: '4913400',
+ paymentAddr:
+ 'addr_test1qqmlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gn593a82g73tm9n0l6vehusxn3fxwwxhrssgmvnwnlaa6p4qdgdrwh',
+ rewardAddr:
+ 'stake_test1uzzc7n4y0g4ajehlaxvm7grfc5n88rt3cgydkfhfl77aq6ss5kauz',
+ },
+ publicKey:
+ 'a5f18f73dde7b6f11df448913d60a86bbb397a435269e5024193b293f28892fd33d1225d468aac8f5a9d3cfedceacabe80192fcf0beb5c5c9b7988151f3353cc',
+ stakeKeyHash: '858f4ea47a2bd966ffe999bf2069c526738d71c208db26e9ffbdd06a',
+ paymentAddr:
+ 'addr_test1qqmlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gn593a82g73tm9n0l6vehusxn3fxwwxhrssgmvnwnlaa6p4qdgdrwh',
+ lovelace: '12737296152',
+ minAda: '3521270',
+};
diff --git a/packages/nami/src/mocks/history.mock.ts b/packages/nami/src/mocks/history.mock.ts
new file mode 100644
index 000000000..ee6b6d96a
--- /dev/null
+++ b/packages/nami/src/mocks/history.mock.ts
@@ -0,0 +1,75 @@
+export const transactions = [
+ {
+ txHash: 'b71054ebc4900dd2aea06826482a8ecdbe78f605ceb1175011a24cfa7c189710',
+ txIndex: 3,
+ blockHeight: 2_297_036,
+ },
+ {
+ txHash: 'b71054ebc4900dd2aea06826482a8ecdbe78f605ceb1175011a24cfa7c189710',
+ txIndex: 3,
+ blockHeight: 2_297_036,
+ },
+ {
+ txHash: 'a52e02b8acbc6d149c0f8a3906300ea984b5a2f3db24ba6ac06e28832653e5d8',
+ txIndex: 2,
+ blockHeight: 2_294_178,
+ },
+ {
+ txHash: 'a52e02b8acbc6d149c0f8a3906300ea984b5a2f3db24ba6ac06e28832653e5d8',
+ txIndex: 2,
+ blockHeight: 2_294_178,
+ },
+ {
+ txHash: '24999c2c21244287d023a42fc09b7a22861ba9b4c4fa6316e16a34006dfc21df',
+ txIndex: 0,
+ blockHeight: 2_294_177,
+ },
+ {
+ txHash: '24999c2c21244287d023a42fc09b7a22861ba9b4c4fa6316e16a34006dfc21df',
+ txIndex: 0,
+ blockHeight: 2_294_177,
+ },
+ {
+ txHash: 'f54a56f84e1f11169020475c271dac4d017e254abb513cabae9412a8a3979656',
+ txIndex: 0,
+ blockHeight: 2_270_926,
+ },
+ {
+ txHash: 'f54a56f84e1f11169020475c271dac4d017e254abb513cabae9412a8a3979656',
+ txIndex: 0,
+ blockHeight: 2_270_926,
+ },
+];
+
+export const transactions2 = [
+ {
+ txHash: 'c79f37caa73e2db87367c8ca9a802d4c50032f21c7057757659f39ba3b68a224',
+ txIndex: 2,
+ blockHeight: 2_298_116,
+ blockTime: 1_716_995_236,
+ },
+ {
+ txHash: '35df1fdedc85bc84fab4d4aa112e6a3e6322d23c8ef1dd0401a5a6afeeb00f80',
+ txIndex: 0,
+ blockHeight: 2_293_683,
+ blockTime: 1_716_898_095,
+ },
+ {
+ txHash: '3eafd8a177c0806c89835601fde8797d467ee199df83cbe07632781a5cc7d6d3',
+ txIndex: 0,
+ blockHeight: 2_248_117,
+ blockTime: 1_715_867_287,
+ },
+ {
+ txHash: '3e0e7f8ae7732277ec14c87b58b7c6bbc641aa1b70bfe198f30d74733aeaf189',
+ txIndex: 0,
+ blockHeight: 2_248_114,
+ blockTime: 1_715_867_251,
+ },
+ {
+ txHash: '49acbc6d2d30e7ba7a829d93ce0d32f26d8046765d6a1b50c8380b1595fb5c21',
+ txIndex: 2,
+ blockHeight: 2_226_747,
+ blockTime: 1_715_359_290,
+ },
+];
diff --git a/packages/nami/src/mocks/network.mock.ts b/packages/nami/src/mocks/network.mock.ts
new file mode 100644
index 000000000..451385d41
--- /dev/null
+++ b/packages/nami/src/mocks/network.mock.ts
@@ -0,0 +1,4 @@
+export const network = {
+ id: 'preprod',
+ node: 'https://cardano-preprod.blockfrost.io/api/v0',
+};
diff --git a/packages/nami/src/mocks/store.mock.ts b/packages/nami/src/mocks/store.mock.ts
new file mode 100644
index 000000000..08e668b7b
--- /dev/null
+++ b/packages/nami/src/mocks/store.mock.ts
@@ -0,0 +1,26 @@
+/* eslint-disable unicorn/no-null */
+import { network } from './network.mock';
+
+export const settings = {
+ adaSymbol: 't₳',
+ currency: 'usd',
+ network,
+};
+
+export const store = {
+ settings: { settings },
+ globalModel: {
+ sendStore: {
+ fee: { fee: '0' },
+ value: { ada: '', assets: [], personalAda: '', minAda: '0' },
+ address: { result: '', display: '' },
+ message: '',
+ tx: null,
+ txInfo: {
+ protocolParameters: null,
+ utxos: [],
+ balance: { lovelace: '0', assets: null },
+ },
+ },
+ },
+};
diff --git a/packages/nami/src/mocks/token.mock.ts b/packages/nami/src/mocks/token.mock.ts
new file mode 100644
index 000000000..c2ae9a75f
--- /dev/null
+++ b/packages/nami/src/mocks/token.mock.ts
@@ -0,0 +1,228 @@
+export const tokens = {
+ '212a16adbc2aec5cab350fc8e8a32defae6d766f7a774142d5ae995f54657374546f6b656e':
+ {
+ decimals: 0,
+ quantity: 9,
+ displayName: 'TestToken',
+ fingerprint: 'asset16cee8gr79j5k4ag5v8wlk5ygg5fjyech5ugykj',
+ image: '',
+ name: 'TestToken',
+ policy: '212a16adbc2aec5cab350fc8e8a32defae6d766f7a774142d5ae995f',
+ time: 1_718_016_433_943,
+ unit: '212a16adbc2aec5cab350fc8e8a32defae6d766f7a774142d5ae995f54657374546f6b656e',
+ },
+ '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff198444149': {
+ decimals: 6,
+ displayName: 'DAI',
+ fingerprint: 'asset1vdkz0fx34r9km5xf4l5jk3emyysfamw5xr3yc2',
+ image:
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAADAFBMVEVMaXG/tSPymjH5uzj/vzH6x1P//0r/8RX7jxb4qxP/AAD5rBT5rhr7w0r4rRf6xU/5uDD5tiv6sB/5wUX5siT8y1X7xlD5sxz5tCf6vTz5rhr/tkj5rRr8xkr7sRv7tR/5v0L7wkT5sRz1v072qRH6rx/6rRL5vz/5wUP7yFP9zFb6rRH5uTP7yVf8y1b6vTz8yE/5sBv6sBv8sxf8xUz6sBr7w0n6xk30qxT9zFL6wUb5vEL6sBb8y1P8xUb8y1L5wE76tCP6sSL8tiP5wUf3v0L7rhT6tCT5v0H8sBT2wVf5vkD6tCX7rhL5tzD8zVb6qxD/rxP5qxH5uzb5sib7rRL7rhL7wD77tCf/wyz6tSr7tSn5uzv5tiz5ujj5ty76vj35ti7/zVf8ylf/zFr8ylf////5rxv6v0H7xU76wkj//v77vj35rRb7tCb6sR/7w0v6sB77w0n6sB37xEz//vv7xE36uDD6siL6tSr5rBT6ty75rhr6wkf7wkT6sSD6v0D5rhj6uTL6wUX6siH6syT6tiv8xlD6ujT6tSn6vTr6tiz7uzX6wEP6wET6syX7uTL6vj/6vD36tCj7uzb7xlD6vDn6uDH6vDr//fn6uTD7x1L82Iz7y2X7xFL6tir6siD6uzj6sSH6sBv6uC37yV78zW////77x1P//vz7uzv//fv6tST//Pj/1FP/vR79yVb/1ln6syH8y2f6qxH8y2P+yVP8tyz7x1j/xT37vUH7xE/7wk37ymH7rRL7yFT81IL7yFv/z0r/wCb/uRX6rxj7xVT7zWr7wUn7rhX6sR3/viz6v0P70Xb82pH81ob93Zn7v0T/+vD/+ev+8db/0VH/uhn/zUX8z3L/tBP/sxL+6b3/wDn/wDD/0Vn6vTz//Pf95K794ab6uzf+7s3/thj/uiL8sBf+xUP9ujL/shX+uij9vTf/vTH/rxL/zVb803z/yEP/shT/zVj82pL+9uX+68T936H+9eH/z1j/0lr+9eT80Xr95LD7xlf+7MlcjeWRAAAAf3RSTlMABAb9BP0CAQL9Afv7+v78/fz7/fzuZSz9/JUHZ/rJ+vvxjRocGO39K5WPk9362pSwubD2sLCVuTH2cBdyxv1yMe9w/cuN7/J8fB3dfNjwfNxDvvDd0mJBQf7UkmJiv5XUv0O+LdL////////////////////////////////+h/Oq2AAABlRJREFUeNqU0gGHMkEYwPFnx2hmd8ZiYVlrl2RhQSVC4EWEqKiv8IJzckCO3Fe5LzAQgAUT+gJLAVARQp/gnhtHV7td9cMY4/k/YKCIUzyoP0lHg8ReLOxkMEonvnnkcB/DSTJMx2Kz3hyzBcqOeBXjdEhwBYM7MK/1+mt9yuxvM2Qu2Umv+72aGfhDBfNqVx9sge0FG58OulutmaFbLOCdRK+EmJUSYqWTDsexcg6FdpTvhftykyv2edQG6kAJxiFsbgt5YcW2GQJnUMDBCfKl/LhLLvPAAV7sSUtJ9/0BrlQtcr2BcVJX8v+DpKoTzi56yhoP92ZDA5OL7xMo+fYEqQKgv/tQedOneCo8b6iAH8fT16dM49jH0HCoFe28+dnnlXkZbxdZPx/Kgn9fjNUDkCNbFAbgU9tZFGeSWqW0tsrGs601nm6t3YPqRSqqjnc83RWOukaF5GmQsW3PBM+2eYPtvkmevvKp//xJGjftCq0k63Yp6drtLJsuS5tC0X4fXsVWrdx27/0lWRJtXhWJzStt6r2NO5KU3H/vtpWroldgc/tqneRiaQ/HcbXB2qhgcOzXP0feNPVeytMlWd2+GS/DKtj28MXVNkleaSNK8f2Ip9dm09lIqy8+vA2vy+CJ9tUXCYa+RkEQOBHCBITGWvqyDLaLpNXtT+D1lcte+HR9HgEXoEo0mD9aH9Pot/9QiWiE5hmtIY+0/tMXlq0EONRP7GPZlyIF/l5bVoy7tPNSTxghDk0U6bITG/oPAcDRw+sNJL27ARf43Iw+zmTSZ3X+8StueO+iIZuMrj98FEB2pF+eTTJpnZGCPhM5Y/o8E7jhx1Jpisn7j8hgnfxtPcHEZNoiBd3eIoakttWPI1ro0mUyjElMvy1fB8f75SYJY3DrOntwQXmTzp1A19RaXY3mO71arxSX9x+Hk4tyRuIa7e5+cxidQyO+mYokvp+QQP/m6HZI302+eBJeXkyX9ovcLeg/cVWmzHg+ffFl2Pt5eqZI7f2K5uhKnKqkU1UK0fl4LlMUz6d/vhee+ji9SMR6/8c3COKCuPSPn4L0O2pC5qjDUd6Cr8F3rT5HEl/rPEK1M34/y4v5O+mgTsBmukze2HPAuJIwpUOokhvVmdQkYBOpebXhj8hzUMPySVjvd7igx8WzJKDMrORddY7T2TODC1p6upxJukZ/xxfA39jwrrRgpuD5z6h3RcWulkquMnoXUkXnAl05NsB64nnqs+dh7wLlERXr/60ARefRgjhqYS+cqKMKRQH9MPpPlZp3i+N5qu4EHKijikVGtnVoyB7GtzE0bE82/D0SuBH7kD2n8G6eqjsAx+oooyTwbhlf0xV9G/myBLzXz9FovgaPpThVdwx2Ks8HSDmn3/VHjjT2dA5poPAyfp0ry9/FYyl8XrkTZKfqlOdJKnO0QK0iZjmn1ZrogcJLU0xZd0oGcGBamUNSJX0D1enTqkLe8VPkSCvOSaCcPgAAB7/eoSJZCt/EBTN8wBhTaFa/2xo6FzlUrYHTKtKOrw8CPtYf/2jHaUKsYLix9Z2oVv/gyHuVeIJ+sAYsKjK546PH8bEug0emN5Jja/GbSEA0F0ff/WP5zphjPZ1g4/QjeH0VbNl0a8Ai0RgdKMV4uOtdq9WSYODWpi14HVbAg20bNVaRJuDAzystCo5PhL66/K7qJs6QNBvbHvyr8jrwUBiKAzi+cmsTz8NsMEOCNjYhpVKFQND/UMXdwMw5YBxDHAHu4Ag4Djx7MBGi/2EA+xdCoAP3q+vu1qxaH/Dwfg+D/b4wvP+9NwttwY2wP6IW1vPby8x044R2ocnxPwtGYy047h/HvY+auY5pOm6cI6wbTOa4I2Z68MI/M8ZJAPO9zO+umGNKimLeSFFKhy9wfEEKkH0TFEjRcLhjqgRZtpWSbSFSPWkXjucqBD2mhkiFiy3LvFwj6CklRGpyPBl4Ri4TvHxIYYlJWU4KhrxKpvj6PJ4SFYIjMXmkeoDx60UYB3UpKXlAPsd0W9TH8/Pjc+zTVpfJ5c9nX0ekPsu+J2JZn4odyL6L4VnURLphwecJFmyoqBXh0rV27Q+MkO48dm8FDgdvR0Nj0L82Dji4kR1qEz38Creet1p53haO+kQbZmE8Ob4T8p8fS+rIEPXFQheNkSqN+TP5/w2munmw3l4qPAAAAABJRU5ErkJggg==',
+ name: 'DAI',
+ policy: '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff198',
+ time: 1_718_016_433_866,
+ unit: '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff198444149',
+ },
+ '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff198446a6564': {
+ decimals: 0,
+ displayName: 'Djed',
+ fingerprint: 'asset1spcamsngdptfa0nr2r48e8720ry4k8mt6me5e4',
+ image: '',
+ name: 'Djed',
+ policy: '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff198',
+ time: 1_718_016_433_894,
+ unit: '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff198446a6564',
+ },
+ '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff19855534443': {
+ decimals: 6,
+ displayName: 'USDC',
+ fingerprint: 'asset1qketn3dc3hq5eudhpfrfnet9f7uk3ffpkt3vn5',
+ image:
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAAM1BMVEUAAAAgcM8oeMcndckndcondco1fs1ChtFQj9RdmNdroNt4qd6TuuW81O/X5vXl7vn///9JjkDLAAAABXRSTlMAECDg8En8Fj4AAAFASURBVHjavZdVgsMwEEMnUG6Tuf9ll1l5Xjmkn4BHz0zB6g5f6qNWhwkttPuIjuxmXQ7/yLazXD+L7bbY78rx335+nI9EAH++CL6B8NcuAEEw4JEEyAEBGvvlx79l/0FUJBQri2mO/9cXEjqIEAAR2H/g7mTAgQES0oBfAEhQgMSWa1UqQP4UF4ELkExQABXg+/WiKQLQfIykiG466mICDl8FuP6OumsH/gI8BIC9BQQLkLkUABQGACHTBxDCBvzWWQgGQDTOA0hdlgAeJUA/SfC/Ioyg5LRAgDeZ/PWA2Lwine0VKbCz2J+VqzKv9PGm+ftCu3Rninf1RFAAbM9zd+cIJqg0teETRrJdKgCEBHtWHdP038k5Jx4YUHVUVUDtYRk/+bi+34Vh8ZWHtdRvIJqIRYRw1JC7XXj5rpVnfgbXQl1Nf7Ug6wAAAABJRU5ErkJggg==',
+ name: 'USDC',
+ policy: '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff198',
+ time: 1_718_016_433_945,
+ unit: '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff19855534443',
+ },
+ '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff19855534454': {
+ decimals: 6,
+ displayName: 'USDT',
+ fingerprint: 'asset1tnlqa0d3qqjrpsx3h9vjq9e3x6yurq7w7pwl2d',
+ image:
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIUAAACFCAYAAAB12js8AAATvElEQVR42u1dBXgbx9ZdNw+dB2Xux/SYf2aGPFtayY5TbpOUmZlDJcdaSQ4bCnFkreUwMzMzNMxp0Oxk/nPGY/9q6pAlr2ju+84nV0/Znblz9t47987MGqkqrkiJkRv5zPBEAoanKmCYVX7DA+SWfWCYkUA2vrsV//0XZlWgAJ8vARZgA3OAdcBe4DjQAIgoNKjv96rf8fchwAe8jOvme2z/n/D3Tbh3V9fMPvK+pkRAIrfqc9k+LY4QYYRh2kM5CCBDELAM1+RyDsSPMSi/wGd3/H/vqUFcC+wHTgJNgIgDmtX1eN3VQJj3M0EUdf+fuMLFWZ4qC20KkrBoc7FEHEWLFwN/Z3V/kqCNCN4qfxf8fYsn4s9RJJgJ7AHqAJEA1PH+qh0DgBwTlsqMBLt4FUHcsCD5IwsNT9gyOihaMOBG7hflsArF0jJ4xwWz8OTdgr9zgYB6Us8AIgnBdq0B/EAuCZJbFciSbg5wjR5swAUZlylaqDgSwgt4qojgD/D5N0B/RYQaQKQQaoAVQB/gr0CKbPaP/SRM4BKiCWHaARKCxLgayIMiI8ARQKQBDgE2gH4FrlexhyZGe2JGpHUgSIobobCeyj9Lq5CGqAXmsp8gxQ0eNXNx4zOjhVM2b1WhMqMkhHU1Pu8H5gGNgMgANLK/0eRQD0jmzVjyIgGCRCApGDO4gMntzx4yhhzToYtu7kgwmzkPd6ZMZUmE/6xAzADrMEbMpYX4PVAGnJTK0TgFYpTAlfzuwVB/6VLybF/akgNECIIUMqjijOI6KOB5YLsmQrvYiSnrM4ivrsYnrAbzHMH0ih262/1l3PA/Fe/y8y+AMZfMMmo0AhGQ4i9eHvqkTKPnzRhsuEoKjZQWTi9d9mBYCYtk6Ao8qq3DFWMn9WbaVranGlZ2FJCqVsMTYWKmGC7DzxjiDnQs2OEppsYZIGhSj2Gf4akEMexAigWUMn4AKgtb3cWMuChHYwbdiWcCY7MUijNYwOLs4u7qoVchkvagIxvjqhiNjXDLnv8cW5ols7+VVpITwiYhLOYfvovG9wYOdopiNA4CD3or/d8lKYikJEMUGFC+oXMPnY6TwOsgRFfAUEhKUmSramC9I4rRqEeM0ccbsrIVKTQhNJKQGJ5wUC4WocvQhEg8MaJdScKmnV47SGJ8H416RxMi8cQAXmfwyVkJU+OOJ6aYh+g5uoRLzHqhMaeTRDE6+LT9vXPsoVeZdtC5BBdYyEylSkwFPHramXQ4yDwGE1yeSgdS4ixusZbB1DUzlToxlbTYKDOfYR9rJZ1bRGO1k8kp1jJ06jrpMUPWSqoDrK52XmCJGxFdgaBWekogaGJm2Lp7La5SECmWgeUfy54kKR7V1c6UQQ3wSI/Qp5IUeaE4EqOb7TPINm9LHLFTKzulsAX4k4k48Dc7+scpjogUM4YguNo6opWckqg2bf/VQOz5C7qNfFzkHyr60m08k7JL7zUagKcLKj8hKWKbjbjk1JMI/N4Jt2ECbtvKOJhVlhPE2Ab8zsMxHReIZcGMzFxmc9m5E4R4eGKZ+HTJZFGYYXhscrkwnbEYpe6w9QPMSDpOCGUlunE/Qmc3mE/Mh4smiKazzSKTpPnsWWEtm8b+O7UGw0VrYVZ3wFqoHdHcyja9ExupSXHOUVIQk722n/tJrpwQykr0ZHCZVqTQpKjDuN7fsuM9cEXHARA3qs2+Iq1IoUlBzOOGZuCyiKF2gEdZiXQkhSZFY8v4Wpc8UYdkIHOI63l+AiDSkhSaFMRMEOJqb9h/cWLkRwbTUhB5QG1ak0KTogbIY/ni34NFF1pvaTEnQWSrI3hEWpNCk4KImCH/D9wVF1jX6R5Z2HpQKQ8dO5wRpNCkOALXwfE27vyyqL31EgEUv4JZalW2yAhSaFIQ/bwln2Z5Rvm+XfhiJVQdXbwyo0ihSbEC1uIWwHCVDvwmKcyWMyRygZqMIoUmRQ2Qy2V7rvKi8wpfo4Nd1AmxIqNIoUlBBNwhfxcz7G8tjxfSdRB0HWsSS4qJ4uy5cyKThL31L5+eaFKsbnMhXGuRU/VRa60jJ5FnXZtQyiszK8WifdvFsgNfiaVxBK93uOaUiEWO1p7GdXbGvV2L9+8Qb8+tZv8TfVJODl1IbvHHLWltd6UvS50+LxIJL4CVXnFH9+piMfWr9SIWmb9nqyjAdTqjfbDS7H9iYfvfc30KA1FpyQIY8RN17LFIR+RFAmLKjnUiFpm3ZwsHkNdLV8w0w9aPYbGkpSB+CezJaFJoUuyBtfgF0LZ2ogCoz2hSaFLUoQ6Sz6Ko4SorIyneS4FGa1I4EFfkbi+TxwpwG2BYk0KTAgjxpXzc+dWan9Ck0KRYi2DzVrgOi1sB92tSaFKQByaOMGCgmZ8uxxuaRNQmG5ftk+Dfk3esFbHI3N2bef3oa0qYRBqdhIO+FDDIfBloTo1BbxvstrQw6jbyCb5z9CDxwPjh4vHJn4kXZ4wSb86pEu/PHyNT576lU8X6I/tELLL52AFRhOsMWDhBvDdvtHhjti2emz5SbuR5EPdViS22h+2KbifbnSqkaAJeIil8ydhApVQ+icx0UunYQVYqXpoREh/MHyuKV8wQozYsEZO2rxWLkRrfcHSf2Pr1IYWDchA3EUcPiI1H94uv686IWOR4XY3YAGKRXLzelmMHgINiC+61Dffk90zR0yKN3LBIBFDPIClfAkEfQrt7gLSS2NGkTs4ZiEVShJKFBARN8V1jBounp34h+iwYK0rXzJMp6tWHdmOg5WCDAPvlf8+BSY9sXi5/M3DJFDkIr82yxfPTK8STUz4Xj0wqEz3HjxD3jRsmpu/cIGKRBXu3ivtxHVoFXvcpXJ8Wiff7APelNRqxeo5sz8xdG8UqtE8RSJKH7Z24fQ3aOleS+plpX8p+mqrvSUQSm6SYk2gi0ApQydxXOW7bKrHm8B6x/XjLE78EBaPqzSvkk/cOCkfPTRuJgR4u6xleXicqjmjX30tLE5dAkwErXcGF4pboohbaF5QE4uC/C3dDy0bC0JpI64L+sZ/s76eLJ5HE1EMyEGQOSbEuEWSggp+CNRi2arasGO45dUzsOnlUzIXyh+I7xgS9J5TQT7fvoxMz+4g1BuI12C/EJFWyn7N2bxJfnTjC/lMP+G4WHxC2OVHkWEdS7HWyPI7V4jC5YZpSlrNZkpaK+XTJJJplKi16i35aTkkVWSTYNvabu+3nw0Ux9jl45iRc5joGs7Q4TpfV95IUx50iRO+JJaJ6ywpxor5WrjiiEl6dVSny0XFlfjMyT2FGudFX8cDMRqxE/ZxqqBNjtqxEoFriJDGOkxQNThCCT8NyLFKhnMPqqrFbV4l7xg45z0Tq5BX1cTcCUD481BOFi3E49XWIGA2GE+yjMsdsXSmU0EQynqCv1RnNdkC9cPbFaXCrTNi2mq7EkfsbTiSc7h07VKw9vFe0Sl1To/ho0UQVN2hSRMOMWsRc29QglMjp7X3QoxOJsIRYCsr+08fFx4snMhOpyJHZpFBkkEmuTxZPgn5OiGih/pyyVI7FFDzPiotUo9dq1zY2YN6+TfRfOI6JIeYdOiMtTFLEZY0mByX+aXuf7PcD6H+/BePErF2bRE1jfdRq73PQ23Zmcx2LKRydffSaMAKp6cXiyHkrq2vhTpiptDctk9nBhxGUMhJX/jVmS8J6xOfrFiAhdljsPnlMHDhzQhyqOYnYpoYzoXZxTE0NZf7kxFFRuXEpyRWzJVBxFPsnH5Q+yG5WbV7G9Dzd6rdWkPO+PaE3J2cfjucp+FS8gPQwp1p7T32NzT9nzyNIg9iH71nPqACB6FuZTqZimMXkAEdVKy+3UomIfogkJWdBLJo9MeUzBnPMOLYHBsL8HX/P2gVnSpddoVVta7NSJADb//LMEPvD2gj7x/5/iwiciu6Dax23dRX7zX/vfJ4iURlNkuNRTLN8y6ZiXr6Jimh3dxhJw6eW2c5Vh3aJCdtXi/K188XApVOQPq6WiuPg0f1Q+VSiF/C0kwLPDftETrjoipELnJ9N9SoLlAcwDmBKm0TjwDOtzfbROjGWWY10NtvP2QT7c740ot8kCF2Uf9k09ifRGc3E1z6oACqiL3wqXcjygzvbnqKLSX1zo5zeklB0DeuO7JW1hRkofo1FYEZLM2L1XDFoxUyWvWVgS3NNcODemhsRb18ELLDxt3y6izDIg1fOYvFNusDxqFnMQOGLtRnODHYcPyKDZw4823UxYb/Y5pUgOfvLWIIPCJN4SVD7mE1S2MlWJeUT/+ikcvHWnAgLSRiA1WLlwV1Q/GGZGqeLOdehLXqE/B+fTgxe08XALYzq1x0TxkqMn0hYJqCYsBu0cib7xYeAFoaWJ9mqpKNICgsQSYR21x1EVx2pVNYKyvDU0vdyurgWVoKVRwaGhxAg0oKcQRRf39QyuPEQXqcBZOF1aREOYcBp0XhfWim2gxbqs7ULZPveRjtZ1WW7aQUk+ZN/PYWPpHgJaEqV5XbRgRyfMpbF89TWQAaDnLoxgORiHJKHLoCJMpapYxEu2FHuh9eV+16fmfolA1Hel/dXWwDbCYTZ/tRAM/AySVGQRq+gbne9gxmnNZpqiuzoYevOr9EM5OvV3N8QvZobpPgTScF9H2sBTQpNitVwdTdxh1i2WqepSaFJgZ2Cga56L6kmRTTe6zVbHVoCk9Gdu441KaToXeeInolf6PMp9PkUpu3/OUBLIUnxY32SjdAn2djWTwCQImIZrskfJT6u0KRINPr/70gry01LkRv5mDMQILGn42lSEIk9HY/xRE7xx/IcTRKCuIXzVE2KjCTFGriNW82w9f/vLDVBCvdoP0/cDWhSZCQp/KYtxz/6pbRFLQeiRRJ3NrcmBZG4s7m9PJu7YmA0KQYm3oVoUiQKK9pcx8jC898f5jO84wqzGIVqUmQMKVjW7/O/XxZl4dRl41tyZzVdiJ/4G+CIJkVGkOIQSPFXHHfX2GB7r7G2DDeAH/0AP4poUmQEKWyz0sqmlXCDGO3Kv48rkjMR9bbBGk2KtCZFLZDn5ct/Rn588RfVemXAaV3tfNpbk8JhzAWuB/i26ku/sNYr3zpoOfoGY00KR9EI9AQM4pIiC2S2j3D0XeeaFI5iHoqgNwAGcVlCYnharMX9zq+z0KRwykqQEFckKpnF2GKyJkVakWJ6q5W4YjGlpfATLuCkJkVakOKUx/Z347i6q3xGh4QLLty2xbxFqSZFWpCiBDmJbKa0OyyecJu1+D2wPaVJoUmxEy7jdybHc3Sx0WHhWgso1LDFKhLjWaBRkyJlg8tnHlxSabhDA2XhKyYhKbwt1uIaYAygSZF6iCAMuJqpBteogUZc5D8q+rEmQvxFyrkRTYqdfLELZ5N5EwcbcRNaizwUTf5jcYWB6PVRrulLNVJMjZEU81OTFGc4Xs9vXWK4K4uk24ireMM+EILZTj+3GQZTSTlekEId/8xzJa4U/HfctU5ypdrbA4NmZUDONtwYv06R/PBgw2v7AesO3HRGKimIZ2LxLImO4oFxw1PNSswww747pNuYNsToNOFsxGUj8JTrLgKMLzam1rkVMSCVzqHguCCOYJ7JE1IrtDtTWGZl0OmttFgb8aABB5NKIRoHQQaPZ3yQL7OXeSZHxMPAEyy8+/OPr8JNeydNGlzjJAjx4H8N+SDLRGDJGNBRyQsVIbbwMYfxXTTmjYS/K12D+n/dXVX8XW7942wjIcLYgoGM2XLwSR9NjIQSog9cezaAYlfASKSQEESSEEMTgohBNDE0IZwhBdEVDX0HON2pCtE4CbweTQgi6cSMqOlq2Po+It9eerraWaBerd4mgkoHyRDLGgxLpsR7Fr+TxTxG3BNcGhuZh8gt63cVZxkMKlNC8jAdygMxvJU+WVmNU0pcg3q0kUkeX2wwD+EGUko80pXAakSCzGewVhLscHVVg9XOIGsZXBPhqfTLxFRKCjvgsgfKIhqrq6rsvvOKFKKxBXp7hNVOucI+VIzUddBIaWERjdVVMvsXe5cyF/8XQOSSS/s0GoBqEOJPPaaXGWZYVjtlcStthDEGYw0zIkvvV3PNoLYaF8Q24GkTepIzuhD0Bv2lpRRg5U+3SYNlzeQfFlXQemCVuFXC/QiaCBIngVJYh9/1mFYqt1j8Zscq1JkKjbSX7lg8mh8aSFfCZX7ZaoPK9Ax2KXXAZDwkLvcouc/GcI/C7IJnUGWS0Gq4Rhcz0UVyMCi9Qe10n5dB5GhU/b0fi5aulnrgGojRARk7ZLR45UEpbeS4UZFjLlALpOuJdDOBntzXyX6DFAYPENFygWMQVI7jek9EnqhjA4fThAxHgAiQ12YZLu84AE0MwgS8NoAzmdThbH2AlUBNClqFFUA/9sMM+X9Ai0DL0EoKLVdQXOtR9ikib24tgHv50pflCfv4aqtcwA+sSeIMKdu1GgjI9trWLX/+ojgLnwgeLcNl9YuliKWFp7b979ggLYYstJnVWB8a8XXx2pIgOUB/5Z/3AHUJnD3sUe14D5Yuh0RwhwJdPNUtKWlYCOO/J36G/gQNLXGesRRUFDIgkwShwruNtPgE/gTW5Od8sw0HBQirJ3U/cBJojtPgN6nr8bprgRDvx/vi/r8ww9aP/2nYCEMRQWYhXeXYmVXqyNRSC6dsfy7+mDUVDgDgl9nSXhUWB6Ur/r4JLuhPAInyMuADRgGzgXXAXuA40NBOepnf71W/mwPYgAW8BAIUAH/Bo4txj+xec1vWqXpUO9xwC7nFn6T0lPL/AA5N33JT/kSDAAAAAElFTkSuQmCC',
+ name: 'USDT',
+ policy: '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff198',
+ time: 1_718_016_433_972,
+ unit: '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff19855534454',
+ },
+ '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff19869555344': {
+ decimals: 0,
+ displayName: 'iUSD',
+ fingerprint: 'asset1z68cfhqv29phnmlcczdjc9p28j2jl9f5jx8kqa',
+ image: '',
+ name: 'iUSD',
+ policy: '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff198',
+ time: 1_718_016_433_871,
+ unit: '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff19869555344',
+ },
+ e517b38693b633f1bc0dd3eb69cb1ad0f0c198c67188405901ae63a3001bc28068616e646c65735f6e61747572652d6c616b65:
+ {
+ decimals: 0,
+ displayName: 'handles_nature-lake',
+ fingerprint: 'asset1adzfyjwswv8j5982n9rsr7jnh5ldfnmws0qc64',
+ image:
+ 'https://ipfs.blockfrost.dev/ipfs/zdj7Wjfr1dZz7Kao2ADZSF3xttBm7AJewWH5sARvG6XkmaprT',
+ name: '(444) handles_nature-lake',
+ policy: 'e517b38693b633f1bc0dd3eb69cb1ad0f0c198c67188405901ae63a3',
+ time: 1_718_016_433_977,
+ unit: 'e517b38693b633f1bc0dd3eb69cb1ad0f0c198c67188405901ae63a3001bc28068616e646c65735f6e61747572652d6c616b65',
+ },
+ f6f49b186751e61f1fb8c64e7504e771f968cea9f4d11f5222b169e374484f534b59: {
+ decimals: 0,
+ displayName: 'tHOSKY',
+ fingerprint: 'asset15qks69wv4vk7clnhp4lq7x0rpk6vs0s6exw0ry',
+ image:
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAAAYCAMAAAA4a6b0AAACKFBMVEUAAAD////////////v7+/y8vLy8vLz8/P4+Pj4+Pjy8vL19fX19fX29vb29vb29vb29vb29vb19fX19fX29vb09PT19fX19fX19fX29vb29vb09PT09PT09PT19fX29vb09PT09PT09PT19fX19fX19fX19fX19fX19fX19fX19fX29vb29vb19fX19fX19fX19fX29vb19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fUQEBAUFBQXFxcaGhobGxsfHx8gICAjIyMkJCQuLi4wMDAzMzM1NTU5OTk8PDw+Pj4/Pz9ERERIRkdISEhKSkpLS0tPT09QUFBUVFRWVlZhYWFjYGFjY2NlZWVlZ2toaGhpaWlqampra2ttbW1ycnJ0dHR3d3d6enp+iJV+kq5/f3+BgYGCgoKDg4OFgoOJdHuRkZGSkpKTk5OVlZWXl5eYmJicnJyekpaenp6fn5+hoaGioqKjo6OlpaWmpqanp6eoqKipqamrq6usrKyulJqurq6uuMawsLCysrK0tLS0vsy1tbW2k522tra2v8q8vLy9vb2+vr6/v7/BkJ7BwcHCwsLExMTGxsbHx8fKysrLy8vMzMzNzc3N0NPR0dHR1NjS0tLU1NTV1dXW1tbY2NjZ2dna3eHc3Nzd3d3em67g4ODh4eHi4uLj4+Pk5OTl5eXm5ubo6Ojs7Ozv7+/x8fHy8vL19fW4Q7oaAAAAQXRSTlMAAQgKEBMUFSUmJzIzNjg5OjtJS1NhY2ZpbW91d3l7jI2OkZWZqq2wt7i7vL7R0tXW19vc4eLm5+/w9vf4+fr7/sRRB5kAAAABYktHRAH/Ai3eAAABwUlEQVQYGVXBhz+UcRzA8S+KUpraQyUtLU1cF19yjUuhlHZaCmlKqcsqSSVcRkZ19/BEKfk1Pv9ejxdeeL9lTOTyzTuTk7avXxghk0TGpTLKvTZCxi3ezwR7Z8uYVTB40cuwksw22BctI5aCVWbfawe+l1vP/eCKkmEzU+B+W2AoH6hvCP48A+wOE0c8fK4Mdg3lA/UNQavlKbBSROYCzdV2d3+6x+PJqLK67QIgKVRkNdBc0deXdsSrGSe1qccuwDFfJAFovtyY7W1/oDnBK1rzqABHrIS44V+uqtrGp6fM4FnVoy3ARgkH/qrja+H7q58K39xQ1Qpgq0wFstTx5VaVCV56dUEdNmyREBdkaU5xsc+0+mpNoKjonKoNG0QSIEtLrX5jrmumMQN9flUbYkVioCPN2/jLmIC/3Rhj5WnJH5gnMgc4qJUDvbfvnDhfVz7UqQdqIDFURDaBdVxfDDy5++1D3uDHY4fLgBXimJECrdn6uunxu5elHbnpZcCuMBm2BOioOlTX6XsbOH2zFnBFyYgYHL3PHv7outbzG3BFy5hFbibYM0vGTY9LZZR7zRSZZNqy+B3JidvWLQiXEf8BSrjtcYUwJvwAAAAASUVORK5CYII=',
+ name: 'tHOSKY',
+ policy: 'f6f49b186751e61f1fb8c64e7504e771f968cea9f4d11f5222b169e3',
+ time: 1_718_016_433_945,
+ unit: 'f6f49b186751e61f1fb8c64e7504e771f968cea9f4d11f5222b169e374484f534b59',
+ },
+ f6f49b186751e61f1fb8c64e7504e771f968cea9f4d11f5222b169e3744d494e: {
+ decimals: 6,
+ displayName: 'tMIN',
+ fingerprint: 'asset1dcspl93vqst7k7fcz2vx4mu6jvq7hsrse7zlpv',
+ image:
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAMAAADXqc3KAAAB11BMVEUAAAAxScIzR8IxRcQvQsYuScgsRsEzRMQwSMctRsQvRsYuRsMuRcQwRsYuRMUwRsUwRcYvRMQvRMUuRsUuRcYvRcUvRcQvRMUwRcUvRcYvRcUvRcUvRcUuRcUwRcYvRsQvRcUvRcUvRMUvRcUvRcUvRcUvRMUvRcUvRcQvRcUvRcUvRcUvRcUwRsUxRsUzScY0ScY2S8c2TMc5Tsg6T8g7UMg8UclAVMpBVcpHWsxLXs1MX81NTU1PYc5QY85UVFRZatFZa9FcXFxcbdJeb9JjY2NmdtRnd9VsbGxxcXFzgth2hNl4eHh4h9l5h9p/jNuBjtyBj9yIld6Njo6OmuCPm+CRkpKRneCWoeGWoeKZpOKapOKbpuKdnp6gquSkpaWmr+Woseaqq6urrKyrtOe1vOq2tra5wevAwMDBwcHCye3ExMTIyMjKz+/Q0NDS0tLU2fLY3PTc4PTe4fPg4ODh4eHh5Pbi4uLk5/fl5/bo6Ojo6vjp6enp6/jq6urr7fns7Ozt7Ozt7/nu7u7u7/nw8PDx8fHy8/vz8/Pz9Prz9Pv08/T09fj19PT19fX29vb29/z39/f3+Pz4+Pj4+fz5+fn6+v37+/38/Pz8/f79/f3+/v7///+3CVNNAAAALHRSTlMAFRkaGxwdHiBJTE1OUGlqa2xtbm+RmJmbnJ6iqrW2t7jX2Nnb8fLz9Pv8/R/3l70AAAABYktHRJxxvMInAAABbUlEQVQYGVXBiT+TcRwH8G/yEKbYJGHZxvbw/D5UstxXuUO5dVG6ELmvx5GIdCAN1b7/rO/m4TXvN1li7S5Pbq7HlRxDkaJvGLCozCt0LlFHBJ+NLCkKFygHhSUqAPltZRBVDRDKRkLTIZ7w70qg9sdhOYTvMhGlQ/i/MvejeJX5OULSiGINiH7+1t34eHz+2UAPQgyNHBB3Nxa7p5g58OvVfYQlURbEI/7MYbtbS6UIcVIOxDs+s/NluhDCQzrEMp/5Z/7shNBJB3DnPzN/5PU55qH39TMQXsoGUMJisKl1bfjlRNcnLgLgpiwAt//wqTcVm+Ya+wE4KRlilEVwu7n3QbW5OQ9xlWIMAA//MvORuTAyNmsedQDI04huQrQfMwfXTdNc2f+QDyCViKJ9EHWTzMHA3sH3pwUAvFEk4gyE1PS9fvui5R6ESqAwh8IFyk4Wmw8RvPF07tL1PFhURhRF0pJuuXXd7bym0akT8JCFuvOPqIIAAAAASUVORK5CYII=',
+ name: 'tMIN',
+ policy: 'f6f49b186751e61f1fb8c64e7504e771f968cea9f4d11f5222b169e3',
+ time: 1_718_016_433_971,
+ unit: 'f6f49b186751e61f1fb8c64e7504e771f968cea9f4d11f5222b169e3744d494e',
+ },
+ '0b23996b05afb3a76cc802dcb1d854a2b3596b208bf775c162cec2d34e6f6e5371756172654e66743235':
+ {
+ decimals: 0,
+ displayName: 'NonSquareNft25',
+ fingerprint: 'asset15tfh93yjsffr7v9fepepuq2w4scl58eeaszmx7',
+ image:
+ 'https://ipfs.blockfrost.dev/ipfs/QmPmYGX7Vob7X9BkfHQeHskTJQJzgd9oZupugVSLXBJYLV',
+ name: 'NonSquareNft25',
+ policy: '0b23996b05afb3a76cc802dcb1d854a2b3596b208bf775c162cec2d3',
+ time: 1_718_430_739_151,
+ unit: '0b23996b05afb3a76cc802dcb1d854a2b3596b208bf775c162cec2d34e6f6e5371756172654e66743235',
+ },
+ '093e1dd222241dabb60ec25e98026d68ff45bd4e7c6a86bca0f59d3853505f714a61653270':
+ {
+ unit: '093e1dd222241dabb60ec25e98026d68ff45bd4e7c6a86bca0f59d3853505f714a61653270',
+ policy: '093e1dd222241dabb60ec25e98026d68ff45bd4e7c6a86bca0f59d38',
+ fingerprint: 'asset13plz6ta5p87u2mrt0n4fny7h9pkmjyn9yja3r9',
+ name: 'SP_qJae2p',
+ displayName: 'SP_qJae2p',
+ image: '',
+ decimals: 0,
+ time: 1_719_336_367_799,
+ },
+ fbaec8dd4d4405a4a42aec11ce5a0160c01e488f3918b082ccbab70544da9f788bef996b9adbefa7d3d9cbb616d7e8174a1ffaf320270db7bf561b05:
+ {
+ unit: 'fbaec8dd4d4405a4a42aec11ce5a0160c01e488f3918b082ccbab70544da9f788bef996b9adbefa7d3d9cbb616d7e8174a1ffaf320270db7bf561b05',
+ policy: 'fbaec8dd4d4405a4a42aec11ce5a0160c01e488f3918b082ccbab705',
+ fingerprint: 'asset1rmn49nag3s2zs66w3qqyl4d6sj68egat3ljzg0',
+ name: "Dڟx���k������˶\u0016��\u0017J\u001F�� '\r��V\u001B\u0005",
+ displayName: "Dڟx���k������˶\u0016��\u0017J\u001F�� '\r��V\u001B\u0005",
+ image: '',
+ decimals: 0,
+ time: 1_719_336_368_050,
+ },
+ fa39bd793aed73c0a2d30451e616e298320cb8ada00987370d2dcd0453505f464556484e4a: {
+ unit: 'fa39bd793aed73c0a2d30451e616e298320cb8ada00987370d2dcd0453505f464556484e4a',
+ policy: 'fa39bd793aed73c0a2d30451e616e298320cb8ada00987370d2dcd04',
+ fingerprint: 'asset1mj63md76x3u9cjrxruuay8ane3x57xj8y3lhs9',
+ name: 'SP_FEVHNJ',
+ displayName: 'SP_FEVHNJ',
+ image: '',
+ decimals: 0,
+ time: 1_719_336_368_057,
+ },
+ e16c2dc8ae937e8d3790c7fd7168d7b994621ba14ca11415f39fed724d494e: {
+ unit: 'e16c2dc8ae937e8d3790c7fd7168d7b994621ba14ca11415f39fed724d494e',
+ policy: 'e16c2dc8ae937e8d3790c7fd7168d7b994621ba14ca11415f39fed72',
+ fingerprint: 'asset1marrj9cp99pa9ag4evkucsrj6uk0vckecktksl',
+ name: 'MIN',
+ displayName: 'MIN',
+ image: '',
+ decimals: 0,
+ time: 1_719_336_368_047,
+ },
+ c82a4452eaebccb82aced501b3c94d3662cf6cd2915ad7148b459aec41584f: {
+ unit: 'c82a4452eaebccb82aced501b3c94d3662cf6cd2915ad7148b459aec41584f',
+ policy: 'c82a4452eaebccb82aced501b3c94d3662cf6cd2915ad7148b459aec',
+ fingerprint: 'asset1mm643p2m7cwdtc3cj4f3yrfymum56dh534s38y',
+ name: 'AXO',
+ displayName: 'AXO',
+ image: '',
+ decimals: 0,
+ time: 1_719_336_368_056,
+ },
+ b2ab960cf45de24f65d7abe9ee6ac7ed03453a8953fe2421eba0d32553505f676471663775: {
+ unit: 'b2ab960cf45de24f65d7abe9ee6ac7ed03453a8953fe2421eba0d32553505f676471663775',
+ policy: 'b2ab960cf45de24f65d7abe9ee6ac7ed03453a8953fe2421eba0d325',
+ fingerprint: 'asset1p4pqxthwj50znz5x6vyyd2zjlwf3m0uvyln4wv',
+ name: 'SP_gdqf7u',
+ displayName: 'SP_gdqf7u',
+ image: '',
+ decimals: 0,
+ time: 1_719_336_367_821,
+ },
+ aa0f536f65c1ffd33001a831c418f1e2f3105cfd9741bbcb6202aedc001bc280676f6f7365: {
+ unit: 'aa0f536f65c1ffd33001a831c418f1e2f3105cfd9741bbcb6202aedc001bc280676f6f7365',
+ policy: 'aa0f536f65c1ffd33001a831c418f1e2f3105cfd9741bbcb6202aedc',
+ fingerprint: 'asset1hm5fmz43379lunssujnevwhq8mxjhm87kvq34z',
+ name: '(444) goose',
+ displayName: 'goose',
+ image:
+ 'https://ipfs.blockfrost.dev/ipfs/zb2rhf9RCoh1nWKN2bzW2G3SMtnLEvuNWZFGQEeDZmp7s42Mz',
+ decimals: 0,
+ time: 1_719_336_367_820,
+ },
+ '6736988a80b3e42c1940e48d5ab2de52c626acb22d21c13b5ff5c86253505f47516674726f':
+ {
+ unit: '6736988a80b3e42c1940e48d5ab2de52c626acb22d21c13b5ff5c86253505f47516674726f',
+ policy: '6736988a80b3e42c1940e48d5ab2de52c626acb22d21c13b5ff5c862',
+ fingerprint: 'asset13psx8mc5luvgrjzjdgcwmajn9vkevu2pmgggnu',
+ name: 'SP_GQftro',
+ displayName: 'SP_GQftro',
+ image: '',
+ decimals: 0,
+ time: 1_719_336_367_821,
+ },
+ '666816b289a3c7a6427333703dc6cfd4b9c544f97bd70dfd913a778a53505f4d464b497961':
+ {
+ unit: '666816b289a3c7a6427333703dc6cfd4b9c544f97bd70dfd913a778a53505f4d464b497961',
+ policy: '666816b289a3c7a6427333703dc6cfd4b9c544f97bd70dfd913a778a',
+ fingerprint: 'asset1nvjefftum7q2p6s06m4u7qc5gltyffa79ytejq',
+ name: 'SP_MFKIya',
+ displayName: 'SP_MFKIya',
+ image: '',
+ decimals: 0,
+ time: 1_719_336_368_035,
+ },
+ '4298bc56195ebed886f2172eb0352a26611ce34f4482a3ee3cc0f39574657374': {
+ unit: '4298bc56195ebed886f2172eb0352a26611ce34f4482a3ee3cc0f39574657374',
+ policy: '4298bc56195ebed886f2172eb0352a26611ce34f4482a3ee3cc0f395',
+ fingerprint: 'asset1td7qdtk2rmyktdcezzv33askkuddh8a4sl46jj',
+ name: 'test',
+ displayName: 'test',
+ image: '',
+ decimals: 0,
+ time: 1_719_336_368_058,
+ },
+ '359289937f6cd0478f2c0737eed4ba879725c09d9d80caeeadf4a67f6261746d616e': {
+ unit: '359289937f6cd0478f2c0737eed4ba879725c09d9d80caeeadf4a67f6261746d616e',
+ policy: '359289937f6cd0478f2c0737eed4ba879725c09d9d80caeeadf4a67f',
+ fingerprint: 'asset14amr4cepgv90u862p845l0vesxv2xpjqk4nup7',
+ name: 'batman',
+ displayName: 'batman',
+ image: '',
+ decimals: 0,
+ time: 1_719_336_368_037,
+ },
+};
diff --git a/packages/nami/src/mocks/transaction.mock.ts b/packages/nami/src/mocks/transaction.mock.ts
new file mode 100644
index 000000000..0b8fec4ff
--- /dev/null
+++ b/packages/nami/src/mocks/transaction.mock.ts
@@ -0,0 +1,28 @@
+export const protocolParameters = {
+ linearFee: {
+ minFeeA: '44',
+ minFeeB: '155381',
+ },
+ minUtxo: '1000000',
+ poolDeposit: '500000000',
+ keyDeposit: '2000000',
+ coinsPerUtxoWord: '4310',
+ maxValSize: '5000',
+ priceMem: 0.0577,
+ priceStep: 0.000_072_1,
+ maxTxSize: 16_384,
+ slot: 62_415_854,
+ collateralPercentage: 150,
+ maxCollateralInputs: 3,
+};
+
+export const currentlyDelegating = {
+ active: true,
+ rewards: '2000000',
+ homepage: 'https://adanet.io',
+ poolId: 'pool1pmvsu5kmy9nt82qwqugcsku5772sls8r3x99ww5tnzcwjzpvy4n',
+ ticker: 'ANET',
+ description:
+ 'Energy-optimized Bare Metal Infrastructure | Adoption-focused written / visual guides | MD, US',
+ name: 'AdaNet.io',
+};
diff --git a/packages/nami/src/tsconfig.json b/packages/nami/src/tsconfig.json
new file mode 100644
index 000000000..3c43903cf
--- /dev/null
+++ b/packages/nami/src/tsconfig.json
@@ -0,0 +1,3 @@
+{
+ "extends": "../tsconfig.json"
+}
diff --git a/packages/nami/src/types.ts b/packages/nami/src/types.ts
new file mode 100644
index 000000000..bf307526a
--- /dev/null
+++ b/packages/nami/src/types.ts
@@ -0,0 +1,231 @@
+export interface BlockfrostRequestOptions {
+ headers?: Record;
+ body?: string;
+ signal?: Readonly;
+}
+
+export interface BlockfrostResponse {
+ status_code?: number;
+ error?: object;
+ message?: string;
+}
+
+export interface Transaction {
+ txHash: string;
+ txIndex: number;
+ blockHeight: number;
+}
+
+export interface TransactionResponse {
+ tx_hash: string;
+ tx_index: string;
+ block_height: string;
+ block_time: number;
+}
+
+export interface History {
+ confirmed: string[];
+ details: Record;
+}
+
+export interface TransactionDetail {
+ block: BlockDetailResponse;
+ info: TransactionInfo;
+ metadata: MetadataResponse[];
+ utxos: UtxosResponse;
+}
+
+export type BlockDetailResponse = BlockfrostResponse & {
+ block_vrf: string;
+ confirmations: number;
+ epoch: number;
+ epoch_slot: number;
+ fees: string;
+ hash: string;
+ height: number;
+ next_block: string;
+ op_cert: string;
+ op_cert_counter: string;
+ output: string;
+ previous_block: string;
+ size: number;
+ slot: number;
+ slot_leader: string;
+ time: number;
+ tx_count: number;
+};
+
+export type TransactionInfoResponse = BlockfrostResponse & {
+ hash: string;
+ block: string;
+ block_height: number;
+ block_time: number;
+ slot: number;
+ index: number;
+ output_amount: OutputAmount[];
+ fees: string;
+ deposit: string;
+ size: number;
+ invalid_before: number | null;
+ invalid_hereafter: number | string | null;
+ utxo_count: number;
+ withdrawal_count: number;
+ mir_cert_count: number;
+ delegation_count: number;
+ stake_cert_count: number;
+ pool_update_count: number;
+ pool_retire_count: number;
+ asset_mint_or_burn_count: number;
+ redeemer_count: number;
+ valid_contract: boolean;
+};
+
+interface TransactionInfo {
+ asset_mint_or_burn_count: number;
+ block: string;
+ block_height: number;
+ block_time: number;
+ delegation_count: number;
+ deposit: string;
+ fees: string;
+ hash: string;
+ index: number;
+ invalid_before?: string;
+ invalid_hereafter?: string;
+ mir_cert_count: number;
+ output_amount: OutputAmount[];
+ pool_retire_count: number;
+ pool_update_count: number;
+ redeemer_count: number;
+ size: number;
+ slot: number;
+ stake_cert_count: number;
+ utxo_count: number;
+ valid_contract: boolean;
+ withdrawal_count: number;
+}
+
+interface OutputAmount {
+ quantity: string;
+ unit: string;
+}
+
+export type MetadataResponse = BlockfrostResponse & {
+ json_metadata: {
+ msg: string[];
+ };
+ label: string;
+};
+
+export interface Utxo {
+ hash: string;
+ inputs: UtxoInput[];
+ outputs: UtxoOutput[];
+}
+
+export type UtxosResponse = BlockfrostResponse & {
+ inputs: UtxoInput[];
+ outputs: UtxoOutput[];
+};
+
+export interface UtxoInput {
+ address: string;
+ amount: Amount[];
+ collateral: boolean;
+ data_hash?: string | null;
+ inline_datum?: string | null;
+ output_index: number;
+ reference: boolean;
+ reference_script_hash?: string | null;
+ tx_hash: string;
+}
+
+export interface UtxoOutput {
+ address: string;
+ amount: Amount[];
+ collateral: boolean;
+ data_hash?: string | null;
+ inline_datum?: string | null;
+ output_index: number;
+ reference_script_hash?: string | null;
+}
+
+export interface Amount {
+ quantity: number;
+ unit: string;
+}
+
+export interface Network {
+ id: NetworkTypeMap;
+ name: NetworkType;
+ node: string;
+ mainnetSubmit?: string;
+ testnetSubmit?: string;
+}
+
+export enum NetworkType {
+ MAINNET = 'mainnet',
+ PREPROD = 'preprod',
+ PREVIEW = 'preview',
+ TESTNET = 'testnet',
+}
+
+export type NetworkTypeMap = Record;
+
+export interface Paginate {
+ page: number;
+ limit: number;
+}
+
+export type StakeAddressResponse = BlockfrostResponse & {
+ stake_address: string; // Bech32 stake address
+ active: boolean; // Registration state of an account
+ active_epoch?: number | null; // Epoch of the most recent action - registration or deregistration
+ controlled_amount: string; // Balance of the account in Lovelaces
+ rewards_sum: string; // Sum of all rewards for the account in Lovelaces
+ withdrawals_sum: string; // Sum of all the withdrawals for the account in Lovelaces
+ reserves_sum: string; // Sum of all funds from reserves for the account in Lovelaces
+ treasury_sum: string; // Sum of all funds from treasury for the account in Lovelaces
+ withdrawable_amount: string; // Sum of available rewards that haven't been withdrawn yet for the account in Lovelaces
+ pool_id?: string | null; // Bech32 pool ID that owns the account
+};
+
+export type StakeRewardAddressResponse = BlockfrostResponse & {
+ active: boolean;
+ active_epoch: number;
+ controlled_amount: string;
+ pool_id: string | null;
+ reserves_sum: string;
+ rewards_sum: string;
+ stake_address: string;
+ treasury_sum: string;
+ withdrawable_amount: string;
+ withdrawals_sum: string;
+};
+
+export type StakePoolMetadataResponse = BlockfrostResponse & {
+ pool_id: string;
+ hex: string;
+ url: string;
+ hash: string;
+ ticker: string;
+ name: string;
+ description: string;
+ homepage: string;
+};
+
+export interface StakePoolMetadata {
+ active: boolean;
+ rewards: string;
+ homepage: string;
+ poolId: string;
+ ticker: string;
+ description: string;
+ name: string;
+}
+
+export enum SupportedCurrencies {
+ USD = 'usd',
+ ADA = 'ada',
+ EUR = 'eur',
+}
diff --git a/packages/nami/src/types/assets.ts b/packages/nami/src/types/assets.ts
new file mode 100644
index 000000000..9c5843797
--- /dev/null
+++ b/packages/nami/src/types/assets.ts
@@ -0,0 +1,19 @@
+export interface Asset {
+ name: string;
+ labeledName: string;
+ displayName: string;
+ policy: string;
+ fingerprint: string;
+ unit: string;
+ quantity: string;
+ decimals: number;
+ image?: string;
+}
+export interface CardanoAsset {
+ unit: string;
+ quantity: string;
+}
+
+export type AssetInput = Asset & {
+ input: string;
+};
diff --git a/packages/nami/src/types/wallet.ts b/packages/nami/src/types/wallet.ts
new file mode 100644
index 000000000..c53af6cd2
--- /dev/null
+++ b/packages/nami/src/types/wallet.ts
@@ -0,0 +1,8 @@
+import type { Wallet } from '@lace/cardano';
+
+export interface CreateWalletParams {
+ name: string;
+ mnemonic: string[];
+ password: string;
+ chainId?: Wallet.Cardano.ChainId;
+}
diff --git a/packages/nami/src/ui/Container.tsx b/packages/nami/src/ui/Container.tsx
new file mode 100644
index 000000000..b49754991
--- /dev/null
+++ b/packages/nami/src/ui/Container.tsx
@@ -0,0 +1,81 @@
+/* eslint-disable unicorn/no-null */
+import React, { useEffect } from 'react';
+
+import { ChevronUpIcon } from '@chakra-ui/icons';
+import { Box, IconButton } from '@chakra-ui/react';
+
+import { POPUP, POPUP_WINDOW, TAB } from '../config/config';
+
+import { Scrollbars } from './app/components/scrollbar';
+import { Store as StoreProvider } from './store';
+import { Theme } from './theme';
+
+import 'focus-visible/dist/focus-visible';
+import './app/components/styles.css';
+import type { Wallet } from '@lace/cardano';
+
+const isMain = window.document.querySelector(`#${POPUP.main}`);
+const isTab = window.document.querySelector(`#${TAB.hw}`);
+
+export const Container = ({
+ children,
+ environmentName,
+ theme,
+}: Readonly<{
+ children: React.ReactNode;
+ environmentName: Wallet.ChainName;
+ theme: 'dark' | 'light';
+}>) => {
+ const [scroll, setScroll] = React.useState({ el: null, y: 0 });
+
+ useEffect(() => {
+ window.document.body.addEventListener('keydown', e => {
+ e.key === 'Escape' && e.preventDefault();
+ });
+ // Windows is somehow not opening the popup with the right size. Dynamically changing it, fixes it for now:
+ if (navigator.userAgent.includes('Win') && !isMain && !isTab) {
+ const width =
+ POPUP_WINDOW.width + (window.outerWidth - window.innerWidth);
+ const height =
+ POPUP_WINDOW.height + (window.outerHeight - window.innerHeight);
+ window.resizeTo(width, height);
+ }
+ }, []);
+
+ return (
+
+
+
+ {
+ setScroll({ el: e.target, y: e.target.scrollTop });
+ }}
+ >
+ {children}
+ {scroll.y > 1200 && (
+ {
+ scroll.el.scrollTo({ behavior: 'smooth', top: 0 });
+ }}
+ position="fixed"
+ bottom="15px"
+ right="15px"
+ size="sm"
+ rounded="xl"
+ colorScheme="teal"
+ opacity={0.85}
+ icon={ }
+ />
+ )}
+
+
+
+
+ );
+};
diff --git a/packages/nami/src/ui/README.md b/packages/nami/src/ui/README.md
new file mode 100644
index 000000000..85aba74d1
--- /dev/null
+++ b/packages/nami/src/ui/README.md
@@ -0,0 +1,3 @@
+# Light Wallet | Packages | Nami | UI
+
+Components for the Nami Mode package.
diff --git a/packages/nami/src/ui/UpgradeToLaceHeader.stories.tsx b/packages/nami/src/ui/UpgradeToLaceHeader.stories.tsx
new file mode 100644
index 000000000..764e1d809
--- /dev/null
+++ b/packages/nami/src/ui/UpgradeToLaceHeader.stories.tsx
@@ -0,0 +1,52 @@
+import React from 'react';
+
+import type { Meta, StoryObj } from '@storybook/react';
+
+import { store } from '../mocks/store.mock';
+
+import { useStoreActions, useStoreState } from './store.mock';
+import { UpgradeToLaceHeader } from './UpgradeToLaceHeader';
+
+const customViewports = {
+ popup: {
+ name: 'Popup',
+ styles: {
+ width: '400px',
+ height: '600px',
+ },
+ },
+};
+
+const meta: Meta = {
+ title: 'UpgradeHeader',
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ component: () => {}} />,
+ parameters: {
+ viewport: {
+ viewports: customViewports,
+ defaultViewport: 'popup',
+ },
+ layout: 'centered',
+ },
+ beforeEach: () => {
+ useStoreState.mockImplementation((callback: any) => {
+ return callback({
+ ...store,
+ globalModel: {
+ ...store.globalModel,
+ laceSwitchStore: { isLaceSwitchInProgress: true },
+ },
+ });
+ });
+ useStoreActions.mockImplementation(() => {
+ return () => void 0;
+ });
+ },
+};
+
+export default meta;
+export const Light: StoryObj = {
+ parameters: {
+ colorMode: 'light',
+ },
+};
diff --git a/packages/nami/src/ui/UpgradeToLaceHeader.tsx b/packages/nami/src/ui/UpgradeToLaceHeader.tsx
new file mode 100644
index 000000000..676cd13f0
--- /dev/null
+++ b/packages/nami/src/ui/UpgradeToLaceHeader.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+
+import { motion } from 'framer-motion';
+
+import { SwitchToLaceBanner } from './app/components/switchToLaceBanner';
+
+export const UpgradeToLaceHeader = ({
+ switchWalletMode,
+}: {
+ switchWalletMode: () => Promise;
+}) => {
+ return (
+
+
+
+ );
+};
diff --git a/packages/nami/src/ui/app/components/TrezorWidget.tsx b/packages/nami/src/ui/app/components/TrezorWidget.tsx
new file mode 100644
index 000000000..9e05c0b35
--- /dev/null
+++ b/packages/nami/src/ui/app/components/TrezorWidget.tsx
@@ -0,0 +1,63 @@
+import { CloseIcon } from '@chakra-ui/icons';
+import { Box, Modal, ModalContent, ModalOverlay, useDisclosure } from '@chakra-ui/react';
+import React from 'react';
+
+const TrezorWidget = React.forwardRef((props, ref) => {
+ const { isOpen, onOpen, onClose } = useDisclosure();
+ React.useImperativeHandle(ref, () => ({
+ openModal() {
+ onOpen();
+ },
+ closeModal() {
+ onClose();
+ },
+ }));
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+});
+
+export default TrezorWidget;
diff --git a/packages/nami/src/ui/app/components/about.tsx b/packages/nami/src/ui/app/components/about.tsx
new file mode 100644
index 000000000..e10446e30
--- /dev/null
+++ b/packages/nami/src/ui/app/components/about.tsx
@@ -0,0 +1,128 @@
+import React from 'react';
+import {
+ Modal,
+ ModalOverlay,
+ ModalContent,
+ ModalHeader,
+ ModalBody,
+ ModalCloseButton,
+ useDisclosure,
+ useColorModeValue,
+ Image,
+ Text,
+ Box,
+ Link,
+} from '@chakra-ui/react';
+
+import LogoWhite from '../../../assets/img/logoWhite.svg';
+import LogoBlack from '../../../assets/img/logo.svg';
+import IOHKWhite from '../../../assets/img/iohkWhite.svg';
+import IOHKBlack from '../../../assets/img/iohk.svg';
+import TermsOfUse from './termsOfUse';
+import PrivacyPolicy from './privacyPolicy';
+import { useCaptureEvent } from '../../../features/analytics/hooks';
+import { Events } from '../../../features/analytics/events';
+import { useOutsideHandles } from '../../../features/outside-handles-provider';
+
+const About = React.forwardRef((props, ref) => {
+ const capture = useCaptureEvent();
+ const { openExternalLink } = useOutsideHandles()
+ const { isOpen, onOpen, onClose } = useDisclosure();
+ const Logo = useColorModeValue(LogoBlack, LogoWhite);
+ const IOHK = useColorModeValue(IOHKWhite, IOHKBlack);
+
+ const termsRef = React.useRef();
+ const privacyPolRef = React.useRef();
+
+ React.useImperativeHandle(ref, () => ({
+ openModal() {
+ onOpen();
+ },
+ closeModal() {
+ onClose();
+ },
+ }));
+ return (
+ <>
+
+
+
+ About
+
+
+ openExternalLink('https://namiwallet.io')}
+ width="90px"
+ src={Logo}
+ />
+
+ Nami mode: {process.env.APP_VERSION}
+
+
+
+ Maintained by{' '}
+ openExternalLink('https://iohk.io/')}
+ style={{ textDecoration: 'underline', cursor: 'pointer' }}
+ >
+ IOG
+
+
+
+ openExternalLink('https://iohk.io/')}
+ src={IOHK}
+ width="66px"
+ />
+
+
+ {/* Footer */}
+
+ {
+ capture(Events.SettingsTermsAndConditionsClick);
+ termsRef.current.openModal();
+ }}
+ color="GrayText"
+ _hover={{ color: 'GrayText', textDecoration: 'underline' }}
+ >
+ Terms of use
+
+ |
+ privacyPolRef.current.openModal()}
+ color="GrayText"
+ _hover={{ color: 'GrayText', textDecoration: 'underline' }}
+ >
+ Privacy Policy
+
+
+
+
+
+
+
+
+ >
+ );
+});
+
+export default About;
diff --git a/packages/nami/src/ui/app/components/account.tsx b/packages/nami/src/ui/app/components/account.tsx
new file mode 100644
index 000000000..6f5221ea5
--- /dev/null
+++ b/packages/nami/src/ui/app/components/account.tsx
@@ -0,0 +1,65 @@
+import React from 'react';
+
+import Logo from '../../../assets/img/logoWhite.svg';
+import { Box, Text, Image, useColorModeValue } from '@chakra-ui/react';
+import AvatarLoader from './avatarLoader';
+
+const Account = ({ name, avatar }: { name: string; avatar?: string }) => {
+ const avatarBg = useColorModeValue('white', 'gray.700');
+ const panelBg = useColorModeValue('#349EA3', 'gray.800');
+
+ return (
+
+
+
+
+
+
+
+
+
+ {name}
+
+
+
+ );
+};
+
+export default Account;
diff --git a/packages/nami/src/ui/app/components/asset.tsx b/packages/nami/src/ui/app/components/asset.tsx
new file mode 100644
index 000000000..39b466562
--- /dev/null
+++ b/packages/nami/src/ui/app/components/asset.tsx
@@ -0,0 +1,194 @@
+import {
+ Box,
+ Avatar,
+ Image,
+ Skeleton,
+ useColorModeValue,
+ Button,
+ Collapse,
+} from '@chakra-ui/react';
+import { useStoreActions, useStoreState } from '../../store';
+import React, { PropsWithChildren } from 'react';
+import Copy from './copy';
+import UnitDisplay from './unitDisplay';
+import { useHistory } from 'react-router-dom';
+import { BsArrowUpRight } from 'react-icons/bs';
+import { AssetInput } from '../../../types/assets';
+import { OutsideHandlesContextValue } from '../../../features/outside-handles-provider';
+import { useCaptureEvent } from '../../../features/analytics/hooks';
+import { Events } from '../../../features/analytics/events';
+
+const useIsMounted = () => {
+ const isMounted = React.useRef(false);
+ React.useEffect(() => {
+ isMounted.current = true;
+ return () => (isMounted.current = false);
+ }, []);
+ return isMounted;
+};
+
+type Props = PropsWithChildren<{
+ asset: AssetInput;
+ enableSend: boolean;
+ background: string;
+ color: string;
+}> & Pick;
+
+const Asset = ({ asset, enableSend, cardanoCoin, ...props }: Props) => {
+ const capture = useCaptureEvent();
+ const background = useColorModeValue('gray.100', 'gray.700');
+ const color = useColorModeValue('rgb(26, 32, 44)', 'inherit');
+ const [show, setShow] = React.useState(false);
+ const [value, setValue] = [
+ useStoreState(state => state.globalModel.sendStore.value),
+ useStoreActions(actions => actions.globalModel.sendStore.setValue),
+ ];
+ const history = useHistory();
+ const navigate = history.push;
+
+ const displayName = asset.unit === 'lovelace' ? 'Ada' : asset.displayName;
+ const decimals = asset.unit === 'lovelace' ? 6 : asset.decimals;
+
+ const onShowDetails = () => {
+ if (asset.unit === 'lovelace') return;
+ setShow(!show)
+ }
+
+ return (
+
+
+
+
+
+ {cardanoCoin.symbol}
+
+ ) : (
+
+ )
+ ) : (
+
+ )
+ }
+ />
+
+
+
+
+ {displayName}
+
+
+
+
+
+
+
+
+
+ Policy
+
+ e.stopPropagation()}>
+
+ {asset.policy}
+
+
+
+
+
+
+ Asset
+
+ e.stopPropagation()}>
+
+ {asset.fingerprint}
+
+
+
+
+
+ {enableSend && (
+
+ }
+ onClick={e => {
+ setValue({ ...value, assets: [asset] });
+ capture(Events.SendClick);
+ navigate('/send');
+ }}
+ >
+ Send
+
+
+ )}
+
+
+
+ );
+};
+
+const Fallback = ({ name }) => {
+ const [timedOut, setTimedOut] = React.useState(false);
+ const isMounted = useIsMounted();
+ React.useEffect(() => {
+ setTimeout(() => isMounted.current && setTimedOut(true), 30000);
+ }, []);
+ if (timedOut) return ;
+ return ;
+};
+
+export default Asset;
diff --git a/packages/nami/src/ui/app/components/assetBadge.tsx b/packages/nami/src/ui/app/components/assetBadge.tsx
new file mode 100644
index 000000000..d5d93dd4e
--- /dev/null
+++ b/packages/nami/src/ui/app/components/assetBadge.tsx
@@ -0,0 +1,156 @@
+import { SmallCloseIcon } from '@chakra-ui/icons';
+import {
+ Avatar,
+ Box,
+ Button,
+ Image,
+ Input,
+ InputGroup,
+ InputLeftElement,
+ InputRightElement,
+ SkeletonCircle,
+} from '@chakra-ui/react';
+import React from 'react';
+import { toUnit } from '../../../api/extension';
+
+import AssetPopover from './assetPopover';
+import { NumericFormat } from 'react-number-format';
+import type { AssetInput } from '../../../types/assets';
+
+const useIsMounted = () => {
+ const isMounted = React.useRef(false);
+ React.useEffect(() => {
+ isMounted.current = true;
+ return () => {
+ isMounted.current = false;
+ };
+ }, []);
+ return isMounted;
+};
+
+const AssetBadge = ({
+ asset,
+ onRemove,
+ onInput,
+}: {
+ asset: AssetInput;
+ onRemove;
+ onInput;
+}) => {
+ const [width, setWidth] = React.useState(
+ BigInt(asset.quantity) <= 1 ? 60 : 200,
+ );
+ const [value, setValue] = React.useState('');
+
+ React.useEffect(() => {
+ const initialWidth = BigInt(asset.quantity) <= 1 ? 60 : 200;
+ setWidth(initialWidth);
+ if (BigInt(asset.quantity) == BigInt(1)) {
+ setValue('1');
+ onInput(1);
+ } else {
+ setValue(asset.input);
+ onInput(asset.input);
+ }
+ }, [asset]);
+ return (
+
+
+
+
+
+
+ ) : (
+
+ )
+ }
+ />
+
+
+
+ }
+ />
+ {
+ setValue(formattedValue);
+ onInput(formattedValue);
+ }}
+ isInvalid={
+ asset.input &&
+ (BigInt(toUnit(asset.input, asset.decimals)) >
+ BigInt(asset.quantity) ||
+ BigInt(toUnit(asset.input, asset.decimals)) <= 0)
+ }
+ customInput={Input}
+ />
+ onRemove()} />
+ }
+ />
+
+
+ );
+};
+
+const Fallback = ({ name }) => {
+ const [timedOut, setTimedOut] = React.useState(false);
+ const isMounted = useIsMounted();
+ React.useEffect(() => {
+ setTimeout(() => isMounted.current && setTimedOut(true), 30000);
+ }, []);
+ if (timedOut) return ;
+ return ;
+};
+
+export default AssetBadge;
diff --git a/packages/nami/src/ui/app/components/assetPopover.tsx b/packages/nami/src/ui/app/components/assetPopover.tsx
new file mode 100644
index 000000000..97629b4ff
--- /dev/null
+++ b/packages/nami/src/ui/app/components/assetPopover.tsx
@@ -0,0 +1,112 @@
+import React, { PropsWithChildren } from 'react';
+import {
+ Popover,
+ PopoverArrow,
+ PopoverBody,
+ PopoverCloseButton,
+ PopoverContent,
+ PopoverTrigger,
+ Box,
+ Portal,
+ Image,
+ Avatar,
+ Text,
+} from '@chakra-ui/react';
+import Copy from './copy';
+import UnitDisplay from './unitDisplay';
+import type { Asset } from '../../../types/assets';
+
+type Props = PropsWithChildren<{
+ asset: Asset;
+ gutter?: number;
+}>;
+
+const AssetPopover = ({ asset, gutter, ...props }: Props) => {
+ return (
+
+
+ {props.children}
+
+
+
+
+
+
+ {asset && (
+
+ }
+ />
+
+
+
+ {asset.displayName || asset.name}
+
+
+
+
+
+
+
+
+
+
+ Policy: {asset.policy}
+
+
+
+
+
+
+
+ Asset: {asset.fingerprint}
+
+
+
+
+ )}
+
+
+
+
+ );
+};
+
+export default AssetPopover;
diff --git a/packages/nami/src/ui/app/components/assetPopoverDiff.tsx b/packages/nami/src/ui/app/components/assetPopoverDiff.tsx
new file mode 100644
index 000000000..0535b472d
--- /dev/null
+++ b/packages/nami/src/ui/app/components/assetPopoverDiff.tsx
@@ -0,0 +1,213 @@
+import React from 'react';
+import { Scrollbars } from '../components/scrollbar';
+import {
+ Avatar,
+ Box,
+ Stack,
+ Button,
+ Portal,
+ Popover,
+ PopoverArrow,
+ PopoverBody,
+ PopoverCloseButton,
+ PopoverContent,
+ PopoverHeader,
+ PopoverTrigger,
+} from '@chakra-ui/react';
+import { ChevronDownIcon } from '@chakra-ui/icons';
+import { FixedSizeList as List } from 'react-window';
+import Copy from './copy';
+
+import MiddleEllipsis from 'react-middle-ellipsis';
+import { getAsset } from '../../../api/extension';
+import UnitDisplay from './unitDisplay';
+
+const abs = big => {
+ return big < 0 ? big * BigInt(-1) : big;
+};
+
+const CustomScrollbars = ({ onScroll, forwardedRef, style, children }) => {
+ const refSetter = React.useCallback(scrollbarsRef => {
+ if (scrollbarsRef) {
+ forwardedRef(scrollbarsRef.view);
+ }
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+};
+
+const CustomScrollbarsVirtualList = React.forwardRef((props, ref) => (
+
+));
+
+const AssetsPopover = ({ assets, isDifference }) => {
+ return (
+
+
+ e.stopPropagation()}
+ style={{
+ all: 'revert',
+ background: 'none',
+ border: 'none',
+ outline: 'none',
+ cursor: 'pointer',
+ color: 'inherit',
+ fontWeight: 'bold',
+ display: 'inline-block',
+ padding: '2px 4px',
+ }}
+ _hover={{ all: 'revert' }}
+ >
+ {assets.length} Asset
+ {assets.length > 1 ? 's' : ''}
+
+
+
+ e.stopPropagation()} w="98%">
+
+
+ Assets
+
+
+ {assets && (
+
+ {({ index, style }) => {
+ const asset = assets[index];
+ return (
+
+
+
+ );
+ }}
+
+ )}
+
+
+
+
+
+ );
+};
+
+const Asset = ({ asset, isDifference }) => {
+ const [token, setToken] = React.useState(null);
+ const isMounted = useIsMounted();
+
+ const fetchData = async () => {
+ const detailedAsset = {
+ ...(await getAsset(asset.unit)),
+ quantity: asset.quantity,
+ };
+ if (!isMounted.current) return;
+ setToken(detailedAsset);
+ };
+
+ React.useEffect(() => {
+ fetchData();
+ }, []);
+
+ return (
+
+ {token && (
+
+
+
+
+
+
+
+ {token.name}
+
+
+
+
+ Policy: {token.policy}
+
+
+
+
+
+
+
+
+ {isDifference ? (token.quantity <= 0 ? '-' : '+') : '+'}{' '}
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+const useIsMounted = () => {
+ const isMounted = React.useRef(false);
+ React.useEffect(() => {
+ isMounted.current = true;
+ return () => (isMounted.current = false);
+ }, []);
+ return isMounted;
+};
+
+export default AssetsPopover;
diff --git a/packages/nami/src/ui/app/components/assetsModal.tsx b/packages/nami/src/ui/app/components/assetsModal.tsx
new file mode 100644
index 000000000..8ad05be94
--- /dev/null
+++ b/packages/nami/src/ui/app/components/assetsModal.tsx
@@ -0,0 +1,125 @@
+import React from 'react';
+import {
+ Box,
+ Button,
+ Modal,
+ ModalBody,
+ ModalContent,
+ useColorModeValue,
+ useDisclosure,
+} from '@chakra-ui/react';
+import { Scrollbars } from './scrollbar';
+import { LazyLoadComponent } from 'react-lazy-load-image-component';
+import Asset from './asset';
+import { useOutsideHandles } from '../../../features/outside-handles-provider';
+
+const AssetsModal = React.forwardRef((props, ref) => {
+ const { cardanoCoin } = useOutsideHandles();
+ const { isOpen, onOpen, onClose } = useDisclosure();
+ const [data, setData] = React.useState({
+ title: '',
+ assets: [],
+ background: '',
+ color: 'inherit',
+ });
+ const background = useColorModeValue('white', 'gray.800');
+
+ const abs = (big) => {
+ return big < 0 ? BigInt(big) * BigInt(-1) : big;
+ };
+
+ React.useImperativeHandle(ref, () => ({
+ openModal(data) {
+ setData(data);
+ onOpen();
+ },
+ }));
+
+ return (
+
+
+
+
+
+
+
+ {data.title}
+
+
+ {data.assets.map((asset, index) => {
+ asset = {
+ ...asset,
+ quantity: abs(asset.quantity).toString(),
+ };
+ return (
+
+
+
+
+
+
+
+ );
+ })}
+
+
+
+ Back
+
+
+
+
+
+
+
+
+ );
+});
+
+export default AssetsModal;
diff --git a/packages/nami/src/ui/app/components/assetsViewer.tsx b/packages/nami/src/ui/app/components/assetsViewer.tsx
new file mode 100644
index 000000000..e267c3bf9
--- /dev/null
+++ b/packages/nami/src/ui/app/components/assetsViewer.tsx
@@ -0,0 +1,209 @@
+import {
+ Box,
+ IconButton,
+ Input,
+ InputGroup,
+ InputRightElement,
+ Popover,
+ PopoverArrow,
+ PopoverBody,
+ PopoverContent,
+ PopoverTrigger,
+ Spinner,
+ Text,
+ useColorModeValue,
+} from '@chakra-ui/react';
+import { SearchIcon, SmallCloseIcon } from '@chakra-ui/icons';
+import React from 'react';
+import Asset from './asset';
+import { Planet } from 'react-kawaii';
+import { LazyLoadComponent } from 'react-lazy-load-image-component';
+import { useOutsideHandles } from '../../../features/outside-handles-provider';
+import { searchTokens } from '../../../adapters/assets';
+import { Asset as NamiAsset } from '../../../types/assets';
+
+const AssetsViewer = ({ assets }) => {
+ const totalColor = useColorModeValue(
+ 'rgb(26, 32, 44)',
+ 'rgba(255, 255, 255, 0.92)',
+ );
+ const [assetsArray, setAssetsArray] = React.useState(null);
+ const [search, setSearch] = React.useState('');
+ const [total, setTotal] = React.useState(0);
+ const createArray = async () => {
+ if (!assets) {
+ setAssetsArray(null);
+ setSearch('');
+ return;
+ }
+ setAssetsArray(null);
+ await new Promise((res, rej) => setTimeout(() => res(), 10));
+ const filteredAssets = search ? searchTokens(assets, search) : assets;
+ setTotal(filteredAssets.length);
+ setAssetsArray(filteredAssets);
+ };
+ React.useEffect(() => {
+ createArray();
+ }, [assets, search]);
+
+ React.useEffect(() => {
+ return () => {
+ setSearch('');
+ setAssetsArray(null);
+ };
+ }, []);
+
+ return (
+ <>
+
+ {!(assets && assetsArray) ? (
+
+
+
+ ) : assetsArray.length <= 0 ? (
+
+
+
+
+ No Assets
+
+
+ ) : (
+ <>
+
+ {total} {total == 1 ? 'Asset' : 'Assets'}
+
+
+
+
+ >
+ )}
+
+
+
+
+ >
+ );
+};
+
+const AssetsGrid = ({ assets }) => {
+ const { cardanoCoin } = useOutsideHandles();
+ return (
+
+ {assets.map((asset, index) => (
+
+
+ 0 && 4}
+ display="flex"
+ alignItems="center"
+ justifyContent="center"
+ >
+
+
+
+
+ ))}
+
+ );
+};
+
+const Search = ({ setSearch, assets }) => {
+ const [input, setInput] = React.useState('');
+ const iconColor = useColorModeValue('gray.800', 'rgba(255, 255, 255, 0.92)');
+ const ref = React.useRef();
+ React.useEffect(() => {
+ if (!assets) {
+ setInput('');
+ }
+ if (input == '') setSearch('');
+ }, [input, assets]);
+ return (
+ setTimeout(() => ref.current.focus())}
+ >
+
+ }
+ />
+
+
+
+
+
+ {
+ setInput(e.target.value);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && input) setSearch(input);
+ }}
+ />
+ setInput('')} />
+ }
+ />
+
+
+ input && setSearch(input)}
+ icon={ }
+ />
+
+
+
+ );
+};
+
+export default AssetsViewer;
diff --git a/packages/nami/src/ui/app/components/avatarLoader.tsx b/packages/nami/src/ui/app/components/avatarLoader.tsx
new file mode 100644
index 000000000..07220c6d2
--- /dev/null
+++ b/packages/nami/src/ui/app/components/avatarLoader.tsx
@@ -0,0 +1,37 @@
+import { Box } from '@chakra-ui/react';
+import React from 'react';
+import { avatarToImage } from '../../../api/extension';
+
+const AvatarLoader = ({
+ avatar,
+ width,
+ smallRobot,
+}: {
+ avatar?: string;
+ width: string;
+ smallRobot?: boolean;
+}) => {
+ const [loaded, setLoaded] = React.useState('');
+
+ const fetchAvatar = async () => {
+ if (!avatar || avatar === loaded) return;
+ setLoaded(Number(avatar) ? avatarToImage(avatar) : avatar);
+ };
+
+ React.useEffect(() => {
+ fetchAvatar();
+ }, [avatar]);
+ return (
+
+ );
+};
+
+export default AvatarLoader;
diff --git a/packages/nami/src/ui/app/components/changePasswordModal.tsx b/packages/nami/src/ui/app/components/changePasswordModal.tsx
new file mode 100644
index 000000000..0e8faa94f
--- /dev/null
+++ b/packages/nami/src/ui/app/components/changePasswordModal.tsx
@@ -0,0 +1,238 @@
+import {
+ Button,
+ Box,
+ Text,
+ Input,
+ InputGroup,
+ InputRightElement,
+ Modal,
+ ModalBody,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ ModalOverlay,
+ useToast,
+ useDisclosure,
+} from '@chakra-ui/react';
+import React from 'react';
+import { useCaptureEvent } from '../../../features/analytics/hooks';
+import { Events } from '../../../features/analytics/events';
+
+export const ChangePasswordModal = React.forwardRef<
+ {},
+ {
+ changePassword: (
+ currentPassword: string,
+ newPassword: string,
+ ) => Promise;
+ }
+>(({ changePassword }, ref) => {
+ const capture = useCaptureEvent();
+ const cancelRef = React.useRef();
+ const inputRef = React.useRef();
+ const toast = useToast();
+
+ const { isOpen, onOpen, onClose } = useDisclosure();
+ const [isLoading, setIsLoading] = React.useState(false);
+ const [state, setState] = React.useState({
+ currentPassword: '',
+ newPassword: '',
+ repeatPassword: '',
+ matchingPassword: false,
+ passwordLen: null,
+ show: false,
+ });
+
+ React.useEffect(() => {
+ setState({
+ currentPassword: '',
+ newPassword: '',
+ repeatPassword: '',
+ matchingPassword: false,
+ passwordLen: null,
+ show: false,
+ });
+ }, [isOpen]);
+
+ React.useImperativeHandle(ref, () => ({
+ openModal() {
+ onOpen();
+ },
+ }));
+
+ const confirmHandler = async () => {
+ if (
+ !state.currentPassword ||
+ !state.newPassword ||
+ !state.repeatPassword ||
+ state.newPassword !== state.repeatPassword
+ )
+ return;
+
+ setIsLoading(true);
+
+ try {
+ await changePassword(state.currentPassword, state.newPassword);
+ toast({
+ title: 'Password updated',
+ status: 'success',
+ duration: 5000,
+ });
+
+ capture(Events.SettingsChangePasswordConfirm);
+
+ onClose();
+ } catch (e) {
+ toast({
+ title: e instanceof Error ? e.message : 'Password update failed!',
+ status: 'error',
+ duration: 5000,
+ });
+ }
+
+ setIsLoading(false);
+ };
+
+ return (
+
+
+
+ Change Password
+
+
+ Type your current password and new password below, if you want to
+ continue.
+
+
+
+
+
+ setState(s => ({ ...s, currentPassword: e.target.value }))
+ }
+ placeholder="Enter current password"
+ />
+
+ setState(s => ({ ...s, show: !s.show }))}
+ >
+ {state.show ? 'Hide' : 'Show'}
+
+
+
+
+
+
+
+
+
+
+ setState(s => ({ ...s, newPassword: e.target.value }))
+ }
+ onBlur={e =>
+ setState(s => ({
+ ...s,
+ passwordLen: e.target.value
+ ? e.target.value.length >= 8
+ : null,
+ }))
+ }
+ placeholder="Enter new password"
+ />
+
+ setState(s => ({ ...s, show: !s.show }))}
+ >
+ {state.show ? 'Hide' : 'Show'}
+
+
+
+
+
+ {state.passwordLen === false && (
+
+ Password must be at least 8 characters long
+
+ )}
+
+
+
+
+
+
+ setState(s => ({ ...s, repeatPassword: e.target.value }))
+ }
+ placeholder="Repeat new password"
+ />
+
+ setState(s => ({ ...s, show: !s.show }))}
+ >
+ {state.show ? 'Hide' : 'Show'}
+
+
+
+
+ {state.repeatPassword &&
+ state.repeatPassword !== state.newPassword && (
+ Password doesn't match
+ )}
+
+
+
+
+
+ Cancel
+
+
+
+ Confirm
+
+
+
+
+ );
+});
diff --git a/packages/nami/src/ui/app/components/collectible.tsx b/packages/nami/src/ui/app/components/collectible.tsx
new file mode 100644
index 000000000..b5d1d2f8f
--- /dev/null
+++ b/packages/nami/src/ui/app/components/collectible.tsx
@@ -0,0 +1,134 @@
+import {
+ Box,
+ Avatar,
+ Image,
+ Skeleton,
+ useColorModeValue,
+} from '@chakra-ui/react';
+import React from 'react';
+import './styles.css';
+import { useCaptureEvent } from '../../../features/analytics/hooks';
+import { Events } from '../../../features/analytics/events';
+
+const useIsMounted = () => {
+ const isMounted = React.useRef(false);
+ React.useEffect(() => {
+ isMounted.current = true;
+ return () => (isMounted.current = false);
+ }, []);
+ return isMounted;
+};
+
+const Collectible = React.forwardRef(({ asset, ...props }, ref) => {
+ const capture = useCaptureEvent();
+ const background = useColorModeValue('gray.300', 'white');
+ const [showInfo, setShowInfo] = React.useState(false);
+
+ return (
+ {
+ capture(Events.NFTsImageClick);
+ asset && ref.current.openModal(asset);
+ }}
+ position="relative"
+ display="flex"
+ alignItems="center"
+ justifyContent="center"
+ flexDirection="column"
+ width="160px"
+ height="160px"
+ overflow="hidden"
+ rounded="3xl"
+ background={background}
+ border="solid 1px"
+ borderColor={background}
+ onMouseEnter={() => setShowInfo(true)}
+ onMouseLeave={() => setShowInfo(false)}
+ cursor="pointer"
+ userSelect="none"
+ data-testid={props.testId}
+ >
+
+ {!asset ? (
+
+ ) : (
+
+ ) : (
+
+ )
+ }
+ />
+ )}
+
+ {asset && (
+
+
+
+ {asset.name}
+
+
+ x {asset.quantity}
+
+
+
+ )}
+
+ );
+});
+
+const Fallback = ({ name }) => {
+ const [timedOut, setTimedOut] = React.useState(false);
+ const isMounted = useIsMounted();
+ React.useEffect(() => {
+ setTimeout(() => isMounted.current && setTimedOut(true), 30000);
+ }, []);
+ if (timedOut) return ;
+ return ;
+};
+
+export default Collectible;
diff --git a/packages/nami/src/ui/app/components/collectiblesViewer.tsx b/packages/nami/src/ui/app/components/collectiblesViewer.tsx
new file mode 100644
index 000000000..15b20709d
--- /dev/null
+++ b/packages/nami/src/ui/app/components/collectiblesViewer.tsx
@@ -0,0 +1,366 @@
+import {
+ Box,
+ SimpleGrid,
+ IconButton,
+ Input,
+ InputGroup,
+ InputRightElement,
+ Popover,
+ PopoverArrow,
+ PopoverBody,
+ PopoverContent,
+ PopoverTrigger,
+ Spinner,
+ Text,
+ useDisclosure,
+ Modal,
+ ModalContent,
+ ModalBody,
+ Avatar,
+ Image,
+ useColorModeValue,
+ Button,
+} from '@chakra-ui/react';
+import { SearchIcon, SmallCloseIcon } from '@chakra-ui/icons';
+import React, { useRef } from 'react';
+import { Planet } from 'react-kawaii';
+import Collectible from './collectible';
+import { LazyLoadComponent } from 'react-lazy-load-image-component';
+import './styles.css';
+import Copy from './copy';
+import { useHistory } from 'react-router-dom';
+import { BsArrowUpRight } from 'react-icons/bs';
+import { useStoreActions, useStoreState } from '../../store';
+import { useCaptureEvent } from '../../../features/analytics/hooks';
+import { Events } from '../../../features/analytics/events';
+import { Asset } from '../../../types/assets';
+import { searchTokens } from '../../../adapters/assets';
+
+interface Props {
+ assets: Asset[];
+ setAvatar: (image: string) => void;
+}
+
+const CollectiblesViewer = ({ assets, setAvatar }: Readonly) => {
+ const [assetsArray, setAssetsArray] = React.useState(null);
+ const [search, setSearch] = React.useState('');
+ const [total, setTotal] = React.useState(0);
+ const ref = useRef();
+ const capture = useCaptureEvent();
+
+ const createArray = async () => {
+ if (!assets) {
+ setAssetsArray(null);
+ setSearch('');
+ return;
+ }
+ setAssetsArray(null);
+ await new Promise((res, rej) => setTimeout(() => res(), 10));
+ const filteredAssets = searchTokens(assets, search);
+ setTotal(filteredAssets.length);
+ setAssetsArray(filteredAssets);
+ };
+ React.useEffect(() => {
+ createArray();
+ }, [assets, search]);
+
+ React.useEffect(() => {
+ return () => {
+ setSearch('');
+ setAssetsArray(null);
+ };
+ }, []);
+
+ const avatarHandler = async (avatar: string) => {
+ setAvatar(avatar);
+ await capture(Events.SettingsChangeAvatarClick);
+ };
+
+ return (
+ <>
+
+ {!(assets && assetsArray) ? (
+
+
+
+ ) : assetsArray.length <= 0 ? (
+
+
+
+
+ No Collectibles
+
+
+ ) : (
+ <>
+
+ {total} {total == 1 ? 'Collectible' : 'Collectibles'}
+
+
+
+ >
+ )}
+
+
+
+
+
+ >
+ );
+};
+
+export const CollectibleModal = React.forwardRef(({ onUpdateAvatar }, ref) => {
+ const { isOpen, onOpen, onClose } = useDisclosure();
+ const [asset, setAsset] = React.useState(null);
+ const [fallback, setFallback] = React.useState(false); // remove short flickering where image is not instantly loaded
+ const background = useColorModeValue('white', 'gray.800');
+ const dividerColor = useColorModeValue('gray.200', 'gray.700');
+ const [value, setValue] = [
+ useStoreState(state => state.globalModel.sendStore.value),
+ useStoreActions(actions => actions.globalModel.sendStore.setValue),
+ ];
+ const history = useHistory();
+ const navigate = history.push;
+ const timer = React.useRef();
+
+ React.useImperativeHandle(ref, () => ({
+ openModal(asset) {
+ setAsset(asset);
+ timer.current = setTimeout(() => setFallback(true));
+ onOpen();
+ },
+ closeModal() {
+ clearTimeout(timer.current);
+ setFallback(false);
+ onClose();
+ },
+ }));
+ return (
+
+ {asset && (
+
+
+
+ {asset.image ? (
+
+ )
+ }
+ />
+ ) : (
+
+ )}
+
+
+ {asset.name}
+
+
+
+ {
+ await onUpdateAvatar(asset.image);
+ }}
+ >
+ As Avatar
+
+ }
+ onClick={e => {
+ setValue({ ...value, assets: [asset] });
+ navigate('/send');
+ }}
+ >
+ Send
+
+
+ x {asset.quantity}
+
+
+
+
+
+ Policy
+
+
+ e.stopPropagation()}>
+
+ {asset.policy}{' '}
+
+
+
+
+
+
+ Asset
+
+
+ e.stopPropagation()}>
+
+ {asset.fingerprint}
+
+
+
+
+
+ )}
+
+ );
+});
+
+const AssetsGrid = React.forwardRef(({ assets }, ref) => {
+ return (
+
+
+ {assets.map((asset) => (
+
+
+
+
+
+ ))}
+
+
+ );
+});
+
+const Search = ({ setSearch, assets }) => {
+ const [input, setInput] = React.useState('');
+ const ref = React.useRef();
+ React.useEffect(() => {
+ if (!assets) {
+ setInput('');
+ }
+ if (input == '') setSearch('');
+ }, [input, assets]);
+ return (
+ setTimeout(() => ref.current.focus())}
+ >
+
+ }
+ />
+
+
+
+
+
+ {
+ setInput(e.target.value);
+ }}
+ onKeyDown={e => {
+ if (e.key === 'Enter' && input) setSearch(input);
+ }}
+ />
+ setInput('')} />
+ }
+ />
+
+
+ input && setSearch(input)}
+ icon={ }
+ />
+
+
+
+ );
+};
+
+export default CollectiblesViewer;
diff --git a/packages/nami/src/ui/app/components/confirmModal.tsx b/packages/nami/src/ui/app/components/confirmModal.tsx
new file mode 100644
index 000000000..69d0c4beb
--- /dev/null
+++ b/packages/nami/src/ui/app/components/confirmModal.tsx
@@ -0,0 +1,304 @@
+import type { PasswordObj as Password } from '@lace/core';
+import {
+ Icon,
+ Box,
+ Text,
+ Button,
+ useDisclosure,
+ Input,
+ InputGroup,
+ InputRightElement,
+ Modal,
+ ModalBody,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ ModalOverlay,
+} from '@chakra-ui/react';
+import React from 'react';
+import { MdUsb } from 'react-icons/md';
+import { indexToHw, initHW, isHW } from '../../../api/extension';
+import { ERROR, HW } from '../../../config/config';
+
+interface Props {
+ ready: boolean;
+ onConfirm: (status: boolean, tx: string) => void;
+ sign: (password: string, hw: object) => Promise;
+ setPassword: (pw: Readonly>) => void;
+ onCloseBtn: () => void;
+ title: React.ReactNode;
+ info: React.ReactNode;
+}
+
+const ConfirmModal = React.forwardRef(
+ ({ ready, onConfirm, sign, onCloseBtn, title, info, setPassword }, ref) => {
+ const {
+ isOpen: isOpenNormal,
+ onOpen: onOpenNormal,
+ onClose: onCloseNormal,
+ } = useDisclosure();
+ const {
+ isOpen: isOpenHW,
+ onOpen: onOpenHW,
+ onClose: onCloseHW,
+ } = useDisclosure();
+ const props = {
+ ready,
+ onConfirm,
+ sign,
+ onCloseBtn,
+ title,
+ info,
+ };
+ const [hw, setHw] = React.useState('');
+
+ React.useImperativeHandle(ref, () => ({
+ openModal(accountIndex) {
+ if (isHW(accountIndex)) {
+ setHw(indexToHw(accountIndex));
+ onOpenHW();
+ } else {
+ onOpenNormal();
+ }
+ },
+ closeModal() {
+ onCloseNormal();
+ onCloseHW();
+ },
+ }));
+
+ return (
+ <>
+
+
+ >
+ );
+ }
+);
+
+const ConfirmModalNormal = ({ props, isOpen, onClose, setPassword }) => {
+ const [state, setState] = React.useState({
+ wrongPassword: false,
+ password: '',
+ show: false,
+ name: '',
+ });
+ const [waitReady, setWaitReady] = React.useState(true);
+ const inputRef = React.useRef();
+
+ React.useEffect(() => {
+ setState({
+ wrongPassword: false,
+ password: '',
+ show: false,
+ name: '',
+ });
+ }, [isOpen]);
+
+ const confirmHandler = async () => {
+ if (!state.password || props.ready === false || !waitReady) return;
+ try {
+ setWaitReady(false);
+ const signedMessage = await props.sign(state.password);
+ await props.onConfirm(true, signedMessage);
+ onClose?.();
+ } catch (e) {
+ if (e === ERROR.wrongPassword || e.name === 'AuthenticationError')
+ setState((s) => ({ ...s, wrongPassword: true }));
+ else await props.onConfirm(false, e);
+ }
+ setWaitReady(true);
+ };
+
+ return (
+ {
+ if (props.onCloseBtn) {
+ props.onCloseBtn();
+ }
+ onClose()
+ }}
+ isCentered
+ initialFocusRef={inputRef}
+ blockScrollOnMount={false}
+ // styleConfig={{maxWidth: '100%'}}
+ >
+
+
+
+ {props.title ? props.title : 'Confirm with password'}
+
+
+ {props.info}
+
+ {
+ setPassword?.(e.target);
+ setState((s) => ({ ...s, password: e.target.value }));
+ }}
+ onKeyDown={(e) => {
+ if (e.key == 'Enter') confirmHandler();
+ }}
+ placeholder="Enter password"
+ />
+
+ setState((s) => ({ ...s, show: !s.show }))}
+ >
+ {state.show ? 'Hide' : 'Show'}
+
+
+
+ {state.wrongPassword === true && (
+ Password is wrong
+ )}
+
+
+
+ {
+ if (props.onCloseBtn) {
+ props.onCloseBtn();
+ }
+ onClose();
+ }}
+ >
+ Close
+
+
+ Confirm
+
+
+
+
+ );
+};
+
+const ConfirmModalHw = ({ props, isOpen, onClose, hw }) => {
+ const [waitReady, setWaitReady] = React.useState(true);
+ const [error, setError] = React.useState('');
+
+ const confirmHandler = async () => {
+ if (props.ready === false || !waitReady) return;
+ try {
+ setWaitReady(false);
+ const appAda = await initHW({ device: hw.device, id: hw.id });
+ const signedMessage = await props.sign(null, { ...hw, appAda });
+ await props.onConfirm(true, signedMessage);
+ } catch (e) {
+ if (e === ERROR.submit) props.onConfirm(false, e);
+ else setError('An error occured');
+ }
+ setWaitReady(true);
+ };
+
+ React.useEffect(() => {
+ setError('');
+ }, [isOpen]);
+
+ return (
+ <>
+
+
+
+
+ {props.title ? props.title : `Confirm with device`}
+
+
+ {props.info}
+
+
+
+
+ {!waitReady
+ ? `Waiting for ${
+ hw.device == HW.ledger ? 'Ledger' : 'Trezor'
+ }`
+ : `Connect ${hw.device == HW.ledger ? 'Ledger' : 'Trezor'}`}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ {
+ if (props.onCloseBtn) {
+ props.onCloseBtn();
+ }
+ onClose();
+ }}
+ >
+ Close
+
+
+ Confirm
+
+
+
+
+ >
+ );
+};
+
+export default ConfirmModal;
diff --git a/packages/nami/src/ui/app/components/copy.tsx b/packages/nami/src/ui/app/components/copy.tsx
new file mode 100644
index 000000000..32d28070c
--- /dev/null
+++ b/packages/nami/src/ui/app/components/copy.tsx
@@ -0,0 +1,23 @@
+import { Box, Tooltip } from '@chakra-ui/react';
+import React from 'react';
+
+const Copy = ({ label, copy, onClick, ...props }) => {
+ const [copied, setCopied] = React.useState(false);
+ return (
+
+ {
+ if (onClick) onClick();
+ navigator.clipboard.writeText(copy);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 800);
+ }}
+ >
+ {props.children}
+
+
+ );
+};
+
+export default Copy;
diff --git a/packages/nami/src/ui/app/components/historyViewer.tsx b/packages/nami/src/ui/app/components/historyViewer.tsx
new file mode 100644
index 000000000..c9a224b30
--- /dev/null
+++ b/packages/nami/src/ui/app/components/historyViewer.tsx
@@ -0,0 +1,166 @@
+import { Box, Text, Spinner, Accordion, Button } from '@chakra-ui/react';
+import { ChevronDownIcon } from '@chakra-ui/icons';
+import React from 'react';
+import { File } from 'react-kawaii';
+import {
+ getTransactions,
+ setTransactions,
+ setTxDetail,
+} from '../../../api/extension';
+import Transaction from './transaction';
+import { useCaptureEvent } from '../../../features/analytics/hooks';
+import { Events } from '../../../features/analytics/events';
+import { useOutsideHandles } from '../../../features/outside-handles-provider';
+
+const BATCH = 5;
+
+let slice = [];
+
+let txObject = {};
+
+const HistoryViewer = ({ history, network, currentAddr, addresses }) => {
+ const capture = useCaptureEvent();
+ const { cardanoCoin } = useOutsideHandles();
+ const [historySlice, setHistorySlice] = React.useState(null);
+ const [page, setPage] = React.useState(1);
+ const [final, setFinal] = React.useState(false);
+ const [loadNext, setLoadNext] = React.useState(false);
+ const getTxs = async () => {
+ if (!history) {
+ slice = [];
+ setHistorySlice(null);
+ setPage(1);
+ setFinal(false);
+ return;
+ }
+ await new Promise((res, rej) => setTimeout(() => res(), 10));
+ slice = slice.concat(
+ history.confirmed.slice((page - 1) * BATCH, page * BATCH),
+ );
+
+ if (slice.length < page * BATCH) {
+ const txs = await getTransactions(page, BATCH);
+
+ if (txs.length <= 0) {
+ setFinal(true);
+ } else {
+ slice = Array.from(new Set(slice.concat(txs.map(tx => tx.txHash))));
+ await setTransactions(slice);
+ }
+ }
+ if (slice.length < page * BATCH) setFinal(true);
+ setHistorySlice(slice);
+ };
+
+ React.useEffect(() => {
+ getTxs();
+ }, [history, page]);
+
+ React.useEffect(() => {
+ const storeTx = setInterval(() => {
+ if (Object.keys(txObject).length <= 0) return;
+ setTimeout(() => setTxDetail(txObject));
+ }, 1000);
+ return () => {
+ slice = [];
+ setHistorySlice(null);
+ setPage(1);
+ setFinal(false);
+ clearInterval(storeTx);
+ };
+ }, []);
+
+ React.useEffect(() => {
+ if (!historySlice) return;
+ if (historySlice.length >= (page - 1) * BATCH) setLoadNext(false);
+ }, [historySlice]);
+
+ return (
+
+ {!(history && historySlice) ? (
+
+ ) : historySlice.length <= 0 ? (
+
+
+
+
+ No History
+
+
+ ) : (
+ <>
+ {
+ capture(Events.ActivityActivityActivityRowClick);
+ }}
+ >
+ {historySlice.map((txHash, index) => {
+ if (!history.details[txHash]) history.details[txHash] = {};
+
+ return (
+ {
+ txObject[txHash] = txDetail;
+ }}
+ key={index}
+ txHash={txHash}
+ detail={history.details[txHash]}
+ currentAddr={currentAddr}
+ addresses={addresses}
+ network={network}
+ cardanoCoin={cardanoCoin}
+ />
+ );
+ })}
+
+ {final ? (
+
+ ... nothing more
+
+ ) : (
+
+ {
+ setLoadNext(true);
+ setTimeout(() => setPage(page + 1));
+ }}
+ colorScheme="orange"
+ aria-label="More"
+ fontSize={20}
+ w="50%"
+ h="30px"
+ rounded="xl"
+ >
+ {loadNext ? '...' : }
+
+
+ )}
+ >
+ )}
+
+ );
+};
+
+const HistorySpinner = () => (
+
+
+
+);
+
+export default HistoryViewer;
diff --git a/packages/nami/src/ui/app/components/laceSecondaryButton.tsx b/packages/nami/src/ui/app/components/laceSecondaryButton.tsx
new file mode 100644
index 000000000..a5229b94a
--- /dev/null
+++ b/packages/nami/src/ui/app/components/laceSecondaryButton.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import { Button, Text } from '@chakra-ui/react';
+
+type Props = {
+ children: string;
+ onClick?: () => void;
+};
+
+const LaceSecondaryButton = ({ children, onClick }: Props) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export default LaceSecondaryButton;
diff --git a/packages/nami/src/ui/app/components/privacyPolicy.tsx b/packages/nami/src/ui/app/components/privacyPolicy.tsx
new file mode 100644
index 000000000..5c8df6680
--- /dev/null
+++ b/packages/nami/src/ui/app/components/privacyPolicy.tsx
@@ -0,0 +1,647 @@
+import React from 'react';
+import {
+ Box,
+ Text,
+ Modal,
+ ModalBody,
+ ModalContent,
+ ModalCloseButton,
+ ModalHeader,
+ ModalOverlay,
+ useDisclosure,
+ UnorderedList,
+ ListItem,
+ Link,
+} from '@chakra-ui/react';
+import { Scrollbars } from './scrollbar';
+
+const PrivacyPolicy = React.forwardRef((props, ref) => {
+ const { isOpen, onOpen, onClose } = useDisclosure();
+ React.useImperativeHandle(ref, () => ({
+ openModal() {
+ onOpen();
+ },
+ closeModal() {
+ onClose();
+ },
+ }));
+ return (
+
+
+
+
+ Privacy Policy
+
+
+
+
+ Last updated September 6, 2023
+
+
+ Thank you for choosing to be part of our community at Input
+ Output Global, Inc., (together with our subsidiaries and
+ affiliates, "IOG", "we", "us", or "our"). This Privacy Policy
+ applies to all personal information collected through this
+ website and all of IOG's related websites, mobile apps,
+ products, services, sales, marketing or events, including our
+ plug-ins and browser extensions (collectively, the{' '}
+
+ "Products"
+
+ ).
+
+
+
+ We are committed to protecting your personal information. When
+ you access or use our Products, you trust us with your personal
+ information. In this Privacy Policy, we describe how we collect,
+ use, store and share your personal information and what rights
+ you have in relation to it. If there are any terms in this
+ Privacy Policy that you do not agree with, please discontinue
+ access and use of our Products.
+
+
+
+ Please read this Privacy Policy carefully as it will help you
+ make informed decisions about sharing your personal information
+ with us.
+
+
+
+ 1. Collection of Data
+
+
+ IOG collects several types of information for various purposes
+ to provide and improve the Products for your use.
+
+
+
+ Types of Data Collected
+
+
+
+ Personal Data
+
+ While using the Products, IOG may ask you to provide certain
+ personally identifiable information that can be used to
+ contact or identify you (
+
+ "Personal Data"
+
+ ), which may include, but is not limited to:
+
+
+ Email address
+ First name and last name
+ Phone number
+
+ Address, State, Province, ZIP/Postal code, City
+
+ Cookies and Usage Data
+
+
+ Your Personal Data may be used to contact you with
+ newsletters, marketing or promotional materials and other
+ information that may be of interest to you. You may opt out
+ of receiving any, or all, of these communications by
+ following the unsubscribe link or instructions provided in
+ any email sent to you by IOG.
+
+
+
+ Usage Information
+
+ Usage Information refers to information collected from
+ Products such as what action has been applied to Products
+ such as clicking logs; your registration details or how you
+ may be using Products.
+
+
+
+ IP Address
+
+ When you use Products, we may automatically log your IP
+ address (the unique address which identifies your computer
+ on the internet) which is automatically recognized by our
+ server.
+
+
+
+
+
+ 2. Use of Data
+
+
+ Specifically, IOG uses your information for the purpose for
+ which you provided it to us such as:
+
+
+ To notify you about changes to Products
+
+ To allow you to participate in interactive features of
+ Products when you choose to do so
+
+ To provide customer support
+
+ To gather analysis or valuable information so that we can
+ improve the website
+
+ To monitor the usage of Products
+
+ To detect, prevent and address technical issues
+
+
+ To provide you with news, special offers and general
+ information about other goods, services and events which IOG
+ offers that are similar to those that you have already
+ purchased or enquired about unless you have opted not to
+ receive such information
+
+
+
+ Where and as permitted under applicable law, IOG may process
+ your contact information for direct marketing purposes (e.g.,
+ event invitations, newsletters) and to carry out customer
+ satisfaction surveys, in each case also by email. You may object
+ to the processing of your contact data for these purposes at any
+ time by writing to{' '}
+ legal@iohk.io or by
+ using the opt-out mechanism provided in the respective
+ communication you received.
+
+
+
+ 3. Legal Basis under General Data Protection Regulation (GDPR)
+
+
+ For a citizen or resident of a member country of the European
+ Union (EU) or the European Economic Area (EEA), the legal basis
+ for collecting and using Personal Data described in this Privacy
+ Policy depends on the Personal Data being collected and the
+ specific context in which it is collected as described below:
+
+ IOG may process your Personal Data because:
+
+ IOG needs to perform a contract with you
+ You have given us permission to do so
+
+ The processing derives from IOG's legitimate interests
+
+ IOG has to comply with applicable law
+
+
+ The legal basis for IOG processing data about you is that it is
+ necessary for the purposes of:
+
+
+
+ IOG exercising its rights and performing its obligations in
+ connection with any contract we make with you (Article 6 (1)
+ (b) General Data Protection Regulation),
+
+
+ Compliance with IOG's legal obligations (Article 6 (1) (c)
+ General Data Protection Regulation), and/or
+
+
+ Legitimate interests pursued by IOG (Article 6 (1) (f) General
+ Data Protection Regulation).
+
+
+
+ Generally the legitimate interest pursued by IOG in relation to
+ our use of your personal data is the efficient performance or
+ management of our business relationship with you.
+
+
+ In some cases, we may ask if you consent to the relevant use of
+ your personal data. In such cases, the legal basis for IOG
+ processing that data about you may (in addition or instead) be
+ that you have consented (Article 6 (1) (a) General Data
+ Protection Regulation).
+
+
+
+ 4. Retention of Data
+
+
+ IOG will retain your Personal Data only for as long as is
+ necessary for the purposes set out in this Privacy Policy. IOG
+ will retain and use your Personal Data to the extent necessary
+ to comply with our legal obligations (for example, if IOG is
+ required to retain your Personal Data to comply with applicable
+ laws), resolve disputes, and enforce legal agreements and
+ policies.
+
+
+ IOG will also retain Usage Data for internal analysis purposes.
+ Usage Data is generally retained for a shorter period of time,
+ except when this data is used to strengthen the security or to
+ improve the functionality of Products, or IOG is legally
+ obligated to retain such data for longer time periods.
+
+
+
+ 5. Transfer Of Data
+
+
+ Your information, including Personal Data, may be transferred to
+ — and maintained on — computers located outside of your state,
+ province, country or other governmental jurisdiction where the
+ data protection laws may differ from those from your
+ jurisdiction.
+
+
+ Your use of Products under the IOG Terms of Use followed by your
+ submission of your personal information constitutes your
+ unreserved agreement to this Privacy Policy in general and the
+ transfer of data under this policy in particular.
+
+
+ IOG will take all steps reasonably necessary to ensure that your
+ data is treated securely and in accordance with this Privacy
+ Policy and no transfer of your Personal Data will take place to
+ an organization or a country unless there are adequate controls
+ in place including the security of your data and other personal
+ information.
+
+
+
+ 6. Disclosure Of Data
+
+
+ Legal Requirements
+
+
+ IOG may disclose your Personal Data in good faith belief that
+ such disclosure is necessary to:
+
+
+ To comply with a legal obligation
+
+ To protect and defend the rights or property of IOG
+
+
+ To prevent or investigate possible wrongdoing in connection
+ with Products
+
+
+ To protect the personal safety of users of Products or the
+ public
+
+ To protect against legal liability
+
+
+ Disclosure of Personal Information Within The EU
+
+
+ Insofar as we employ the services of service providers to
+ implement or fulfill any tasks on our behalf the contractual
+ relations will be regulated in writing according to the
+ provisions of the European General Data Protection Regulation
+ (EU-GDPR) and the Federal Data Protection Act (new BDSG).
+
+
+ Disclosure of Personal Information Outside of The EU
+
+
+ Insofar as you have selected and consented to this in the form,
+ your data will be disclosed to our offices within the network of
+ affiliated companies outside of the European economic area for
+ the processing of your enquiry. These offices are legally
+ obligated to abide by the EU-GDPR. Furthermore, between the
+ legally autonomous companies in the network of affiliated
+ companies written agreements exist for the processing of data on
+ commission, based on standardized contract stipulations.
+
+
+ 7. Security Of Data
+
+
+ We use reasonable technical and organizational methods to
+ safeguard your information, for example, by password protecting
+ (via usernames and passwords unique to you) certain parts of the
+ Products and by using SSL encryption and firewalls to protect
+ against unauthorized access, disclosure, alteration or
+ destruction. However, please note that this is not a guarantee
+ that such information may not be accessed, disclosed, altered or
+ destroyed by breach of such firewalls and secure server
+ software.
+
+
+ Whilst we will use all reasonable efforts to safeguard your
+ information, you acknowledge that data transmissions over the
+ internet cannot be guaranteed to be 100% secure and for this
+ reason we cannot guarantee the security or integrity of any
+ Personal Information that is transferred from you or to you via
+ the internet and as such, any information you transfer to IOG is
+ done at your own risk.
+
+
+ Where we have given you (or where you have chosen) a password
+ which enables you to access certain parts of our site, you are
+ responsible for keeping this password confidential. We ask you
+ not to share a password with anyone.
+
+
+ If we learn of a security systems breach we may attempt to
+ notify you electronically so that you can take appropriate
+ protective steps. By using Products or providing Personal
+ Information to us you agree that we can communicate with you
+ electronically regarding security, privacy and administrative
+ issues relating to your use of Products. We may post a notice on
+ Products if a security breach occurs. We may also send an email
+ to you at the email address you have provided to us in these
+ circumstances. Depending on where you live, you may have a legal
+ right to receive notice of a security breach in writing.
+
+
+
+ 8. Rights Under General Data Protection Regulation (GDPR)
+
+
+ If you are a citizen or resident of a member country of the
+ European Union (EU) or the European Economic Area (EEA), you
+ have certain data protection rights. IOG aims to take reasonable
+ steps to allow you to correct, amend, delete, or limit the use
+ of your Personal Data.
+
+
+
+ If you wish to be informed what Personal Data IOG holds about
+ you and if you want it to be removed from our systems, please
+ contact us{' '}
+ legal@iohk.io.
+
+
+ In certain circumstances, you have the following data protection
+ rights:
+
+
+
+
+ The right to access.
+ {' '}
+ You have the right to request information concerning the
+ personal information we hold that relates to you.
+
+
+
+ The right of rectification.
+ {' '}
+ You have the right to have your information rectified if that
+ information is inaccurate or incomplete.
+
+
+
+ The right to object.
+ {' '}
+ You have the right to object to your personal information
+ being used for a particular purpose and you can exercise these
+ rights, for example, via an unsubscribe link at the bottom of
+ any email.
+
+
+
+ The right of restriction.
+ {' '}
+ You have the right to request that IOG restricts the
+ processing of your personal information.
+
+
+
+ The right to data portability.
+ {' '}
+ You have the right to be provided with a copy of the
+ information IOG has on you in a structured, machine readable
+ and commonly used format.
+
+
+
+ The right to withdraw consent.
+ {' '}
+ You also have the right to withdraw your consent at any time
+ where IOG relied on your consent to process your personal
+ information.
+
+
+
+ Please note that IOG may ask you to verify your identity before
+ responding to such requests.
+
+
+ You have the right to complain to a Data Protection Authority
+ about our collection and use of your Personal Data. For more
+ information, please contact your local data protection authority
+ in the EU or EEA.
+
+
+
+ 9. California Residents
+
+
+ If you are a California resident, you have certain rights with
+ respect to your personal information pursuant to the California
+ Consumer Privacy Act of 2018 (“CCPA”). This section applies to
+ you.
+
+
+ We are required to inform you of: (i) what categories of
+ information we collect about you, including during the preceding
+ 12 months, (ii) the purposes for which we use your personal
+ data, including during the preceding 12 months, and (iii) the
+ purposes for which we share your personal data, including during
+ the preceding 12 months.
+
+
+ You have the right to: (i) request a copy of the personal
+ information that we have about you; (ii) request that we delete
+ your personal information; and (iii) opt-out of the sale of your
+ personal information. You can limit the use of tracking
+ technologies, such as cookies, by following instructions in the
+ “Your choices” section. These rights are subject to limitations
+ as described in the CCPA.
+
+
+ We will not discriminate against any consumer for exercising
+ their CCPA rights.
+
+
+ If you would like to exercise any of these rights, please
+ contact us at{' '}
+ legal@iohk.io.
+
+
+
+ 10. Service Providers, Plugins and Tools
+
+
+ IOG may employ third party companies and individuals to
+ facilitate Products, to provide the Products on our behalf, to
+ perform website-related services or to assist us in analyzing
+ how the Products are used (
+
+ "Service Providers"
+
+ ).
+
+
+ These Service Providers have access to your Personal Data only
+ to perform these tasks on our behalf and are obligated not to
+ disclose or use it for any other purpose.
+
+ Social Media
+
+ IOG may use social media plugins to enable a better user
+ experience with the use of Products.
+
+
+ Youtube
+
+ IOG uses the YouTube video platform operated by YouTube LLC, 901
+ Cherry Ave. San Bruno, CA 94066 USA. YouTube is a platform that
+ enables playback of audio and video files. If you visit one of
+ our pages featuring a YouTube plugin, a connection to the
+ YouTube servers is established. Here the YouTube server is
+ informed about which of our pages you have visited. If you’re
+ logged in to your YouTube account, YouTube allows you to
+ associate your browsing behavior directly with your personal
+ profile. You can prevent this by logging out of your YouTube
+ account. YouTube is used to help make our website appealing. For
+ information about the scope and purpose of data collection, the
+ further processing and use of the data by YouTube and your
+ rights and the settings you can configure to protect your
+ privacy, please refer to the YouTube Privacy Guidelines.
+
+
+ Analytics
+
+ IOG may use Service Providers to monitor and analyze the use of
+ Products such as Matomo and/or PostHog.
+
+
+ Newsletter
+
+ If you would like to receive our newsletter, we require a valid
+ email address as well as information that allows us to verify
+ that you are the owner of the specified email address and that
+ you agree to receive this newsletter. No additional data is
+ collected or is only collected on a voluntary basis. We only use
+ this data to send the requested information and do not pass it
+ on to third parties. We will, therefore, process any data you
+ enter onto the contact form only with your consent per (Article
+ 6 (1) (a) General Data Protection Regulation). You can revoke
+ consent to the storage and use of your data and email address
+ for sending the newsletter at any time, e.g., through the
+ "unsubscribe" link in the newsletter. The data processed before
+ we receive your request may still be legally processed. The data
+ provided when registering for the newsletter will be used to
+ distribute the newsletter until you cancel your subscription
+ when said data will be deleted. Data we have stored for other
+ purposes (e.g., email addresses for members areas) remain
+ unaffected.
+
+
+ Newsletter Tracking
+
+ Newsletter tracking (also referred to as Web beacons or tracking
+ pixels) is used if users have given their explicit prior
+ consent. When the newsletter is dispatched, the external server
+ can then record certain data related to the recipient, such as
+ the time and date the newsletter is retrieved, the IP address or
+ details regarding the email program (client) used. The name of
+ the image file is personalized for every email recipient by a
+ unique ID being appended to it. The sender of the email notes
+ which ID belongs to which email address and is thus able to
+ determine which newsletter recipient has just opened the email
+ when the image is called.
+
+
+ You may revoke your consent at any time by unsubscribing to the
+ newsletter. This can be done by sending an email request to
+ unsubscribe to{' '}
+
+ dataprotection@iohk.io
+
+ . You may also remove yourself from the mailing list using the
+ unsubscribe link in the newsletter.
+
+
+ 11. Links To Other Products
+
+
+ Products may contain links to other products that are not
+ operated by IOG. If you click on a third-party link, you will be
+ directed to that third party's site. You’re advised to review
+ the privacy policy of each non-IOG site you decide to visit.
+
+
+ IOG has no control over and assumes no responsibility for the
+ content, privacy policies or practices of any third-party
+ product or service.
+
+
+
+ 12. Changes To This Privacy Policy
+
+
+ IOG may update this Privacy Policy from time to time. Such
+ changes will be posted on this page. The effective date of such
+ changes will be notified via email and/or a prominent notice on
+ the Product, with an update to the "effective date" at the top of
+ this Privacy Policy.
+
+
+ You are advised to review this Privacy Policy periodically for
+ any changes. Changes to this Privacy Policy are effective when
+ they are posted on this page.
+
+
+
+ 13. Data Privacy Contact
+
+
+ You can reach our data protection officer at{' '}
+
+ dataprotection@iohk.io
+
+ .
+
+
+
+ 14. Contact by E-mail or Contact Form
+
+
+ When you contact us by e-mail or through a contact form, we
+ store the data you provide (your email address, possibly your
+ name and telephone number) so we can answer your questions.
+ Insofar as we use our contact form to request entries that are
+ not required for contacting you, we have marked these as
+ optional. This information serves to substantiate your inquiry
+ and improve the handling of your request. Your message may be
+ linked to various actions taken by you on the IOG website.
+ Information collected will be solely used to provide you with
+ support relating to your inquiry and better understand your
+ feedback. A statement of this information is expressly provided
+ on a voluntary basis and with your consent. As far as this
+ concerns information about communication channels (such as
+ e-mail address or telephone number), you also agree that we may,
+ where appropriate, contact you via this communication channel to
+ answer your request. You may of course revoke this consent for
+ the future at any time.
+
+
+ We delete the data that arises in this context after saving is
+ no longer required, or limit processing if there are statutory
+ retention requirements.
+
+
+
+
+
+
+
+ );
+});
+
+export default PrivacyPolicy;
diff --git a/packages/nami/src/ui/app/components/qrCode.tsx b/packages/nami/src/ui/app/components/qrCode.tsx
new file mode 100644
index 000000000..24bbc2ca9
--- /dev/null
+++ b/packages/nami/src/ui/app/components/qrCode.tsx
@@ -0,0 +1,49 @@
+import React from 'react';
+import QRCodeStyling from 'qr-code-styling';
+import Ada from '../../../assets/img/ada.png';
+import { useColorModeValue } from '@chakra-ui/react';
+
+const qrCode = new QRCodeStyling({
+ width: 150,
+ height: 150,
+ image: Ada,
+ dotsOptions: {
+ color: '#319795',
+ type: 'dots',
+ },
+ cornersSquareOptions: { type: 'extra-rounded', color: '#DD6B20' },
+ imageOptions: {
+ crossOrigin: 'anonymous',
+ margin: 8,
+ },
+});
+
+const QrCode = ({ value }) => {
+ const ref = React.useRef(null);
+ const bgColor = useColorModeValue('white', '#2D3748');
+ const contentColor = useColorModeValue(
+ { corner: '#DD6B20', dots: '#319795' },
+ { corner: '#FBD38D', dots: '#81E6D9' }
+ );
+
+ React.useEffect(() => {
+ qrCode.append(ref.current);
+ }, []);
+
+ React.useEffect(() => {
+ qrCode.update({
+ data: value,
+ backgroundOptions: {
+ color: bgColor,
+ },
+ dotsOptions: {
+ color: contentColor.dots,
+ },
+ cornersSquareOptions: { color: contentColor.corner },
+ });
+ }, [value, bgColor]);
+
+ return
;
+};
+
+export default QrCode;
diff --git a/packages/nami/src/ui/app/components/scrollbar.tsx b/packages/nami/src/ui/app/components/scrollbar.tsx
new file mode 100644
index 000000000..0d8ffdbdb
--- /dev/null
+++ b/packages/nami/src/ui/app/components/scrollbar.tsx
@@ -0,0 +1 @@
+export { Scrollbars } from 'react-custom-scrollbars-2';
diff --git a/packages/nami/src/ui/app/components/styles.css b/packages/nami/src/ui/app/components/styles.css
new file mode 100644
index 000000000..635c4d33b
--- /dev/null
+++ b/packages/nami/src/ui/app/components/styles.css
@@ -0,0 +1,15 @@
+.lineClamp {
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+}
+
+.lineClamp3 {
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+}
+
+body::-webkit-scrollbar {
+ display: none;
+}
diff --git a/packages/nami/src/ui/app/components/switchToLaceBanner.tsx b/packages/nami/src/ui/app/components/switchToLaceBanner.tsx
new file mode 100644
index 000000000..5a9b4fd7b
--- /dev/null
+++ b/packages/nami/src/ui/app/components/switchToLaceBanner.tsx
@@ -0,0 +1,222 @@
+import React from 'react';
+
+import { Box, Image, Text, useColorModeValue } from '@chakra-ui/react';
+import { Button as LaceButton } from '@lace/common';
+import { motion } from 'framer-motion';
+
+import LaceLogo from '../../../assets/img/lace.svg';
+import laceGradientBackground from '../../../assets/img/laceGradientBackground.png';
+import laceVideoBackground from '../../../assets/video/laceVideoBackground.mp4';
+import { Events } from '../../../features/analytics/events';
+import { useCaptureEvent } from '../../../features/analytics/hooks';
+import { useStoreActions, useStoreState } from '../../store';
+
+import LaceSecondaryButton from './laceSecondaryButton';
+
+export const getLaceVideoBackgroundSrc = () => {
+ return typeof chrome !== 'undefined' &&
+ chrome.runtime?.getURL('laceVideoBackground.mp4')
+ || laceVideoBackground;
+};
+
+interface Props {
+ switchWalletMode: () => Promise;
+}
+
+export const SwitchToLaceBanner = ({ switchWalletMode }: Props) => {
+ const [isLaceSwitchInProgress, setIsLaceSwitchInProgress] = [
+ useStoreState(
+ state => state.globalModel.laceSwitchStore.isLaceSwitchInProgress,
+ ),
+ useStoreActions(
+ actions => actions.globalModel.laceSwitchStore.setIsLaceSwitchInProgress,
+ ),
+ ];
+ const capture = useCaptureEvent();
+
+ const backgroundColor = useColorModeValue('white', 'blackAlpha.900');
+ const textColor = useColorModeValue('gray.900', 'white');
+
+ const handleSwitchWalletMode = async () => {
+ await switchWalletMode();
+ setIsLaceSwitchInProgress(false);
+ void capture(Events.SwitchToLaceModeBannerActivateLaceButtonClick);
+ };
+
+ const laceVideoBackgroundSrc = getLaceVideoBackgroundSrc();
+
+ return (
+ <>
+ {!isLaceSwitchInProgress && (
+
+ {
+ setIsLaceSwitchInProgress(true);
+ void capture(Events.SwitchToLaceModeBannerClick);
+ }}
+ >
+
+
+ Upgrade to Lace
+
+
+
+ )}
+ {isLaceSwitchInProgress && (
+
+
+ <>
+
+
+
+
+ Your Nami wallet evolved!
+
+
+
+ Enable Lace Mode to unlock access to new and exciting Web
+ 3 features
+
+
+ You can return to the "Nami Mode" at any time
+
+
+
+
+
+
+ Activate Lace Mode
+
+ {
+ setIsLaceSwitchInProgress(false);
+ void capture(
+ Events.SwitchToLaceModeBannerMaybeLaterButtonClick,
+ );
+ }}
+ >
+ Maybe later
+
+
+
+ >
+
+
+ )}
+ >
+ );
+};
diff --git a/packages/nami/src/ui/app/components/termsOfUse.tsx b/packages/nami/src/ui/app/components/termsOfUse.tsx
new file mode 100644
index 000000000..e19f3cf87
--- /dev/null
+++ b/packages/nami/src/ui/app/components/termsOfUse.tsx
@@ -0,0 +1,661 @@
+import React from 'react';
+import {
+ Box,
+ Text,
+ Modal,
+ ModalBody,
+ ModalContent,
+ ModalCloseButton,
+ ModalHeader,
+ ModalOverlay,
+ useDisclosure,
+ OrderedList,
+ ListItem,
+ Link,
+} from '@chakra-ui/react';
+import { Scrollbars } from './scrollbar';
+import { useCaptureEvent } from '../../../features/analytics/hooks';
+import { Events } from '../../../features/analytics/events';
+import { useOutsideHandles } from 'features/outside-handles-provider/useOutsideHandles';
+
+const TermsOfUse = React.forwardRef((props, ref) => {
+ const capture = useCaptureEvent();
+ const { openExternalLink } = useOutsideHandles();
+ const { isOpen, onOpen, onClose } = useDisclosure();
+ React.useImperativeHandle(ref, () => ({
+ openModal() {
+ onOpen();
+ },
+ closeModal() {
+ onClose();
+ },
+ }));
+ return (
+ {
+ capture(Events.SettingsTermsAndConditionsXClick);
+ onClose();
+ }}
+ isCentered
+ >
+
+
+
+ Terms of use
+
+
+
+
+ Last Updated: March 30, 2022
+
+ These Terms of Use (
+
+ "Terms"
+
+ ) set forth the binding legal agreement between you and Input
+ Output Global, Inc. (together with our subsidiaries and
+ affiliates, referred to as{' '}
+
+ "IOG,"
+ {' '}
+
+ "we,"
+ {' '}
+ or{' '}
+
+ "us"
+ {' '}
+ in this Agreement). These Terms govern your use of this website
+ and all of the related websites, mobile apps, products and
+ services offered by IOG and its affiliated entities including
+ our plug-ins and browser extensions (collectively, the{' '}
+
+ "Products"
+
+ ).
+
+
+ We encourage you to review these Terms carefully. By accessing
+ or using the Products in any way, including browsing any
+ IOG-owned website, you are agreeing to these Terms in their
+ entirety. If you do not agree to any of the Terms, you may not
+ use the Products.
+
+ 1. Using the Products.
+
+
+
+ Who can use the Products.
+ {' '}
+ You must be at least the age of majority in the jurisdiction
+ where you live to use the Products.
+
+
+
+ Product Changes.
+ {' '}
+ We reserve the right to make changes or updates to Products,
+ including content and formatting, at any time without notice.
+ We reserve the right to terminate or restrict access to the
+ Products (including any accounts you may have created through
+ your use of the Products) for any reason whatsoever at our
+ sole discretion.
+
+
+
+ Privacy Policy.
+ {' '}
+ Our privacy practices are set forth in our{' '}
+
+ openExternalLink(
+ 'https://static.iohk.io/terms/iog-privacy-policy.pdf'
+ )
+ }
+ >
+ Privacy Policy
+
+ . By using the Products in any way, you understand and
+ acknowledge that the terms of the Privacy Policy apply to you.
+
+
+
+ Additional Terms.
+ {' '}
+ Specific terms and conditions may apply to specific content,
+ products, materials, services or information contained on or
+ available through various Products or transactions concluded
+ through the Products. Such specific terms may be in addition
+ to these Terms or, where inconsistent with these Terms, only
+ to the extent the content or intent of such specific terms is
+ inconsistent with these Terms, such specific terms will
+ supersede these Terms.
+
+
+
+ Feedback.
+ {' '}
+ We welcome your feedback and suggestions about how to improve
+ the Products. Feel free to submit feedback at{' '}
+ openExternalLink('https://iohk.io/en/contact/')}
+ >
+ https://iohk.io/en/contact/
+
+ . By submitting feedback in this or in any other manner to us,
+ you grant us the right, at our discretion, to use, disclose
+ and otherwise exploit the feedback, in whole or part, without
+ any restriction or compensation to you, as further described
+ in Section 2(b) below.
+
+
+ 2. Your Content
+
+
+
+ Definition of Your Content.
+ {' '}
+ The Products may enable you to post materials, including
+ without limitation photos, profile pictures, messages,
+ comments, and testimonials. You may also post reviews of
+ third-party service providers, third-party products, or
+ third-party services. All materials that you post on the
+ Products will be referred to collectively as{' '}
+
+ "Your Content."
+
+
+
+
+ License and Permission to Use Your Content.
+ {' '}
+ You hereby grant to us and our affiliates, licensees and
+ sublicensees, without compensation to you or others, a
+ nonexclusive, perpetual, irrevocable, royalty-free, fully
+ paid-up, worldwide license (including the right to sublicense
+ through multiple tiers) to use, reproduce, process, adapt,
+ publicly perform, publicly display, modify, prepare derivative
+ works, publish, transmit and distribute Your Content, or any
+ portion thereof, throughout the world in any format, media or
+ distribution method (whether now known or hereafter created)
+ for the duration of any copyright or other rights in Your
+ Content. Such permission will be perpetual and may not be
+ revoked for any reason, to the maximum extent permitted by
+ law. Further, to the extent permitted under applicable law,
+ you waive and release and covenant not to assert any moral
+ rights that you may have in Your Content. If you identify
+ yourself by name or provide a picture or audio or video
+ recording of yourself, you further authorize us and our
+ affiliates, licensees and sublicensees, without compensation
+ to you or others, to reproduce, print, publish and disseminate
+ in any format or media (whether now known or hereafter
+ created) your name, voice and likeness throughout the world,
+ and such permission will be perpetual and cannot be revoked
+ for any reason, except as required by applicable law. You
+ further agree that we may use Your Content in any manner that
+ we deem appropriate or necessary, including but not limited to
+ IOG Business Purposes.{' '}
+
+ "IOG Business Purposes"
+ {' '}
+ means any use in connection with a Product or IOG cobranded
+ website, application, publication or service, or any use which
+ advertises, markets or promotes Products, the services or the
+ information within the Products, IOG, or its affiliates. IOG
+ Business Purpose specifically includes the use of Your Content
+ within the Products in connection with features and functions
+ offered by IOG to our users that enable them to view and
+ interact with Your Content (such as DApp reviews).
+
+
+
+ Ownership.
+ {' '}
+ We acknowledge and agree that you, or your licensors, as
+ applicable, retain ownership of any and all copyrights in Your
+ Content, subject to the non-exclusive rights granted to us in
+ the paragraph above, and that no ownership of such copyrights
+ is transferred to us under these Terms, except as may
+ otherwise be provided in these Terms or another agreement
+ between you and IOG.
+
+
+
+ Your Responsibilities for Your Content.
+ {' '}
+ By posting, uploading, or submitting Your Content to any
+ Products, you represent and warrant to us that you have the
+ ownership rights, or you have obtained all necessary licenses
+ or permissions from any relevant parties, to use Your Content
+ in this manner. This includes obtaining the right to grant us
+ the rights to use Your Content in accordance with these Terms.
+ You are in the best position to judge whether Your Content is
+ in violation of intellectual property or personal rights of
+ any third-party.{' '}
+
+ You accept full responsibility for avoiding infringement of
+ the intellectual property or personal rights of others in
+ connection with Your Content.
+ {' '}
+ You are responsible for ensuring that Your Content does not
+ violate any applicable law or regulation, including but not
+ limited to the intellectual property rights of any third
+ party. You agree to pay all royalties, fees, and any other
+ monies owed to any person by reason of Your Content.
+
+
+
+ Limits.
+ {' '}
+ We reserve the right to remove Your Content, in whole or part,
+ for any reason without notice. We do not guarantee that we
+ will publish any or all of Your Content.
+
+
+ 3. Our Content and Materials.
+
+
+
+ Definition of Our Content and Materials.
+ {' '}
+ All intellectual property in or related to the Products
+ (specifically including, but not limited to, our software, the
+ IOG marks, the IOG logos) (
+
+ "Our Content and Materials"
+
+ ) is the property of IOG.
+
+
+
+ Our License to You.
+ {' '}
+ Subject to these Terms of Use, including the restrictions
+ below, we grant you a limited non-exclusive license to use and
+ access Our Content and Materials in connection with your use
+ of the Products. Except as expressly agreed to otherwise by us
+ (such as your entering into another other agreement with us),
+ your use of the Products must be limited to personal,
+ non-commercial use. We may terminate this license at any time
+ for any reason. Except for the rights and license granted in
+ these Terms, we reserve all other rights and grant no other
+ rights or licenses, implied or otherwise. Notwithstanding the
+ foregoing, some content may be subject to open-source
+ licenses, in which case the specific license(s) mentioned in
+ connection with such content shall apply.
+
+
+
+ Restrictions.
+ {' '}
+ Except as expressly provided in these Terms, you agree not to
+ use, modify, reproduce, distribute, sell, license, reverse
+ engineer, decompile, or otherwise exploit Our Content and
+ Materials without our express written permission. Your
+ permitted use of the Products expressly excludes commercial
+ use by you of any product descriptions for the benefit of
+ another merchant. You are expressly prohibited from any use of
+ data mining, robots, or similar data gathering and extraction
+ tools in your use of the Products. You may view and print a
+ reasonable number of copies of web pages located on the
+ Products for your own personal use, provided that you retain
+ all proprietary notices contained in the original materials,
+ including attribution to IOG. We have no obligation to delete
+ content that you personally may find objectionable or
+ offensive.
+
+
+
+ Ownership.
+ {' '}
+ You acknowledge and agree that the Products and IOG marks will
+ remain the property of IOG. The content, information and
+ services made available on the Products are protected by U.S.
+ and international copyright, trademark, and other laws, and
+ you acknowledge that these rights are valid and enforceable.
+ You acknowledge that you do not acquire any ownership rights
+ by using or interacting with the Products.
+
+
+ 4. Other Offerings on the Products.
+
+
+
+ Third-Party Services.
+ {' '}
+ Please note that the Products may enable access to third-party
+ content, products, and services, and may offer interactions
+ with third parties that we do not control (collectively{' '}
+
+ "Third-Party Services"
+
+ ). The availability of any Third-Party Services on the
+ Products does not imply our endorsement or verification of the
+ Third-Party Services. We assume no responsibility for, nor do
+ we endorse or verify the content, offerings or conduct of
+ third parties (including but not limited to the products or
+ services offered by third parties or the descriptions of the
+ products or services offered by third parties). We make no
+ warranties or representations with respect to the accuracy,
+ completeness or timeliness of any content posted on or in the
+ Products by anyone.
+
+
+
+ Third-Party Sites.
+ {' '}
+ The Products may contain links to other websites (the{' '}
+
+ "Third-Party Sites"
+
+ ) for your convenience. We do not control the linked websites
+ or the content provided through such Third-Party Sites. Your
+ use of Third-Party Sites is subject to the privacy practices
+ and terms of use established by the specific linked
+ Third-Party Site, and we disclaim all liability for such use.
+ The availability of such links does not indicate any approval
+ or endorsement by us.
+
+
+
+ 5. Reporting Violations of Your Intellectual Property Rights.
+
+
+ For information about how to submit a request for takedown if
+ you believe content on the Products infringes your intellectual
+ property rights, please read our{' '}
+
+ openExternalLink(
+ 'https://static.iohk.io/terms/iog-dmca-policy.pdf'
+ )
+ }
+ >
+ Digital Millennium Copyright Act (DMCA) Policy
+
+ . We endeavor to respond promptly to requests for content
+ removal, consistent with our policies described above and
+ applicable law.
+
+
+ 6. Disclaimers and Limitations of Liability.
+
+
+ PLEASE READ THIS SECTION CAREFULLY SINCE IT LIMITS THE LIABILITY
+ OF IOG ENTITIES TO YOU.
+
+
+ THE "IOG ENTITIES" MEANS IO GLOBAL, INC., IOG SINGAPORE PTE.
+ LTD. AND ANY SUBSIDIARIES, AFFILIATES, RELATED COMPANIES,
+ SUPPLIERS, LICENSORS AND PARTNERS, AND THE OFFICERS, DIRECTORS,
+ EMPLOYEES, AGENTS AND REPRESENTATIVES OF EACH OF THEM. EACH
+ PROVISION BELOW APPLIES TO THE MAXIMUM EXTENT PERMITTED UNDER
+ APPLICABLE LAW:
+
+
+
+ WE ARE PROVIDING YOU THE PRODUCTS, SERVICES, INFORMATION, OUR
+ CONTENT AND MATERIALS, PRODUCT DESCRIPTIONS, AND THIRD-PARTY
+ CONTENT ON AN "AS IS" AND "AS AVAILABLE" BASIS, WITHOUT
+ WARRANTY OF ANY KIND, EXPRESS OR IMPLIED. WITHOUT LIMITING THE
+ FOREGOING, THE IOG ENTITIES EXPRESSLY DISCLAIM ANY AND ALL
+ WARRANTIES AND CONDITIONS OF MERCHANTABILITY, TITLE, ACCURACY
+ AND COMPLETENESS, UNINTERRUPTED OR ERROR-FREE SERVICE, FITNESS
+ FOR A PARTICULAR PURPOSE, QUIET ENJOYMENT, AND
+ NON-INFRINGEMENT, AND ANY WARRANTIES ARISING OUT OF COURSE OF
+ DEALING OR TRADE USAGE. NOTHING CONTAINED IN THE PRODUCTS IS
+ INTENDED TO BE LEGAL, FINANCIAL, OR TAX ADVICE.
+
+
+ THE IOG ENTITIES MAKE NO PROMISES WITH RESPECT TO, AND
+ EXPRESSLY DISCLAIM ALL LIABILITY, TO THE MAXIMUM EXTENT
+ PERMITTED BY LAW, FOR: (i) CONTENT POSTED BY ANY THIRD-PARTY
+ ON THE PRODUCTS, (ii) THE PRODUCT DESCRIPTIONS OR PRODUCTS,
+ (iii) THIRD-PARTY SITES AND ANY THIRD-PARTY PRODUCT OR SERVICE
+ LISTED ON OR ACCESSIBLE TO YOU THROUGH THE IOG PRODUCTS, AND
+ (iv) THE QUALITY OR CONDUCT OF ANY THIRD PARTY YOU ENCOUNTER
+ IN CONNECTION WITH YOUR USE OF THIS WEBSITE OR ANY IOG
+ PRODUCT.
+
+
+ THE IOG ENTITIES DO NOT WARRANT OR MAKE ANY REPRESENTATIONS AS
+ TO THE SECURITY OF ANY OF ITS WEBSITES. YOU ACKNOWLEDGE ANY
+ INFORMATION SENT THROUGH A WEBSITE MAY BE INTERCEPTED. THE IOG
+ ENTITIES DO NOT WARRANT THAT ITS WEBSITES OR THE SERVERS WHICH
+ MAKE THIS WEBSITE AVAILABLE OR ELECTRONIC COMMUNICATIONS SENT
+ BY IOG ENTITIES ARE FREE FROM VIRUSES OR ANY OTHER HARMFUL
+ ELEMENTS. THE IOG ENTITIES DO NOT WARRANT THAT ANY E-MAIL OR
+ OTHER ELECTRONIC CORRESPONDENCE BEING SENT TO IOG WILL BE
+ TIMELY RECEIVED OR PROCESSED. THE IOG ENTITIES SHALL IN NO
+ EVENT BE LIABLE FOR ANY CONSEQUENCES OF NOT TIMELY RECEIVING
+ OR PROCESSING ANY E-MAIL OR OTHER ELECTRONIC CORRESPONDENCE.
+
+
+ YOU AGREE THAT TO THE MAXIMUM EXTENT PERMITTED BY LAW, THE IOG
+ ENTITIES WILL NOT BE LIABLE TO YOU UNDER ANY THEORY OF
+ LIABILITY. WITHOUT LIMITING THE FOREGOING, YOU AGREE THAT THE
+ IOG ENTITIES SPECIFICALLY WILL NOT BE LIABLE FOR (i) ANY
+ INDIRECT, INCIDENTAL, CONSEQUENTIAL, SPECIAL, INCIDENTAL OR
+ EXEMPLARY DAMAGES, LOSS OF PROFITS, LOSS OF BUSINESS, BUSINESS
+ INTERRUPTION, REPUTATIONAL HARM, OR LOSS OF DATA (EVEN IF THE
+ IOG ENTITIES HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH
+ DAMAGES OR SUCH DAMAGES ARE FORESEEABLE) ARISING OUT OF AND IN
+ ANY WAY CONNECTED WITH YOUR USE OF, OR INABILITY TO USE, THIS
+ WEBSITE OR ANY IOG PRODUCTS OR (ii) ANY AMOUNT, IN THE
+ AGGREGATE, IN EXCESS OF ONE-HUNDRED DOLLARS (USD$100). YOUR
+ USE OF THE PRODUCTS, INFORMATION, OR SERVICES IS AT YOUR SOLE
+ RISK.
+
+
+ 7. Indemnification.
+
+ You agree to fully indemnify, defend, and hold the IOG Entities
+ and their directors, officers, employees, consultants, and other
+ representatives, harmless from and against any and all claims,
+ damages, losses, costs (including reasonable attorneys' fees),
+ and other expenses that arise directly or indirectly out of or
+ from: (a) your breach of any part of these Terms, including but
+ not limited to any policies referenced herein; (b) any
+ allegation that any materials you submit to us or transmit to
+ the Products infringe or otherwise violate the copyright,
+ patent, trademark, trade secret, or other intellectual property
+ or other rights of any third party; (c) your activities in
+ connection with the Products or other websites to which the
+ Products are linked; and/or (d) your negligent or willful
+ misconduct.
+
+ 8. Dispute Resolution.
+
+ If you have a dispute with IOG, you agree to contact us using
+ the form at{' '}
+ openExternalLink('https://iohk.io/en/contact/')}
+ >
+ https://iohk.io/en/contact/
+ {' '}
+ to attempt to resolve the issue informally first.
+
+ 9. Communications.
+
+ You are not required to agree to receive promotional text
+ messages, calls or prerecorded messages as a condition of using
+ the Products.
+ {' '}
+
+ By electing to submit your phone number to us and agreeing to
+ these Terms, you agree to receive communications from the IOG
+ Entities, including via text messages, calls, pre-recorded
+ messages, and push notifications, any of which may be generated
+ by automatic telephone dialing systems. These communications
+ include, for example, operational communications concerning your
+ account or use of the Products, updates concerning new and
+ existing features on the Products, communications concerning
+ promotions run by us or third parties, and news relating to the
+ Products and industry developments. Standard text message
+ charges applied by your telephone carrier may apply to text
+ messages we send. If you submit someone else's phone number or
+ email address to us to receive communications from the IOG
+ Entities, you represent and warrant that each person for whom
+ you provide a phone number or email address has consented to
+ receive communications from IOG.
+
+ If you wish to stop receiving promotional emails or
+ promotional text messages, we provide the following methods
+ for you to opt-out or unsubscribe: (a) follow the instructions
+ we provide in the email or initial text message for that
+ category of promotional emails or text messages or (b) if you
+ have an account on the Products, you may opt-out or
+ unsubscribe using your settings.
+
+
+ 10. Miscellaneous.
+
+
+
+ Application Provider Terms.
+ {' '}
+ If you access the Products through an IOG application, you
+ acknowledge that these Terms are between you and IOG only, and
+ not with an application service or application platform
+ provider (such as Apple, Inc., or Google Inc.), which may
+ provide you the application subject to its own terms of use.
+
+
+
+ Controlling Law and Jurisdiction.
+ {' '}
+ These Terms will be interpreted in accordance with the laws of
+ the State of New York and the United States of America,
+ without regard to their conflict-of-law provisions. You and
+ IOG agree to submit to the personal jurisdiction of a federal
+ or state court located in New York, New York for any actions
+ for which the dispute resolution provision, as set forth in
+ Section 8, does not resolve.
+
+
+
+ Changes.
+ {' '}
+ We reserve the right to change the terms of these Terms,
+ consistent with applicable law. You agree that your continued
+ use of the Products after such changes become effective
+ constitutes your acceptance of the changes. If you do not
+ agree with any updates to these Terms, you may not continue to
+ use the Products. Be sure to return to this page periodically
+ to ensure your familiarity with the most current version of
+ the Terms of Use. Any changes to the Terms will be effective
+ on a going forward basis.
+
+
+
+ Languages.
+ {' '}
+ The English version of these Terms will be the binding version
+ and all communications, notices, and other actions and
+ proceedings relating to these Terms will be made and conducted
+ in English, even if we choose to provide translations of these
+ Terms into the native languages in certain countries. To the
+ extent allowed by law, any inconsistencies among the different
+ translations will be resolved in favor of the English version.
+
+
+
+ Assignment.
+ {' '}
+ No terms of these Terms, nor any right, obligation, or remedy
+ hereunder is assignable, transferable, delegable, or
+ sublicensable by you except with IOG's prior written consent,
+ and any attempted assignment, transfer, delegation, or
+ sublicense shall be null and void. IOG may assign, transfer,
+ or delegate these Terms or any right or obligation or remedy
+ hereunder in its sole discretion.
+
+
+
+ Waiver.
+ {' '}
+ Our failure to assert a right or provision under these Terms
+ will not constitute a waiver of such right or provision.
+
+
+
+ Headings.
+ {' '}
+ Any heading, caption, or section title contained is inserted
+ only as a matter of convenience and in no way defines or
+ explains any section or provision hereof.
+
+
+
+ Further Assurances.
+ {' '}
+ You agree to execute a hard copy of these Terms and any other
+ documents, and take any actions at our expense that we may
+ request to confirm and effect the intent of these Terms and
+ any of your rights or obligations under these Terms.
+
+
+
+ Entire Agreement and Severability.
+ {' '}
+ This Agreement supersedes all prior terms, agreements,
+ discussions and writings regarding the Products and
+ constitutes the entire agreement between you and us regarding
+ the Products. If any part of these Terms is found to be
+ unenforceable, then that part will not affect the
+ enforceability of the remaining parts of the Agreement, which
+ will remain in full force and effect.
+
+
+
+ Survival.
+ {' '}
+ The following provisions will survive expiration or
+ termination of these Terms: Section 2 (Your Content), Section
+ 3(c)(Restrictions) and 3(d)(Ownership), Section 6 (Disclaimers
+ and Limitations of Liability), Section 7 (Indemnification),
+ Section 8 (Dispute Resolution) and Section 10 (Miscellaneous).
+
+
+
+
+ Contact.
+ {' '}
+ Feel free to{' '}
+ openExternalLink('https://iohk.io/en/contact/')}
+ >
+ contact us
+ {' '}
+ with any questions about these Terms. You can also write to
+ us at:
+
+ Input Output Global, Inc.
+ 2015 Ionosphere Street, Ste 201
+ Longmont, CO 80504
+ Attn: Legal
+
+
+
+
+
+
+
+
+ );
+});
+
+export default TermsOfUse;
diff --git a/packages/nami/src/ui/app/components/transaction.tsx b/packages/nami/src/ui/app/components/transaction.tsx
new file mode 100644
index 000000000..090c582a4
--- /dev/null
+++ b/packages/nami/src/ui/app/components/transaction.tsx
@@ -0,0 +1,594 @@
+import { ExternalLinkIcon } from '@chakra-ui/icons';
+import React from 'react';
+import { updateTxInfo } from '../../../api/extension';
+import UnitDisplay from './unitDisplay';
+import {
+ Box,
+ Link,
+ Text,
+ AccordionButton,
+ AccordionIcon,
+ AccordionItem,
+ AccordionPanel,
+ VStack,
+ Icon,
+ useColorModeValue,
+ Skeleton,
+} from '@chakra-ui/react';
+import { compileOutputs } from '../../../api/util';
+import TimeAgo from 'javascript-time-ago';
+import en from 'javascript-time-ago/locale/en';
+import ReactTimeAgo from 'react-time-ago';
+import { Button } from '@chakra-ui/react';
+import ReactDOMServer from 'react-dom/server';
+import AssetsPopover from './assetPopoverDiff';
+import AssetFingerprint from '@emurgo/cip14-js';
+import { hexToAscii } from '../../../api/util';
+import { NETWORK_ID } from '../../../config/config';
+import {
+ FaCoins,
+ FaPiggyBank,
+ FaTrashAlt,
+ FaRegEdit,
+ FaUserCheck,
+ FaUsers,
+ FaRegFileCode,
+} from 'react-icons/fa';
+import { IoRemoveCircleSharp } from 'react-icons/io5';
+import {
+ TiArrowForward,
+ TiArrowBack,
+ TiArrowShuffle,
+ TiArrowLoop,
+} from 'react-icons/ti';
+import { GiAnvilImpact } from 'react-icons/gi';
+import { useCaptureEvent } from '../../../features/analytics/hooks';
+import { Events } from '../../../features/analytics/events';
+import { useStoreState } from '../../store';
+
+TimeAgo.addDefaultLocale(en);
+
+const txTypeColor = {
+ self: 'gray.500',
+ internalIn: 'teal.500',
+ externalIn: 'teal.500',
+ internalOut: 'orange.500',
+ externalOut: 'orange.500',
+ withdrawal: 'yellow.400',
+ delegation: 'purple.500',
+ stake: 'cyan.700',
+ unstake: 'red.400',
+ poolUpdate: 'green.400',
+ poolRetire: 'red.400',
+ mint: 'cyan.500',
+ multisig: 'pink.400',
+ contract: 'teal.400',
+};
+
+const txTypeLabel = {
+ withdrawal: 'Withdrawal',
+ delegation: 'Delegation',
+ stake: 'Stake Registration',
+ unstake: 'Stake Deregistration',
+ poolUpdate: 'Pool Update',
+ poolRetire: 'Pool Retire',
+ mint: 'Minting',
+ multisig: 'Multi-signatures',
+ contract: 'Contract',
+};
+
+const useIsMounted = () => {
+ const isMounted = React.useRef(false);
+ React.useEffect(() => {
+ isMounted.current = true;
+ return () => (isMounted.current = false);
+ }, []);
+ return isMounted;
+};
+
+const Transaction = ({
+ txHash,
+ detail,
+ currentAddr,
+ addresses,
+ network,
+ onLoad,
+ cardanoCoin
+}) => {
+ const isMounted = useIsMounted();
+ const [displayInfo, setDisplayInfo] = React.useState(
+ genDisplayInfo(txHash, detail, currentAddr, addresses),
+ );
+
+ const colorMode = {
+ iconBg: useColorModeValue('white', 'gray.800'),
+ txBg: useColorModeValue('teal.50', 'gray.700'),
+ txBgHover: useColorModeValue('teal.100', 'gray.600'),
+ assetsBtnHover: useColorModeValue('teal.200', 'gray.700'),
+ };
+
+ const getTxDetail = async () => {
+ if (!displayInfo) {
+ let txDetail = await updateTxInfo(txHash);
+ onLoad(txHash, txDetail);
+ if (!isMounted.current) return;
+ setDisplayInfo(genDisplayInfo(txHash, txDetail, currentAddr, addresses));
+ }
+ };
+
+ React.useEffect(() => {
+ getTxDetail();
+ });
+
+ return (
+
+
+ {displayInfo ? (
+
+
+
+ ) : (
+
+ )}
+ {displayInfo ? (
+
+
+
+
+
+ {displayInfo.lovelace ? (
+ = 0
+ ? txTypeColor.externalIn
+ : txTypeColor.externalOut
+ }
+ quantity={displayInfo.lovelace}
+ decimals={6}
+ symbol={cardanoCoin.symbol}
+ />
+ ) : displayInfo.extra.length ? (
+
+ {getTxExtra(displayInfo.extra)}
+
+ ) : (
+ ''
+ )}
+ {!['internalIn', 'externalIn'].includes(displayInfo.type) ? (
+
+ Fee:{' '}
+
+ {parseInt(displayInfo.detail.info.deposit) ? (
+ <>
+ {parseInt(displayInfo.detail.info.deposit) > 0
+ ? ' & Deposit: '
+ : ' & Refund: '}
+ 0
+ ? displayInfo.detail.info.deposit
+ : parseInt(displayInfo.detail.info.deposit) * -1
+ }
+ decimals={6}
+ symbol={cardanoCoin.symbol}
+ />
+ >
+ ) : (
+ ''
+ )}
+
+ ) : (
+ ''
+ )}
+
+ {displayInfo.assets.length > 0 ? (
+
+
+
+
+
+ ) : (
+ ''
+ )}
+
+
+
+ ) : (
+
+ )}
+
+ {displayInfo && (
+
+ )}
+
+
+
+
+
+
+
+ );
+};
+
+const TxIcon = ({ txType, extra }) => {
+ const icons = {
+ self: TiArrowLoop,
+ internalIn: TiArrowShuffle,
+ externalIn: TiArrowForward,
+ internalOut: TiArrowShuffle,
+ externalOut: TiArrowBack,
+ withdrawal: FaCoins,
+ delegation: FaPiggyBank,
+ stake: FaUserCheck,
+ unstake: IoRemoveCircleSharp,
+ poolUpdate: FaRegEdit,
+ poolRetire: FaTrashAlt,
+ mint: GiAnvilImpact,
+ multisig: FaUsers,
+ contract: FaRegFileCode,
+ };
+
+ if (extra.length) txType = extra[0];
+
+ let style;
+ switch (txType) {
+ case 'externalIn':
+ style = { transform: 'rotate(90deg)' };
+ break;
+ case 'internalOut':
+ style = { transform: 'rotate(180deg)' };
+ break;
+ default:
+ style = {};
+ }
+
+ return (
+
+ );
+};
+
+const TxDetail = ({ displayInfo, network }) => {
+ const capture = useCaptureEvent();
+ const colorMode = {
+ extraDetail: useColorModeValue('black', 'white'),
+ };
+
+ return (
+ <>
+
+
+
+ Transaction ID
+
+
+ {
+ switch (network.id) {
+ case NETWORK_ID.mainnet:
+ return 'https://cardanoscan.io/transaction/';
+ case NETWORK_ID.preprod:
+ return 'https://testnet.cardanoscan.io/transaction/';
+ case NETWORK_ID.preview:
+ return 'https://preview.cexplorer.io/tx/';
+ case NETWORK_ID.testnet:
+ return 'https://testnet.cexplorer.io/tx/';
+ }
+ })() + displayInfo.txHash
+ }
+ isExternal
+ onClick={() => {
+ capture(Events.ActivityActivityDetailTransactionHashClick);
+ }}
+ >
+ {displayInfo.txHash}
+
+ {displayInfo.detail.metadata.length > 0 ? (
+ viewMetadata(displayInfo.detail.metadata)}
+ >
+ See Metadata
+
+ ) : (
+ ''
+ )}
+
+
+
+
+ {displayInfo.timestamp}
+
+
+
+ {displayInfo.extra.length > 0 ? (
+
+
+
+ Transaction Extra
+
+
+
+ {getTxExtra(displayInfo.extra)}
+
+
+
+
+ ) : (
+ ''
+ )}
+ >
+ );
+};
+
+const genDisplayInfo = (txHash, detail, currentAddr, addresses) => {
+ if (!detail || !detail.info || !detail.utxos || !detail.block) {
+ return null;
+ }
+
+ const type = getTxType(currentAddr, addresses, detail.utxos);
+ const date = dateFromUnix(detail.block.time);
+ const amounts = calculateAmount(
+ currentAddr,
+ detail.utxos,
+ detail.info.valid_contract,
+ );
+ const assets = amounts.filter(amount => amount.unit !== 'lovelace');
+ const lovelace = BigInt(
+ amounts.find(amount => amount.unit === 'lovelace').quantity,
+ );
+
+ return {
+ txHash: txHash,
+ detail: detail,
+ date: date,
+ timestamp: getTimestamp(date),
+ type: type,
+ extra: getExtra(detail.info, type),
+ amounts: amounts,
+ lovelace: ['internalIn', 'externalIn', 'multisig'].includes(type)
+ ? lovelace
+ : lovelace +
+ BigInt(detail.info.fees) +
+ (parseInt(detail.info.deposit) > 0
+ ? BigInt(detail.info.deposit)
+ : BigInt(0)),
+ assets: assets.map(asset => {
+ const _policy = asset.unit.slice(0, 56);
+ const _name = asset.unit.slice(56);
+ const fingerprint = new AssetFingerprint(
+ Buffer.from(_policy, 'hex'),
+ Buffer.from(_name, 'hex'),
+ ).fingerprint();
+
+ return {
+ unit: asset.unit,
+ quantity: asset.quantity,
+ policy: _policy,
+ name: hexToAscii(_name),
+ fingerprint,
+ };
+ }),
+ };
+};
+
+const getTxType = (currentAddr, addresses, uTxOList) => {
+ let inputsAddr = uTxOList.inputs.map(utxo => utxo.address);
+ let outputsAddr = uTxOList.outputs.map(utxo => utxo.address);
+
+ if (inputsAddr.every(addr => addr === currentAddr)) {
+ // sender
+ return outputsAddr.every(addr => addr === currentAddr)
+ ? 'self'
+ : outputsAddr.some(
+ addr => addresses.includes(addr) && addr !== currentAddr,
+ )
+ ? 'internalOut'
+ : 'externalOut';
+ } else if (inputsAddr.every(addr => addr !== currentAddr)) {
+ // receiver
+ return inputsAddr.some(addr => addresses.includes(addr))
+ ? 'internalIn'
+ : 'externalIn';
+ }
+ // multisig
+ return 'multisig';
+};
+
+const dateFromUnix = unixTimestamp => {
+ return new Date(unixTimestamp * 1000);
+};
+
+const getTimestamp = date => {
+ const zeroLead = str => ('0' + str).slice(-2);
+
+ return `${date.getFullYear()}-${zeroLead(date.getMonth() + 1)}-${zeroLead(
+ date.getDate(),
+ )} ${zeroLead(date.getHours())}:${zeroLead(date.getMinutes())}:${zeroLead(
+ date.getSeconds(),
+ )}`;
+};
+
+const calculateAmount = (currentAddr, uTxOList, validContract = true) => {
+ let inputs = compileOutputs(
+ uTxOList.inputs.filter(
+ input =>
+ input.address === currentAddr && !(input.collateral && validContract),
+ ),
+ );
+ let outputs = compileOutputs(
+ uTxOList.outputs.filter(
+ output =>
+ output.address === currentAddr && !(output.collateral && validContract),
+ ),
+ );
+ let amounts = [];
+
+ while (inputs.length) {
+ let input = inputs.pop();
+ let outputIndex = outputs.findIndex(amount => amount.unit === input.unit);
+ let qty;
+
+ if (outputIndex > -1) {
+ qty =
+ (BigInt(input.quantity) - BigInt(outputs[outputIndex].quantity)) *
+ BigInt(-1);
+ outputs.splice(outputIndex, 1);
+ } else {
+ qty = BigInt(input.quantity) * BigInt(-1);
+ }
+
+ if (qty !== BigInt(0) || input.unit === 'lovelace')
+ amounts.push({
+ unit: input.unit,
+ quantity: qty,
+ });
+ }
+
+ return amounts.concat(outputs);
+};
+
+const getExtra = (info, txType) => {
+ let extra = [];
+ if (info.redeemer_count) {
+ extra.push('contract');
+ } else if (txType === 'multisig') {
+ extra.push('multisig');
+ }
+ if (info.withdrawal_count && txType === 'self') extra.push('withdrawal');
+ if (info.delegation_count) extra.push('delegation');
+ if (info.asset_mint_or_burn_count) extra.push('mint');
+ if (info.stake_cert_count && parseInt(info.deposit) >= 0) extra.push('stake');
+ if (info.stake_cert_count && parseInt(info.deposit) < 0)
+ extra.push('unstake');
+ if (info.pool_retire_count) extra.push('poolRetire');
+ if (info.pool_update_count) extra.push('poolUpdate');
+
+ return extra;
+};
+
+const viewMetadata = metadata => {
+ const HighlightJson = () => (
+
+
+ Metadata
+
+
+
+
+ {JSON.stringify(
+ metadata.map(m => ({ [m.label]: m.json_metadata })),
+ null,
+ 2,
+ )}
+
+
+
+
+ );
+ const newTab = window.open();
+ newTab.document.write(ReactDOMServer.renderToString( ));
+ newTab.document.close();
+};
+
+const getTxExtra = extra =>
+ extra.map((item, index, array) =>
+ index < array.length - 1 ? txTypeLabel[item] + ', ' : txTypeLabel[item],
+ );
+
+export default Transaction;
diff --git a/packages/nami/src/ui/app/components/transactionBuilder.tsx b/packages/nami/src/ui/app/components/transactionBuilder.tsx
new file mode 100644
index 000000000..535107ac8
--- /dev/null
+++ b/packages/nami/src/ui/app/components/transactionBuilder.tsx
@@ -0,0 +1,670 @@
+import React from 'react';
+
+import { CheckIcon, WarningIcon } from '@chakra-ui/icons';
+import {
+ Box,
+ Link,
+ Text,
+ Modal,
+ ModalOverlay,
+ ModalContent,
+ ModalHeader,
+ ModalBody,
+ ModalCloseButton,
+ useDisclosure,
+ Button,
+ useToast,
+ Icon,
+ UnorderedList,
+ ListItem,
+ InputGroup,
+ InputRightElement,
+ Input,
+ Tooltip,
+} from '@chakra-ui/react';
+import { Wallet } from '@lace/cardano';
+import { FaRegFileCode } from 'react-icons/fa';
+import { GoStop } from 'react-icons/go';
+
+import { useCollateral } from '../../../adapters/collateral';
+import { useDelegation } from '../../../adapters/delegation';
+import { ERROR } from '../../../config/config';
+import { Events } from '../../../features/analytics/events';
+import { useCaptureEvent } from '../../../features/analytics/hooks';
+import { useOutsideHandles } from '../../../features/outside-handles-provider/useOutsideHandles';
+
+import ConfirmModal from './confirmModal';
+import UnitDisplay from './unitDisplay';
+
+type States = 'DONE' | 'EDITING' | 'ERROR' | 'LOADING';
+const PoolStates: Record = {
+ LOADING: 'LOADING',
+ ERROR: 'ERROR',
+ EDITING: 'EDITING',
+ DONE: 'DONE',
+};
+
+interface PoolDisplayValue {
+ ticker: string;
+ name: string;
+ id: string;
+ error?: string;
+ state: States;
+ showTooltip: boolean;
+}
+
+const poolDefaultValue: PoolDisplayValue = {
+ ticker: '',
+ name: '',
+ id: '',
+ error: '',
+ state: PoolStates.EDITING,
+ showTooltip: false,
+};
+
+const poolRightElementStyle = (pool: Readonly) => {
+ if (pool.state === PoolStates.DONE || pool.state === PoolStates.ERROR) {
+ return {
+ width: 'auto',
+ h: 'fit-content',
+ top: '8px',
+ right: '8px',
+ };
+ }
+
+ return {
+ width: '4.5rem',
+ h: 'fit-content',
+ top: '4px',
+ };
+};
+
+const poolHasTicker = (pool: Readonly): boolean => {
+ return pool.state === PoolStates.DONE && Boolean(pool.ticker);
+};
+
+const poolTooltipMessage = (
+ pool: Readonly,
+): string | undefined => {
+ if (pool.state !== PoolStates.DONE) {
+ return undefined;
+ }
+
+ const ticker = pool.ticker ? pool.ticker : '-';
+ const name = pool.name ? pool.name : '-';
+
+ return `${ticker} / ${name}`;
+};
+
+const TransactionBuilder = React.forwardRef(
+ (undefined, ref) => {
+ const capture = useCaptureEvent();
+ const {
+ isInitializingCollateral,
+ initializeCollateralTx: initializeCollateral,
+ collateralFee,
+ inMemoryWallet,
+ cardanoCoin,
+ buildDelegation,
+ setSelectedStakePool,
+ delegationTxFee,
+ isBuildingTx,
+ stakingError,
+ passwordUtil: { setPassword },
+ signAndSubmitTransaction,
+ getStakePoolInfo,
+ submitCollateralTx,
+ withSignTxConfirmation,
+ resetDelegationState,
+ hasNoFunds,
+ openExternalLink
+ } = useOutsideHandles();
+ const { initDelegation, stakeRegistration } = useDelegation({
+ inMemoryWallet,
+ buildDelegation,
+ setSelectedStakePool,
+ });
+ const { hasCollateral, reclaimCollateral, submitCollateral } =
+ useCollateral({
+ inMemoryWallet,
+ submitCollateralTx,
+ withSignTxConfirmation,
+ });
+ const toast = useToast();
+ const {
+ isOpen: isOpenCol,
+ onOpen: onOpenCol,
+ onClose: onCloseCol,
+ } = useDisclosure();
+ const [isLoading, setIsLoading] = React.useState(false);
+ const [data, setData] = React.useState<{
+ error?: string;
+ pool: PoolDisplayValue;
+ }>({
+ error: '',
+ pool: { ...poolDefaultValue },
+ });
+ const delegationRef = React.useRef();
+ const undelegateRef = React.useRef();
+ const collateralRef = React.useRef();
+
+ const prepareDelegationTx = async () => {
+ if (data.pool.id === '') return;
+
+ setData(d => ({
+ ...d,
+ pool: {
+ ...d.pool,
+ state: PoolStates.LOADING,
+ },
+ }));
+
+ try {
+ let poolId;
+ try {
+ poolId = Wallet.Cardano.PoolId(data.pool.id);
+ } catch {
+ throw new Error('Stake pool not found');
+ }
+
+ const [pool] = await getStakePoolInfo(poolId).catch(() => {
+ throw new Error('Stake pool not found');
+ });
+
+ if (!pool) throw new Error('Stake pool not found');
+
+ await initDelegation(pool).catch(() => {
+ throw new Error(
+ 'Transaction not possible (maybe insufficient balance)',
+ );
+ });
+ setData(d => ({
+ ...d,
+ pool: {
+ ticker: pool.metadata?.ticker!,
+ name: pool.metadata?.name!,
+ id: pool.id.toString(),
+ state: PoolStates.DONE,
+ showTooltip: false,
+ },
+ }));
+ } catch (error_) {
+ console.log(error_);
+ setData(d => ({
+ ...d,
+ pool: {
+ ...d.pool,
+ error: error_.message,
+ state: PoolStates.ERROR,
+ },
+ }));
+ }
+ };
+
+ React.useImperativeHandle(ref, () => ({
+ initDelegation: async () => {
+ delegationRef.current.openModal();
+ if (hasNoFunds) {
+ setData(d => ({
+ ...d,
+ error: 'Transaction not possible (maybe insufficient balance)',
+ }));
+ }
+ },
+ initUndelegate: async () => {
+ undelegateRef.current.openModal();
+ try {
+ await initDelegation();
+ } catch {
+ console.error(error);
+ setData(d => ({
+ ...d,
+ error: 'Transaction not possible (maybe account balance too low)',
+ }));
+ }
+ },
+ initCollateral: async () => {
+ if (hasCollateral) {
+ onOpenCol();
+ return;
+ }
+ collateralRef.current.openModal();
+
+ try {
+ await initializeCollateral();
+ } catch {
+ setData(d => ({
+ ...d,
+ error: 'Transaction not possible (maybe insufficient balance)',
+ }));
+ }
+ },
+ }));
+
+ const error = data.error || data.pool.error;
+
+ return (
+ <>
+ {
+ setData({ pool: { ...poolDefaultValue } });
+ resetDelegationState();
+ }}
+ setPassword={setPassword}
+ ready={!isBuildingTx && data.pool.state === PoolStates.DONE}
+ title="Delegate your funds"
+ sign={async () => {
+ try {
+ await signAndSubmitTransaction();
+ } catch (error) {
+ console.error(error);
+ }
+ }}
+ onConfirm={status => {
+ if (status === true) {
+ capture(Events.StakingConfirmClick);
+ toast({
+ title: 'Delegation submitted',
+ status: 'success',
+ duration: 4000,
+ });
+ } else {
+ toast({
+ title: 'Transaction failed',
+ description: stakingError,
+ status: 'error',
+ duration: 3000,
+ });
+ }
+ delegationRef.current.closeModal();
+ }}
+ info={
+
+
+ Enter the Stake Pool ID to delegate your funds and start
+ receiving rewards. Alternatively, head to{' '}
+ openExternalLink('https://pool.pm')}
+ >
+ https://pool.pm
+
+ , connect your Nami wallet and delegate to a stake pool of your
+ choice
+
+
+
+
+ {
+ setData(s => ({
+ ...s,
+ pool: {
+ ...s.pool,
+ id: e.target.value,
+ state: PoolStates.EDITING,
+ },
+ }));
+ }}
+ placeholder="Enter Pool ID"
+ onKeyDown={async e => {
+ if (e.key == 'Enter') await prepareDelegationTx();
+ }}
+ onMouseEnter={() => {
+ setData(s => ({
+ ...s,
+ pool: {
+ ...s.pool,
+ showTooltip: s.pool.state === PoolStates.DONE,
+ },
+ }));
+ }}
+ onMouseLeave={() => {
+ setData(s => ({
+ ...s,
+ pool: {
+ ...s.pool,
+ showTooltip: false,
+ },
+ }));
+ }}
+ />
+
+ {data.pool.state === PoolStates.EDITING && (
+ {
+ await prepareDelegationTx();
+ }}
+ >
+ Verify
+
+ )}
+ {data.pool.state === PoolStates.DONE && (
+
+ )}
+ {data.pool.state === PoolStates.ERROR && (
+
+ )}
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : (
+
+ {stakeRegistration && (
+
+ + Stake Registration:
+
+
+
+ )}
+
+ + Fee:
+
+
+
+
+
+ )}
+
+ }
+ ref={delegationRef}
+ />
+ {
+ setData({ pool: { ...poolDefaultValue } });
+ resetDelegationState();
+ }}
+ setPassword={setPassword}
+ ready={!isBuildingTx}
+ title="Stake deregistration"
+ sign={async () => {
+ try {
+ await signAndSubmitTransaction();
+ } catch (error) {
+ console.log(error);
+ }
+ }}
+ onConfirm={status => {
+ if (status === true) {
+ capture(Events.StakingUnstakeConfirmClick);
+ toast({
+ title: 'Deregistration submitted',
+ status: 'success',
+ duration: 4000,
+ });
+ } else {
+ toast({
+ title: 'Transaction failed',
+ description: stakingError,
+ status: 'error',
+ duration: 3000,
+ });
+ }
+ }}
+ info={
+
+
+
+
+ Going forward with deregistration will have the following
+ effects:
+
+
+ You will no longer receive rewards.
+
+ Rewards from the 2 previous epoch will be lost.
+
+ Full reward balance will be withdrawn.
+ The 2 ADA deposit will be refunded.
+
+ You will have to re-register and wait 20 days to receive
+ rewards again.
+
+
+
+ {data.error ? (
+
+ {data.error}
+
+ ) : (
+
+
+ + Stake Deregistration
+
+
+ + Fee:
+
+
+
+
+
+ )}
+
+ }
+ ref={undelegateRef}
+ />
+
+ Collateral
+
+ }
+ sign={async password => {
+ await submitCollateral(password);
+ }}
+ onCloseBtn={() => {
+ capture(Events.SettingsCollateralXClick);
+ }}
+ onConfirm={(status, signedTx) => {
+ if (status === true) {
+ capture(Events.SettingsCollateralConfirmClick);
+ toast({
+ title: 'Collateral added',
+ status: 'success',
+ duration: 4000,
+ });
+ } else if (signedTx === ERROR.fullMempool) {
+ toast({
+ title: 'Transaction failed',
+ description: 'Mempool full. Try again.',
+ status: 'error',
+ duration: 3000,
+ });
+ } else
+ toast({
+ title: 'Transaction failed',
+ status: 'error',
+ duration: 3000,
+ });
+ collateralRef.current.closeModal();
+ capture(Events.SettingsCollateralXClick);
+ }}
+ info={
+
+
+ Add collateral in order to interact with smart contracts on
+ Cardano:
+ The recommended collateral amount is
+
+ 5 {cardanoCoin.symbol}
+ {' '}
+ The amount is separated from your account balance, you can
+ choose to return it to your balance at any time.
+
+ openExternalLink('https://namiwallet.io')}
+ >
+ Read more
+
+
+
+ {data.error ? (
+
+ {data.error}
+
+ ) : (
+
+
+ + Fee:
+
+
+
+
+
+ )}
+
+ }
+ ref={collateralRef}
+ />
+
+ {
+ capture(Events.SettingsCollateralXClick);
+ onCloseCol();
+ }}
+ >
+
+
+
+ {' '}
+
+ Collateral
+
+
+
+
+
+ Your collateral amount is{' '}
+ 5 {cardanoCoin.symbol} .
+ When removing the collateral amount, it is returned to
+ the account balance, but disables interactions with smart
+ contracts.
+
+
+
+
+ {
+ setIsLoading(true);
+ await reclaimCollateral();
+ capture(Events.SettingsCollateralReclaimCollateralClick);
+ toast({
+ title: 'Collateral removed',
+ status: 'success',
+ duration: 4000,
+ });
+ onCloseCol();
+ capture(Events.SettingsCollateralXClick);
+ setIsLoading(false);
+ }}
+ >
+ Remove
+
+
+
+
+
+
+
+ >
+ );
+ },
+);
+
+export default TransactionBuilder;
diff --git a/packages/nami/src/ui/app/components/unitDisplay.tsx b/packages/nami/src/ui/app/components/unitDisplay.tsx
new file mode 100644
index 000000000..c7a725d0a
--- /dev/null
+++ b/packages/nami/src/ui/app/components/unitDisplay.tsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import { Box } from '@chakra-ui/react';
+import { displayUnit } from '../../../api/extension';
+
+const hideZero = (str) =>
+ str[str.length - 1] == 0 ? hideZero(str.slice(0, -1)) : str;
+
+const UnitDisplay = ({ quantity, decimals, symbol, hide = false, ...props }) => {
+ const num = displayUnit(quantity, decimals)
+ .toLocaleString('en-EN', { minimumFractionDigits: decimals })
+ .split('.')[0];
+ const subNum = displayUnit(quantity, decimals)
+ .toLocaleString('en-EN', { minimumFractionDigits: decimals })
+ .split('.')[1];
+ return (
+
+ {quantity || quantity === 0 || typeof quantity === 'bigint' ? (
+ <>
+ {num}
+ {(hide && hideZero(subNum).length <= 0) || decimals == 0 ? '' : '.'}
+
+ {hide ? hideZero(subNum) : subNum}
+ {' '}
+ >
+ ) : (
+ '... '
+ )}
+ {symbol}
+
+ );
+};
+
+export default UnitDisplay;
diff --git a/packages/nami/src/ui/app/components/userInfo.tsx b/packages/nami/src/ui/app/components/userInfo.tsx
new file mode 100644
index 000000000..efdf06a6e
--- /dev/null
+++ b/packages/nami/src/ui/app/components/userInfo.tsx
@@ -0,0 +1,103 @@
+import React from 'react';
+import {
+ StarIcon,
+} from '@chakra-ui/icons';
+import {
+ Box,
+ Stack,
+ Text,
+ MenuItem,
+} from '@chakra-ui/react';
+
+import AvatarLoader from '../components/avatarLoader';
+import UnitDisplay from '../components/unitDisplay';
+import { OutsideHandlesContextValue } from '../../../features/outside-handles-provider';
+
+type Props = Pick & {
+ onClick?: () => void;
+ avatar?: string;
+ name: string;
+ index: string;
+ balance?: string;
+ isActive?: boolean;
+ isHW?: boolean;
+};
+
+const hashCode = (s: string): number => {
+ let h;
+ for(let i = 0; i < s.length; i++)
+ h = Math.imul(31, h) + s.charCodeAt(i) | 0;
+ return h;
+}
+
+const UserInfo = ({ onClick, avatar, name, balance, isActive, isHW, cardanoCoin, index }: Props) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+ {name}
+
+ {balance ? (
+
+ ) : (
+ ...
+ )}
+
+ {isActive && (
+ <>
+
+
+
+ >
+ )}
+ {isHW && (
+
+ HW
+
+ )}
+
+
+
+ );
+};
+
+export default UserInfo;
diff --git a/packages/nami/src/ui/app/hw/connect-hw.tsx b/packages/nami/src/ui/app/hw/connect-hw.tsx
new file mode 100644
index 000000000..a067bca7e
--- /dev/null
+++ b/packages/nami/src/ui/app/hw/connect-hw.tsx
@@ -0,0 +1,160 @@
+/* eslint-disable @typescript-eslint/no-misused-promises, @typescript-eslint/no-unsafe-assignment */
+import type { ReactElement } from 'react';
+import React from 'react';
+
+import { ChevronRightIcon } from '@chakra-ui/icons';
+import { Box, Button, Icon, Image, Text, useColorMode } from '@chakra-ui/react';
+import { MdUsb } from 'react-icons/md';
+
+import { initHW } from '../../../api/extension';
+import LedgerLogo from '../../../assets/img/ledgerLogo.svg';
+import TrezorLogo from '../../../assets/img/trezorLogo.svg';
+import { HW } from '../../../config/config';
+import { Events } from '../../../features/analytics/events';
+import { useCaptureEvent } from '../../../features/analytics/hooks';
+
+import type { HardwareDeviceInfo } from './types';
+
+interface ConnectHWProps {
+ onConfirm: (data: Readonly) => void;
+}
+
+const MANUFACTURER: Record = {
+ ledger: 'Ledger',
+ trezor: 'SatoshiLabs',
+};
+
+export const ConnectHW = ({ onConfirm }: ConnectHWProps): ReactElement => {
+ const capture = useCaptureEvent();
+ const { colorMode } = useColorMode();
+ const [selected, setSelected] = React.useState('');
+ const [isLoading, setIsLoading] = React.useState(false);
+ const [error, setError] = React.useState('');
+
+ const handleSetSelectedDevice = async (): Promise => {
+ setIsLoading(true);
+ setError('');
+ try {
+ const device = await navigator.usb.requestDevice({
+ filters: [],
+ });
+ if (device.manufacturerName !== MANUFACTURER[selected]) {
+ setError(
+ `Device is not a ${selected === HW.ledger ? 'Ledger' : 'Trezor'}`,
+ );
+ setIsLoading(false);
+ return;
+ }
+ if (selected === HW.ledger) {
+ try {
+ await initHW({ device: selected, id: device.productId });
+ } catch {
+ setError('Cardano app not opened');
+ setIsLoading(false);
+ return;
+ }
+ }
+
+ void capture(Events.HWConnectNextClick);
+ onConfirm({ device: selected, id: device.productId });
+ return;
+ } catch {
+ setError('Device not found');
+ }
+
+ setIsLoading(false);
+ };
+
+ return (
+ <>
+
+ Connect Hardware Wallet
+
+
+
+ Choose the hardware wallet you would like to use with Nami.
+
+
+
+ {
+ setSelected(HW.trezor);
+ }}
+ data-testid="trezor"
+ >
+
+
+
+ {
+ setSelected(HW.ledger);
+ }}
+ data-testid="ledger"
+ >
+
+
+
+
+ {selected === HW.trezor && (
+
+ Connect your Trezor device directly to your computer. Unlock
+ the device and then click Continue.
+
+ )}
+ {selected === HW.ledger && (
+
+ Connect your Ledger device directly to your computer. Unlock
+ the device and open the Cardano app. Then click Continue.
+
+ )}
+ {selected && }
+ }
+ onClick={handleSetSelectedDevice}
+ >
+ Continue
+
+
+ {error && (
+
+ {error}
+
+ )}
+ >
+ );
+};
diff --git a/packages/nami/src/ui/app/hw/hw.stories.tsx b/packages/nami/src/ui/app/hw/hw.stories.tsx
new file mode 100644
index 000000000..42d3b3a1a
--- /dev/null
+++ b/packages/nami/src/ui/app/hw/hw.stories.tsx
@@ -0,0 +1,211 @@
+/* eslint-disable functional/immutable-data */
+import React from 'react';
+
+import { useColorMode } from '@chakra-ui/react';
+import type { Meta, StoryObj } from '@storybook/react';
+import { fn, userEvent, within } from '@storybook/test';
+
+import {
+ createHWAccounts,
+ getHwAccounts,
+ initHW,
+} from '../../../api/extension/api.mock';
+import { accountHW } from '../../../mocks/account.mock';
+
+import { HWConnectFlow } from './hw';
+
+const HWConnectStory = ({
+ colorMode,
+}: Readonly<{ colorMode: 'dark' | 'light' }>): React.ReactElement => {
+ const { setColorMode } = useColorMode();
+ setColorMode(colorMode);
+
+ return ;
+};
+
+declare global {
+ interface Window {
+ chrome: {
+ runtime: {
+ getURL: (path: string) => string;
+ };
+ };
+ }
+}
+
+const customViewports = {
+ fullScreen: {
+ name: 'Full Screen',
+ styles: {
+ width: '100vw',
+ height: '100vh',
+ },
+ },
+};
+
+const meta: Meta = {
+ title: 'Connect HW',
+ component: HWConnectStory,
+ argTypes: {
+ colorMode: {
+ control: {
+ type: 'select',
+ options: ['dark', 'light'],
+ },
+ },
+ },
+ parameters: {
+ viewport: {
+ viewports: customViewports,
+ defaultViewport: 'Full Screen',
+ },
+ },
+ beforeEach: () => {
+ getHwAccounts.mockImplementation(async () => {
+ return await Promise.resolve([accountHW]);
+ });
+ createHWAccounts.mockImplementation(async () => {
+ return await Promise.resolve([accountHW]);
+ });
+ window.chrome = {
+ runtime: {
+ getURL: (): string => {
+ return `Trezor/popup.html`;
+ },
+ },
+ };
+ navigator.usb.requestDevice = fn(() => {
+ return {
+ deviceClass: 0,
+ deviceProtocol: 0,
+ deviceSubclass: 0,
+ deviceVersionMajor: 2,
+ deviceVersionMinor: 0,
+ deviceVersionSubminor: 1,
+ manufacturerName: 'Ledger',
+ opened: false,
+ productId: 16_405,
+ productName: 'Nano X',
+ serialNumber: '0001',
+ usbVersionMajor: 2,
+ usbVersionMinor: 1,
+ usbVersionSubminor: 0,
+ vendorId: 11_415,
+ };
+ });
+
+ return () => {
+ getHwAccounts.mockClear();
+ createHWAccounts.mockClear();
+ };
+ },
+};
+
+type Story = StoryObj;
+export default meta;
+
+export const LayoutLight: Story = {
+ parameters: {
+ colorMode: 'light',
+ },
+};
+
+export const LayoutDark: Story = {
+ parameters: {
+ colorMode: 'dark',
+ },
+};
+
+export const SelectDeviceLight: Story = {
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+
+ await step('Select device', async () => {
+ const ledgerButton = await canvas.findByTestId('ledger');
+ await userEvent.click(ledgerButton);
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ },
+};
+
+export const SelectDeviceDark: Story = {
+ ...SelectDeviceLight,
+ parameters: {
+ colorMode: 'dark',
+ },
+};
+
+export const SelectAccountLight: Story = {
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+
+ await step('Select account', async () => {
+ const ledgerButton = await canvas.findByTestId('ledger');
+ await userEvent.click(ledgerButton);
+
+ const connectButton = await canvas.findByText('Continue');
+ await userEvent.click(connectButton);
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ },
+};
+
+export const SelectAccountDark: Story = {
+ ...SelectAccountLight,
+ parameters: {
+ colorMode: 'dark',
+ },
+};
+
+export const SuccessAndCloseLight: Story = {
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+
+ await step('Select account', async () => {
+ const ledgerButton = await canvas.findByTestId('ledger');
+ await userEvent.click(ledgerButton);
+
+ const continueButton = await canvas.findByText('Continue');
+ await userEvent.click(continueButton);
+ });
+
+ await step('Submit and close', async () => {
+ const connectButton = await canvas.findByText('Continue');
+ await userEvent.click(connectButton);
+ });
+ },
+ beforeEach: () => {
+ initHW.mockImplementation(async () => {
+ return await Promise.resolve({
+ getExtendedPublicKeys: () => {
+ return [
+ {
+ publicKeyHex:
+ '4335d502b9888f7c862174328c0fd1eb037931cb5cf46a6d3e307fac3f37d619',
+ chainCodeHex:
+ '07080cdbb22a73d911705592afdd82b1a51ce62e3f33d68499b3ffcff28cce3e',
+ },
+ ];
+ },
+ });
+ });
+
+ return () => {
+ initHW.mockReset();
+ };
+ },
+ parameters: {
+ colorMode: 'light',
+ },
+};
+
+export const SuccessAndCloseDark: Story = {
+ ...SuccessAndCloseLight,
+ parameters: {
+ colorMode: 'dark',
+ },
+};
diff --git a/packages/nami/src/ui/app/hw/hw.tsx b/packages/nami/src/ui/app/hw/hw.tsx
new file mode 100644
index 000000000..651ebdf10
--- /dev/null
+++ b/packages/nami/src/ui/app/hw/hw.tsx
@@ -0,0 +1,71 @@
+/* eslint-disable functional/immutable-data, @typescript-eslint/no-unsafe-assignment */
+import type { ReactElement } from 'react';
+import React, { useRef, useState } from 'react';
+
+import { Box, Image, useColorModeValue } from '@chakra-ui/react';
+
+import LogoOriginal from '../../../assets/img/logo.svg';
+import LogoWhite from '../../../assets/img/logoWhite.svg';
+
+import { ConnectHW } from './connect-hw';
+import { SelectAccounts } from './select-account';
+import { SuccessAndClose } from './success-and-close';
+
+import type { HardwareDeviceInfo } from './types';
+
+export const HWConnectFlow = (): ReactElement => {
+ const Logo = useColorModeValue(LogoOriginal, LogoWhite);
+ const cardColor = useColorModeValue('white', 'gray.900');
+ const backgroundColor = useColorModeValue('gray.200', 'gray.800');
+ const [tab, setTab] = useState(0);
+ const data = useRef({ device: '', id: 0 });
+
+ return (
+
+
+
+
+
+
+ {tab === 0 && (
+ {
+ data.current = { device, id };
+ setTab(1);
+ }}
+ />
+ )}
+ {tab === 1 && (
+ {
+ setTab(2);
+ }}
+ />
+ )}
+ {tab === 2 && }
+
+
+ );
+};
diff --git a/packages/nami/src/ui/app/hw/select-account.tsx b/packages/nami/src/ui/app/hw/select-account.tsx
new file mode 100644
index 000000000..2b95be9ba
--- /dev/null
+++ b/packages/nami/src/ui/app/hw/select-account.tsx
@@ -0,0 +1,202 @@
+/* eslint-disable @typescript-eslint/no-misused-promises, unicorn/no-null, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/strict-boolean-expressions */
+/* eslint-disable @typescript-eslint/no-unsafe-call, functional/immutable-data, @typescript-eslint/no-unsafe-assignment */
+
+import type { ReactElement } from 'react';
+import React from 'react';
+
+import { HARDENED } from '@cardano-foundation/ledgerjs-hw-app-cardano';
+import { ChevronRightIcon } from '@chakra-ui/icons';
+import { Box, Button, Checkbox, Text } from '@chakra-ui/react';
+import TrezorConnect from '@trezor/connect-web';
+
+import {
+ getHwAccounts,
+ indexToHw,
+ createHWAccounts,
+ initHW,
+} from '../../../api/extension';
+import { HW } from '../../../config/config';
+import { Events } from '../../../features/analytics/events';
+import { useCaptureEvent } from '../../../features/analytics/hooks';
+import { Scrollbars } from '../components/scrollbar';
+import TrezorWidget from '../components/TrezorWidget';
+
+import type { HardwareDeviceInfo } from './types';
+
+interface SelectAccountsProps {
+ data: HardwareDeviceInfo;
+ onConfirm: () => void;
+}
+
+export const SelectAccounts = ({
+ data,
+ onConfirm,
+}: Readonly): ReactElement | null => {
+ const capture = useCaptureEvent();
+ const [selected, setSelected] = React.useState({ 0: true });
+ const [error, setError] = React.useState('');
+ const trezorReference = React.useRef();
+ const [existing, setExisting] = React.useState({});
+ const [isLoading, setIsLoading] = React.useState(false);
+ const [isInit, setIsInit] = React.useState(false);
+
+ React.useEffect(() => {
+ const getExistingAccounts = async (): Promise => {
+ const hwAccounts = await getHwAccounts({
+ device: data.device,
+ id: data.id,
+ });
+
+ const existing = {};
+ // eslint-disable-next-line functional/no-loop-statements
+ for (const accountIndex of Object.keys(hwAccounts))
+ existing[indexToHw(accountIndex).account] = true;
+ setExisting(existing);
+ setIsInit(true);
+ };
+
+ getExistingAccounts().catch(error => {
+ console.error('Error getting existing accounts:', error);
+ });
+ }, []);
+
+ const handleSelectAccount = async (): Promise => {
+ setIsLoading(true);
+ setError('');
+ const accountIndexes = Object.keys(selected).filter(
+ currentState => selected[currentState] && !existing[currentState],
+ );
+ try {
+ const { device, id } = data;
+ // eslint-disable-next-line functional/no-let
+ let accounts;
+ if (device === HW.ledger) {
+ const appAda = await initHW({ device, id });
+ const ledgerKeys = await appAda.getExtendedPublicKeys({
+ paths: accountIndexes.map(index => [
+ HARDENED + 1852,
+ HARDENED + 1815,
+ HARDENED + Number.parseInt(index),
+ ]),
+ });
+ accounts = ledgerKeys.map(
+ (
+ {
+ publicKeyHex,
+ chainCodeHex,
+ }: Readonly<{ publicKeyHex: string; chainCodeHex: string }>,
+ index: number,
+ ) => ({
+ accountIndex: `${HW.ledger}-${id}-${accountIndexes[index]}`,
+ publicKey: publicKeyHex + chainCodeHex,
+ name: `Ledger ${Number.parseInt(accountIndexes[index]) + 1}`,
+ }),
+ );
+ } else if (device == HW.trezor) {
+ await initHW({ device, id });
+ const trezorKeys = await TrezorConnect.cardanoGetPublicKey({
+ bundle: accountIndexes.map(index => ({
+ path: `m/1852'/1815'/${Number.parseInt(index)}'`,
+ showOnTrezor: false,
+ })),
+ });
+ if (!trezorKeys.success) {
+ trezorReference.current.closeModal();
+ }
+ accounts = trezorKeys.payload.map(({ publicKey }, index) => ({
+ accountIndex: `${HW.trezor}-${id}-${accountIndexes[index]}`,
+ publicKey,
+ name: `Trezor ${Number.parseInt(accountIndexes[index]) + 1}`,
+ }));
+ trezorReference.current.closeModal();
+ }
+ await createHWAccounts(accounts);
+ void capture(Events.HWSelectAccountNextClick, {
+ numAccounts: accountIndexes.length,
+ });
+ onConfirm();
+ return;
+ } catch (error_) {
+ console.log(error_);
+ setError('An error occured');
+ }
+
+ setIsLoading(false);
+ };
+
+ if (!isInit) return null;
+
+ return (
+ <>
+
+ Select Accounts
+
+
+
+ Select the accounts you would like to import. Afterwards click Continue
+ and follow the instructions on your device.
+
+
+
+
+
+ {Object.keys(Array.from({ length: 50 })).map(accountIndex => (
+
+
+ {' '}
+ Account {Number.parseInt(accountIndex) + 1}{' '}
+ {accountIndex == 0 && ' - Default'}
+
+ >,
+ ): void => {
+ setSelected(currentState => ({
+ ...currentState,
+ [accountIndex]: event.target.checked,
+ }));
+ }}
+ ml="auto"
+ />
+
+ ))}
+
+
+ selected[currentState] && !existing[currentState],
+ ).length <= 0
+ }
+ isLoading={isLoading}
+ mt="auto"
+ rightIcon={ }
+ onClick={handleSelectAccount}
+ >
+ Continue
+
+ {error && (
+
+ {error}
+
+ )}
+
+ >
+ );
+};
diff --git a/packages/nami/src/ui/app/hw/success-and-close.tsx b/packages/nami/src/ui/app/hw/success-and-close.tsx
new file mode 100644
index 000000000..930f98a82
--- /dev/null
+++ b/packages/nami/src/ui/app/hw/success-and-close.tsx
@@ -0,0 +1,41 @@
+/*eslint-disable @typescript-eslint/no-misused-promises */
+import type { ReactElement } from 'react';
+import React from 'react';
+
+import { Box, Button, Text } from '@chakra-ui/react';
+import { Planet } from 'react-kawaii';
+
+import { Events } from '../../../features/analytics/events';
+import { useCaptureEvent } from '../../../features/analytics/hooks';
+
+export const SuccessAndClose = (): ReactElement => {
+ const capture = useCaptureEvent();
+ return (
+ <>
+
+ Successfully added accounts!
+
+
+
+
+
+ You can now close this tab and continue with the extension.
+
+ {
+ void capture(Events.HWDoneGoToWallet);
+ window.close();
+ }}
+ >
+ Close
+
+ >
+ );
+};
diff --git a/packages/nami/src/ui/app/hw/types.ts b/packages/nami/src/ui/app/hw/types.ts
new file mode 100644
index 000000000..ede249f09
--- /dev/null
+++ b/packages/nami/src/ui/app/hw/types.ts
@@ -0,0 +1,4 @@
+export interface HardwareDeviceInfo {
+ device: string;
+ id: number;
+}
diff --git a/packages/nami/src/ui/app/index.ts b/packages/nami/src/ui/app/index.ts
new file mode 100644
index 000000000..23c7929ac
--- /dev/null
+++ b/packages/nami/src/ui/app/index.ts
@@ -0,0 +1 @@
+export * from './pages';
diff --git a/packages/nami/src/ui/app/pages/dapp-connector/enable.stories.tsx b/packages/nami/src/ui/app/pages/dapp-connector/enable.stories.tsx
new file mode 100644
index 000000000..cfac20b5c
--- /dev/null
+++ b/packages/nami/src/ui/app/pages/dapp-connector/enable.stories.tsx
@@ -0,0 +1,81 @@
+import React from 'react';
+
+import { Box, useColorMode } from '@chakra-ui/react';
+import type { Meta, StoryObj } from '@storybook/react';
+
+import {
+ getCurrentAccount,
+ getFavoriteIcon,
+} from '../../../../api/extension/api.mock';
+
+import Enable from './enable';
+import { currentAccount } from '../../../../mocks/account.mock';
+
+const EnableStory = ({
+ colorMode,
+}: Readonly<{ colorMode: 'dark' | 'light' }>): React.ReactElement => {
+ const { setColorMode } = useColorMode();
+ setColorMode(colorMode);
+
+ return (
+
+ {} }}
+ />
+
+ );
+};
+
+const customViewports = {
+ popup: {
+ name: 'Popup',
+ styles: {
+ width: '400px',
+ height: '572px',
+ },
+ },
+};
+
+const meta: Meta = {
+ title: 'Dapp Connector/Enable',
+ component: EnableStory,
+ parameters: {
+ viewport: {
+ viewports: customViewports,
+ defaultViewport: 'popup',
+ },
+ layout: 'centered',
+ },
+ beforeEach: () => {
+ getCurrentAccount.mockImplementation(async () => {
+ return await Promise.resolve(currentAccount);
+ });
+ getFavoriteIcon.mockImplementation(() => {
+ return 'https://app.sundae.fi/static/images/favicon.png';
+ });
+ window.chrome = {
+ runtime: {
+ id: 'mock',
+ },
+ };
+ return () => {
+ getCurrentAccount.mockReset();
+ getFavoriteIcon.mockReset();
+ };
+ },
+};
+type Story = StoryObj;
+
+export default meta;
+
+export const Light: Story = {
+ parameters: {
+ colorMode: 'light',
+ },
+};
+export const Dark: Story = {
+ parameters: {
+ colorMode: 'dark',
+ },
+};
diff --git a/packages/nami/src/ui/app/pages/dapp-connector/enable.tsx b/packages/nami/src/ui/app/pages/dapp-connector/enable.tsx
new file mode 100644
index 000000000..42d4b89e3
--- /dev/null
+++ b/packages/nami/src/ui/app/pages/dapp-connector/enable.tsx
@@ -0,0 +1,113 @@
+import { CheckIcon } from '@chakra-ui/icons';
+import { Box, Button, Text, Image, useColorModeValue } from '@chakra-ui/react';
+import React from 'react';
+import { getFavoriteIcon, setWhitelisted } from '../../../../api/extension';
+import { APIError } from '../../../../config/config';
+
+import Account from '../../components/account';
+import { useCaptureEvent } from '../../../../features/analytics/hooks';
+import { Events } from '../../../../features/analytics/events';
+
+const Enable = ({ request, controller }) => {
+ const capture = useCaptureEvent();
+ const background = useColorModeValue('gray.100', 'gray.700');
+ const containerBg = useColorModeValue('white', 'gray.800');
+
+ return (
+
+
+
+
+
+
+
+
+ {request.origin.split('//')[1]}
+
+ This app would like to:
+
+
+
+ {' '}
+ View your balance and addresses
+
+
+
+ {' '}
+ Request approval for transactions
+
+
+
+ Only connect with sites you trust
+
+
+ {
+ capture(Events.DappConnectorAuthorizeDappCancelClick);
+ await controller.returnData({ error: APIError.Refused });
+ window.close();
+ }}
+ >
+ Cancel
+
+
+ {
+ capture(Events.DappConnectorAuthorizeDappAuthorizeClick);
+ await setWhitelisted(request.origin);
+ await controller.returnData({ data: true });
+ window.close();
+ }}
+ >
+ Access
+
+
+
+ );
+};
+
+export default Enable;
diff --git a/packages/nami/src/ui/app/pages/dapp-connector/signData.tsx b/packages/nami/src/ui/app/pages/dapp-connector/signData.tsx
new file mode 100644
index 000000000..e78b15373
--- /dev/null
+++ b/packages/nami/src/ui/app/pages/dapp-connector/signData.tsx
@@ -0,0 +1,260 @@
+import React, { useMemo } from 'react';
+import {
+ getCurrentAccount,
+ isHW,
+ signData,
+ signDataCIP30,
+} from '../../../../api/extension';
+import Account from '../../components/account';
+import { Scrollbars } from '../../components/scrollbar';
+import {
+ Box,
+ Text,
+ Button,
+ Image,
+ Spinner,
+ useColorModeValue,
+} from '@chakra-ui/react';
+import ConfirmModal from '../../components/confirmModal';
+import Loader from '../../../../api/loader';
+import { DataSignError } from '../../../../config/config';
+import { useCaptureEvent } from '../../../../features/analytics/hooks';
+import { Events } from '../../../../features/analytics/events';
+
+const SignData = ({ request, controller }) => {
+ const capture = useCaptureEvent();
+ const ref = React.useRef();
+ const [account, setAccount] = React.useState(null);
+ const [payload, setPayload] = React.useState('');
+ const [address, setAddress] = React.useState('');
+ const [error, setError] = React.useState('');
+ const [isLoading, setIsLoading] = React.useState(true);
+ const background = useColorModeValue('gray.100', 'gray.700');
+ const getAccount = async () => {
+ const currentAccount = await getCurrentAccount();
+ if (isHW(currentAccount.index)) setError('HW not supported');
+ setAccount(currentAccount);
+ };
+ const getPayload = async () => {
+ await Loader.load();
+ const payload = Buffer.from(request.data.payload, 'hex').toString('utf8');
+ setPayload(payload);
+ };
+
+ const signDataMsg = useMemo(() => {
+ const result = [];
+ payload.split(/\r?\n/).forEach(line => {
+ result.push(
+
+ {line}
+
,
+ );
+ });
+ return result;
+ }, [payload]);
+
+ const getAddress = async () => {
+ await Loader.load();
+ try {
+ const baseAddr = Loader.Cardano.BaseAddress.from_address(
+ Loader.Cardano.Address.from_bytes(
+ Buffer.from(request.data.address, 'hex'),
+ ),
+ );
+ if (!baseAddr) throw Error('Not a valid base address');
+ setAddress('payment');
+ return;
+ } catch (e) {}
+ try {
+ const rewardAddr = Loader.Cardano.RewardAddress.from_address(
+ Loader.Cardano.Address.from_bytes(
+ Buffer.from(request.data.address, 'hex'),
+ ),
+ );
+ if (!rewardAddr) throw Error('Not a valid base address');
+ setAddress('stake');
+ return;
+ } catch (e) {}
+ setAddress('unknown');
+ };
+
+ const loadData = async () => {
+ await getAccount();
+ await getPayload();
+ await getAddress();
+ setIsLoading(false);
+ };
+
+ React.useEffect(() => {
+ loadData();
+ }, []);
+ return (
+ <>
+ {isLoading ? (
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+ {request.origin.split('//')[1]}
+
+
+
+ This app requests a signature for:
+
+
+ {signDataMsg}
+
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : (
+
+ Signing with{' '}
+
+ {address}
+ {' '}
+ key
+
+ )}
+
+
+
+ {
+ capture(Events.DappConnectorDappDataCancelClick);
+ await controller.returnData({
+ error: DataSignError.UserDeclined,
+ });
+ window.close();
+ }}
+ >
+ Cancel
+
+
+ {
+ capture(Events.DappConnectorDappDataSignClick);
+ ref.current.openModal(account.index);
+ }}
+ >
+ Sign
+
+
+
+
+ )}
+
+ request.data.CIP30
+ ? signDataCIP30(
+ request.data.address,
+ request.data.payload,
+ password,
+ account.index,
+ )
+ : // deprecated soon
+ signData(
+ request.data.address,
+ request.data.payload,
+ password,
+ account.index,
+ )
+ }
+ onCloseBtn={() => {
+ capture(Events.DappConnectorDappDataCancelClick);
+ }}
+ onConfirm={async (status, signedMessage) => {
+ if (status === true) {
+ capture(Events.DappConnectorDappDataConfirmClick);
+ await controller.returnData({ data: signedMessage });
+ } else {
+ await controller.returnData({ error: signedMessage });
+ }
+ window.close();
+ }}
+ />
+ >
+ );
+};
+
+export default SignData;
diff --git a/packages/nami/src/ui/app/pages/dapp-connector/signTx.stories.tsx b/packages/nami/src/ui/app/pages/dapp-connector/signTx.stories.tsx
new file mode 100644
index 000000000..ae00212fc
--- /dev/null
+++ b/packages/nami/src/ui/app/pages/dapp-connector/signTx.stories.tsx
@@ -0,0 +1,200 @@
+import React from 'react';
+
+import { Box, useColorMode } from '@chakra-ui/react';
+import type { Meta, StoryObj } from '@storybook/react';
+import { userEvent, waitForElementToBeRemoved, within } from '@storybook/test';
+
+import {
+ extractKeyOrScriptHash,
+ getCurrentAccount,
+ getFavoriteIcon,
+} from '../../../../api/extension/api.mock';
+
+import SignTx from './signTx';
+import { currentAccount } from '../../../../mocks/account.mock';
+import { useStoreState } from '../../../store.mock';
+import { store } from '../../../../mocks/store.mock';
+import { valueToAssets } from '../../../../api/util.mock';
+import { getKeyHashes, getValue } from './signTxUtil.mock';
+
+const SignTxStory = ({
+ colorMode,
+}: Readonly<{ colorMode: 'dark' | 'light' }>): React.ReactElement => {
+ const { setColorMode } = useColorMode();
+ setColorMode(colorMode);
+
+ return (
+
+ {} }}
+ />
+
+ );
+};
+
+const customViewports = {
+ popup: {
+ name: 'Popup',
+ styles: {
+ width: '400px',
+ height: '572px',
+ },
+ },
+};
+
+const meta: Meta = {
+ title: 'Dapp Connector/SignTx',
+ component: SignTxStory,
+ parameters: {
+ viewport: {
+ viewports: customViewports,
+ defaultViewport: 'popup',
+ },
+ layout: 'centered',
+ },
+ beforeEach: () => {
+ getCurrentAccount.mockImplementation(async () => {
+ return await Promise.resolve(currentAccount);
+ });
+ getFavoriteIcon.mockImplementation(() => {
+ return 'https://app.sundae.fi/static/images/favicon.png';
+ });
+ extractKeyOrScriptHash.mockImplementation(async () => {
+ return Promise.resolve(currentAccount.paymentKeyHashBech32);
+ });
+ useStoreState.mockImplementation((callback: any) => {
+ return callback(store);
+ });
+ valueToAssets.mockImplementationOnce(() => {
+ return [
+ {
+ unit: 'lovelace',
+ quantity: '12732198240',
+ },
+ ] as any;
+ });
+ valueToAssets.mockImplementationOnce(() => {
+ return [
+ {
+ unit: 'lovelace',
+ quantity: '22732198240',
+ },
+ ] as any;
+ });
+ getValue.mockResolvedValue({
+ ownValue: [
+ {
+ unit: 'lovelace',
+ quantity: BigInt(22732198240),
+ },
+ ],
+ externalValue: {
+ [currentAccount.paymentKeyHashBech32]: {
+ value: [
+ {
+ unit: 'lovelace',
+ quantity: '22732198240',
+ },
+ ],
+ },
+ },
+ });
+ getKeyHashes.mockResolvedValue({ key: [], kind: ['payment'] });
+ window.chrome = {
+ runtime: {
+ id: 'mock',
+ },
+ };
+ return () => {
+ getCurrentAccount.mockReset();
+ getFavoriteIcon.mockReset();
+ extractKeyOrScriptHash.mockReset();
+ valueToAssets.mockReset();
+ getValue.mockReset();
+ getKeyHashes.mockReset();
+ useStoreState.mockReset();
+ };
+ },
+};
+type Story = StoryObj;
+
+export default meta;
+
+export const Light: Story = {
+ parameters: {
+ colorMode: 'light',
+ },
+};
+export const Dark: Story = {
+ parameters: {
+ colorMode: 'dark',
+ },
+};
+
+export const DetailsLight: Story = {
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('Wait for loading', async () => {
+ await waitForElementToBeRemoved(() => canvas.queryByText('Loading...'));
+ });
+ await step('Click details', async () => {
+ await userEvent.click(canvas.getByText('Details'));
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ },
+};
+
+export const DetailsDark: Story = {
+ ...DetailsLight,
+ parameters: {
+ colorMode: 'dark',
+ },
+};
+
+export const DetailsContractLight: Story = {
+ ...DetailsLight,
+ beforeEach: () => {
+ getValue.mockResolvedValue({
+ ownValue: [
+ {
+ unit: 'lovelace',
+ quantity: BigInt(22732198240),
+ },
+ ],
+ externalValue: {
+ [currentAccount.paymentKeyHashBech32]: {
+ script: true,
+ datumHash: 'datum',
+ value: [
+ {
+ unit: 'lovelace',
+ quantity: '22732198240',
+ },
+ ],
+ },
+ },
+ });
+
+ return () => {
+ getValue.mockReset();
+ };
+ },
+ parameters: {
+ colorMode: 'dark',
+ },
+};
+
+export const DetailsContractDark: Story = {
+ ...DetailsContractLight,
+ parameters: {
+ colorMode: 'dark',
+ },
+};
diff --git a/packages/nami/src/ui/app/pages/dapp-connector/signTx.tsx b/packages/nami/src/ui/app/pages/dapp-connector/signTx.tsx
new file mode 100644
index 000000000..5ab34ecaf
--- /dev/null
+++ b/packages/nami/src/ui/app/pages/dapp-connector/signTx.tsx
@@ -0,0 +1,817 @@
+import React from 'react';
+import {
+ bytesAddressToBinary,
+ getCurrentAccount,
+ getFavoriteIcon,
+ getUtxos,
+ signTx,
+ signTxHW,
+} from '../../../../api/extension';
+import Account from '../../components/account';
+import { Scrollbars } from '../../components/scrollbar';
+import ConfirmModal from '../../components/confirmModal';
+import { Loader } from '../../../../api/loader';
+import UnitDisplay from '../../components/unitDisplay';
+import { ChevronRightIcon } from '@chakra-ui/icons';
+import MiddleEllipsis from 'react-middle-ellipsis';
+import Copy from '../../components/copy';
+import { TxSignError } from '../../../../config/config';
+import { useStoreState } from '../../../store';
+import {
+ Box,
+ Stack,
+ Text,
+ Button,
+ Image,
+ Modal,
+ ModalBody,
+ ModalContent,
+ Spinner,
+ useColorModeValue,
+ useDisclosure,
+} from '@chakra-ui/react';
+import AssetsModal from '../../components/assetsModal';
+import { useCaptureEvent } from '../../../../features/analytics/hooks';
+import { Events } from '../../../../features/analytics/events';
+import { getKeyHashes, getValue } from './signTxUtil';
+import { useOutsideHandles } from '../../../../features/outside-handles-provider';
+
+const abs = big => {
+ return big < 0 ? big * BigInt(-1) : big;
+};
+
+const SignTx = ({ request, controller }) => {
+ const capture = useCaptureEvent();
+ const { cardanoCoin } = useOutsideHandles();
+ const ref = React.useRef();
+ const [account, setAccount] = React.useState(null);
+ const [fee, setFee] = React.useState('0');
+ const [value, setValue] = React.useState({
+ ownValue: null,
+ externalValue: null,
+ });
+ const [property, setProperty] = React.useState({
+ metadata: false,
+ certificate: false,
+ withdrawal: false,
+ minting: false,
+ script: false,
+ contract: false,
+ datum: false,
+ });
+ const [tx, setTx] = React.useState('');
+ // key kind can be payment and stake
+ const [keyHashes, setKeyHashes] = React.useState<{
+ key: string[];
+ kind: string[];
+ }>({ kind: [], key: [] });
+ const [isLoading, setIsLoading] = React.useState({
+ loading: true,
+ error: '',
+ });
+
+ const assetsModalRef = React.useRef();
+ const detailsModalRef = React.useRef();
+
+ const getFee = tx => {
+ const fee = tx.body().fee().to_str();
+ setFee(fee);
+ };
+
+ const getProperties = tx => {
+ let metadata = tx.auxiliary_data() && tx.auxiliary_data().metadata();
+ if (metadata) {
+ const json = {};
+ const keys = metadata.keys();
+ for (let i = 0; i < keys.len(); i++) {
+ const key = keys.get(i);
+ json[key.to_str()] = JSON.parse(
+ Loader.Cardano.decode_metadatum_to_json_str(metadata.get(key), 1),
+ );
+ }
+ metadata = json;
+ }
+
+ const certificate = tx.body().certs();
+ const withdrawal = tx.body().withdrawals();
+ const minting = tx.body().mint();
+ const script = tx.witness_set().native_scripts();
+ let datum;
+ let contract = tx.body().script_data_hash();
+ const outputs = tx.body().outputs();
+ for (let i = 0; i < outputs.len(); i++) {
+ const output = outputs.get(i);
+ if (output.datum()) {
+ datum = true;
+ const prefix = bytesAddressToBinary(output.address().to_bytes()).slice(
+ 0,
+ 4,
+ );
+ // from cardano ledger specs; if any of these prefixes match then it means the payment credential is a script hash, so it's a contract address
+ if (
+ prefix == '0111' ||
+ prefix == '0011' ||
+ prefix == '0001' ||
+ prefix == '0101'
+ ) {
+ contract = true;
+ }
+ break;
+ }
+ }
+
+ setProperty({
+ metadata,
+ certificate,
+ withdrawal,
+ minting,
+ contract,
+ script,
+ datum,
+ });
+ };
+
+ const checkCollateral = (tx, utxos, account) => {
+ const collateralInputs = tx.body().collateral();
+ if (!collateralInputs) return;
+
+ // checking all wallet utxos if used as collateral
+ for (let i = 0; i < collateralInputs.len(); i++) {
+ const collateral = collateralInputs.get(i);
+ for (let j = 0; j < utxos.length; j++) {
+ const input = utxos[j].input();
+ if (
+ Buffer.from(input.transaction_id().to_bytes()).toString('hex') ==
+ Buffer.from(collateral.transaction_id().to_bytes()).toString(
+ 'hex',
+ ) &&
+ input.index() == collateral.index()
+ ) {
+ // collateral utxo is less than 50 ADA. That's also fine.
+ if (
+ utxos[j]
+ .output()
+ .amount()
+ .coin()
+ .compare(Loader.Cardano.BigNum.from_str('50000000')) <= 0
+ )
+ return;
+
+ if (!account.collateral) {
+ setIsLoading(l => ({ ...l, error: 'Collateral not set' }));
+ return;
+ }
+
+ if (
+ !(
+ Buffer.from(collateral.transaction_id().to_bytes()).toString(
+ 'hex',
+ ) == account.collateral.txHash &&
+ collateral.index() == account.collateral.txId
+ )
+ ) {
+ setIsLoading(l => ({ ...l, error: 'Invalid collateral used' }));
+ return;
+ }
+ }
+ }
+ }
+ };
+
+ const getInfo = async () => {
+ await Loader.load();
+ const currentAccount = await getCurrentAccount();
+ setAccount(currentAccount);
+ let utxos = await getUtxos();
+ const tx = Loader.Cardano.Transaction.from_bytes(
+ Buffer.from(request.data.tx, 'hex'),
+ );
+ setTx(request.data.tx);
+ getFee(tx);
+ setValue(await getValue(tx, utxos, currentAccount));
+
+ checkCollateral(tx, utxos, currentAccount);
+ const keyHashes = await getKeyHashes(tx, utxos, currentAccount);
+ if ('error' in keyHashes) {
+ setIsLoading(l => ({
+ ...l,
+ error: keyHashes.error,
+ }));
+ } else {
+ setKeyHashes(keyHashes);
+ }
+ getProperties(tx);
+ setIsLoading(l => ({ ...l, loading: false }));
+ };
+ const background = useColorModeValue('gray.100', 'gray.700');
+ const containerBg = useColorModeValue('white', 'gray.800');
+
+ React.useEffect(() => {
+ getInfo();
+ }, []);
+ return (
+ <>
+ {isLoading.loading ? (
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+ {request.origin.split('//')[1]}
+
+
+
+ This app requests a signature for:
+
+
+ {value.ownValue ? (
+ (() => {
+ let lovelace = value.ownValue.find(v => v.unit === 'lovelace');
+ lovelace = lovelace ? lovelace.quantity : '0';
+ const assets = value.ownValue.filter(
+ v => v.unit !== 'lovelace',
+ );
+ return (
+ <>
+
+ {lovelace <= 0 ? '+' : '-'}
+
+
+ {assets.length > 0 && (
+
+ {' '}
+ {(() => {
+ const positiveAssets = assets.filter(
+ v => v.quantity < 0,
+ );
+ const negativeAssets = assets.filter(
+ v => v.quantity > 0,
+ );
+ return (
+
+ {' '}
+ {negativeAssets.length > 0 && (
+
+ assetsModalRef.current.openModal({
+ background: 'red.400',
+ color: 'white',
+ assets: negativeAssets,
+ title: (
+
+ Sending{' '}
+
+ {negativeAssets.length}
+ {' '}
+ {negativeAssets.length == 1
+ ? 'asset'
+ : 'assets'}
+
+ ),
+ })
+ }
+ >
+ - {negativeAssets.length}{' '}
+ {negativeAssets.length > 1
+ ? 'Assets'
+ : 'Asset'}
+
+ )}
+ {negativeAssets.length > 0 &&
+ positiveAssets.length > 0 && }
+ {positiveAssets.length > 0 && (
+
+ assetsModalRef.current.openModal({
+ background: 'teal.400',
+ color: 'white',
+ assets: positiveAssets,
+ title: (
+
+ Receiving{' '}
+
+ {positiveAssets.length}
+ {' '}
+ {positiveAssets.length == 1
+ ? 'asset'
+ : 'assets'}
+
+ ),
+ })
+ }
+ >
+ + {positiveAssets.length}{' '}
+ {positiveAssets.length > 1
+ ? 'Assets'
+ : 'Asset'}
+
+ )}
+
+ );
+ })()}
+
+ )}
+
+
+
+ fee
+
+ >
+ );
+ })()
+ ) : (
+
+ ...
+
+ )}
+
+
+ }
+ onClick={() => detailsModalRef.current.openModal()}
+ >
+ Details
+
+
+ {isLoading.error && (
+ <>
+
+
+ {isLoading.error}
+
+
+
+ >
+ )}
+
+
+ {
+ capture(Events.DappConnectorDappTxCancelClick);
+ await controller.returnData({
+ error: TxSignError.UserDeclined,
+ });
+ window.close();
+ }}
+ >
+ Cancel
+
+
+ {
+ capture(Events.DappConnectorDappTxSignClick);
+ ref.current.openModal(account.index);
+ }}
+ >
+ Sign
+
+
+
+
+ )}
+
+
+ {
+ capture(Events.DappConnectorDappTxCancelClick);
+ }}
+ sign={async (password, hw) => {
+ if (hw) {
+ return await signTxHW(
+ request.data.tx,
+ keyHashes.key,
+ account,
+ hw,
+ request.data.partialSign,
+ );
+ }
+ return await signTx(
+ request.data.tx,
+ keyHashes.key,
+ password,
+ account.index,
+ request.data.partialSign,
+ );
+ }}
+ onConfirm={async (status, signedTx) => {
+ if (status === true) {
+ capture(Events.DappConnectorDappTxConfirmClick);
+ await controller.returnData({
+ data: Buffer.from(signedTx.to_bytes(), 'hex').toString('hex'),
+ });
+ } else {
+ await controller.returnData({ error: signedTx });
+ }
+ window.close();
+ }}
+ />
+ >
+ );
+};
+
+const DetailsModal = React.forwardRef(
+ ({ externalValue, property, keyHashes, tx, assetsModalRef }, ref) => {
+ const { cardanoCoin } = useOutsideHandles();
+ const { isOpen, onOpen, onClose } = useDisclosure();
+ const background = useColorModeValue('white', 'gray.800');
+ const innerBackground = useColorModeValue('gray.100', 'gray.700');
+
+ React.useImperativeHandle(ref, () => ({
+ openModal() {
+ onOpen();
+ },
+ }));
+ return (
+
+
+
+
+
+
+
+ Details
+
+
+
+ {' '}
+ {Object.keys(externalValue).length > 0 && (
+
+
+ Recipients
+
+
+ {Object.keys(externalValue).map((address, index) => {
+ const lovelace = externalValue[address].value.find(
+ v => v.unit === 'lovelace',
+ ).quantity;
+ const assets = externalValue[address].value.filter(
+ v => v.unit !== 'lovelace',
+ );
+ return (
+
+
+
+
+
+
+
+ {address}
+
+
+
+
+ {externalValue[address].script && (
+
+ {externalValue[address].datumHash ? (
+
+ Contract
+
+ ) : (
+ 'Script'
+ )}
+
+ )}
+
+
+
+ {assets.length > 0 && (
+
+ assetsModalRef.current.openModal({
+ assets: assets,
+ title: (
+
+ Address receiving{' '}
+
+ {assets.length}
+ {' '}
+ {assets.length == 1
+ ? 'asset'
+ : 'assets'}
+
+ ),
+ })
+ }
+ >
+ + {assets.length}{' '}
+ {assets.length > 1 ? 'Assets' : 'Asset'}
+
+ )}
+
+
+
+ );
+ })}
+
+
+ )}
+ {property.metadata && (
+ <>
+
+ Metadata
+
+
+
+
+
+
+ {JSON.stringify(property.metadata, null, 2)}
+
+
+
+
+
+ >
+ )}
+
+ Signing keys
+
+
+
+ {keyHashes.kind.map((keyHash, index) => (
+
+
+ {keyHash}
+
+
+ ))}
+
+
+ {Object.keys(property).some(key => property[key]) && (
+ <>
+
+ Tags
+
+
+
+ {Object.keys(property)
+ .filter(p => property[p])
+ .map((p, index) => (
+
+
+ {p == 'minting' && 'Minting'}
+ {p == 'certificate' && 'Certificate'}
+ {p == 'withdrawal' && 'Withdrawal'}
+ {p == 'metadata' && 'Metadata'}
+ {p == 'contract' && 'Contract'}
+ {p == 'script' && 'Script'}
+ {p == 'datum' && 'Datum'}
+
+
+ ))}
+
+
+ >
+ )}
+
+
+ Raw transaction
+
+
+
+ {tx}
+
+
+
+
+
+
+ Back
+
+
+
+
+
+
+
+
+ );
+ },
+);
+
+export default SignTx;
diff --git a/packages/nami/src/ui/app/pages/dapp-connector/signTxUtil.mock.ts b/packages/nami/src/ui/app/pages/dapp-connector/signTxUtil.mock.ts
new file mode 100644
index 000000000..8322a31f0
--- /dev/null
+++ b/packages/nami/src/ui/app/pages/dapp-connector/signTxUtil.mock.ts
@@ -0,0 +1,9 @@
+import { fn } from '@storybook/test';
+
+import * as actualApi from './signTxUtil';
+
+export * from './signTxUtil';
+
+export const getValue = fn(actualApi.getValue).mockName('getValue');
+
+export const getKeyHashes = fn(actualApi.getKeyHashes).mockName('getKeyHashes');
diff --git a/packages/nami/src/ui/app/pages/dapp-connector/signTxUtil.ts b/packages/nami/src/ui/app/pages/dapp-connector/signTxUtil.ts
new file mode 100644
index 000000000..3d51061c3
--- /dev/null
+++ b/packages/nami/src/ui/app/pages/dapp-connector/signTxUtil.ts
@@ -0,0 +1,331 @@
+import AssetFingerprint from '@emurgo/cip14-js';
+import {
+ bytesAddressToBinary,
+ extractKeyOrScriptHash,
+ getSpecificUtxo,
+} from '../../../../api/extension';
+import { valueToAssets } from '../../../../api/util';
+import { Loader } from '../../../../api/loader';
+
+const getPaymentKeyHash = async address => {
+ try {
+ return Buffer.from(
+ Loader.Cardano.BaseAddress.from_address(
+ Loader.Cardano.Address.from_bytes(address.to_bytes()),
+ )
+ .payment_cred()
+ .to_keyhash()
+ .to_bytes(),
+ ).toString('hex');
+ } catch (e) {}
+ try {
+ return Buffer.from(
+ Loader.Cardano.EnterpriseAddress.from_address(
+ Loader.Cardano.Address.from_bytes(address.to_bytes()),
+ )
+ .payment_cred()
+ .to_keyhash()
+ .to_bytes(),
+ ).toString('hex');
+ } catch (e) {}
+ try {
+ return Buffer.from(
+ Loader.Cardano.PointerAddress.from_address(
+ Loader.Cardano.Address.from_bytes(address.to_bytes()),
+ )
+ .payment_cred()
+ .to_keyhash()
+ .to_bytes(),
+ ).toString('hex');
+ } catch (e) {}
+ throw Error('Not supported address type');
+};
+
+export const getKeyHashes = async (
+ tx,
+ utxos,
+ account,
+): Promise<{ key: string[]; kind: string[] } | { error: string }> => {
+ let requiredKeyHashes: string[] = [];
+ const baseAddr = Loader.Cardano.BaseAddress.from_address(
+ Loader.Cardano.Address.from_bech32(account.paymentAddr),
+ );
+ const paymentKeyHash = Buffer.from(
+ baseAddr.payment_cred().to_keyhash().to_bytes(),
+ ).toString('hex');
+ const stakeKeyHash = Buffer.from(
+ baseAddr.stake_cred().to_keyhash().to_bytes(),
+ ).toString('hex');
+
+ //get key hashes from inputs
+ const inputs = tx.body().inputs();
+ for (let i = 0; i < inputs.len(); i++) {
+ const input = inputs.get(i);
+ const txHash = Buffer.from(input.transaction_id().to_bytes()).toString(
+ 'hex',
+ );
+ const index = parseInt(input.index().to_str());
+ if (
+ utxos.some(
+ utxo =>
+ Buffer.from(utxo.input().transaction_id().to_bytes()).toString(
+ 'hex',
+ ) === txHash && parseInt(utxo.input().index().to_str()) === index,
+ )
+ ) {
+ requiredKeyHashes.push(paymentKeyHash);
+ } else {
+ requiredKeyHashes.push('');
+ }
+ }
+
+ //get key hashes from certificates
+ const txBody = tx.body();
+ const keyHashFromCert = txBody => {
+ for (let i = 0; i < txBody.certs().len(); i++) {
+ const cert = txBody.certs().get(i);
+ if (cert.kind() === 0) {
+ const credential = cert.as_stake_registration().stake_credential();
+ if (credential.kind() === 0) {
+ // stake registration doesn't required key hash
+ }
+ } else if (cert.kind() === 1) {
+ const credential = cert.as_stake_deregistration().stake_credential();
+ if (credential.kind() === 0) {
+ const keyHash = Buffer.from(
+ credential.to_keyhash().to_bytes(),
+ ).toString('hex');
+ requiredKeyHashes.push(keyHash);
+ }
+ } else if (cert.kind() === 2) {
+ const credential = cert.as_stake_delegation().stake_credential();
+ if (credential.kind() === 0) {
+ const keyHash = Buffer.from(
+ credential.to_keyhash().to_bytes(),
+ ).toString('hex');
+ requiredKeyHashes.push(keyHash);
+ }
+ } else if (cert.kind() === 3) {
+ const owners = cert.as_pool_registration().pool_params().pool_owners();
+ for (let i = 0; i < owners.len(); i++) {
+ const keyHash = Buffer.from(owners.get(i).to_bytes()).toString('hex');
+ requiredKeyHashes.push(keyHash);
+ }
+ } else if (cert.kind() === 4) {
+ const operator = cert.as_pool_retirement().pool_keyhash().to_hex();
+ requiredKeyHashes.push(operator);
+ } else if (cert.kind() === 6) {
+ const instant_reward = cert
+ .as_move_instantaneous_rewards_cert()
+ .move_instantaneous_reward()
+ .as_to_stake_creds()
+ .keys();
+ for (let i = 0; i < instant_reward.len(); i++) {
+ const credential = instant_reward.get(i);
+
+ if (credential.kind() === 0) {
+ const keyHash = Buffer.from(
+ credential.to_keyhash().to_bytes(),
+ ).toString('hex');
+ requiredKeyHashes.push(keyHash);
+ }
+ }
+ }
+ }
+ };
+ if (txBody.certs()) keyHashFromCert(txBody);
+
+ // key hashes from withdrawals
+ const withdrawals = txBody.withdrawals();
+ const keyHashFromWithdrawal = withdrawals => {
+ const rewardAddresses = withdrawals.keys();
+ for (let i = 0; i < rewardAddresses.len(); i++) {
+ const credential = rewardAddresses.get(i).payment_cred();
+ if (credential.kind() === 0) {
+ requiredKeyHashes.push(credential.to_keyhash().to_hex());
+ }
+ }
+ };
+ if (withdrawals) keyHashFromWithdrawal(withdrawals);
+
+ //get key hashes from scripts
+ const scripts = tx.witness_set().native_scripts();
+ const keyHashFromScript = scripts => {
+ for (let i = 0; i < scripts.len(); i++) {
+ const script = scripts.get(i);
+ if (script.kind() === 0) {
+ const keyHash = Buffer.from(
+ script.as_script_pubkey().addr_keyhash().to_bytes(),
+ ).toString('hex');
+ requiredKeyHashes.push(keyHash);
+ }
+ if (script.kind() === 1) {
+ return keyHashFromScript(script.as_script_all().native_scripts());
+ }
+ if (script.kind() === 2) {
+ return keyHashFromScript(script.as_script_any().native_scripts());
+ }
+ if (script.kind() === 3) {
+ return keyHashFromScript(script.as_script_n_of_k().native_scripts());
+ }
+ }
+ };
+ if (scripts) keyHashFromScript(scripts);
+
+ //get keyHashes from required signers
+ const requiredSigners = tx.body().required_signers();
+ if (requiredSigners) {
+ for (let i = 0; i < requiredSigners.len(); i++) {
+ requiredKeyHashes.push(
+ Buffer.from(requiredSigners.get(i).to_bytes()).toString('hex'),
+ );
+ }
+ }
+
+ //get keyHashes from collateral
+ const collateral = txBody.collateral();
+ if (collateral) {
+ for (let i = 0; i < collateral.len(); i++) {
+ const c = collateral.get(i);
+ const utxo = await getSpecificUtxo(
+ Buffer.from(c.transaction_id().to_bytes()).toString('hex'),
+ c.index(),
+ );
+ if (utxo) {
+ const address = Loader.Cardano.Address.from_bech32(utxo.address);
+ requiredKeyHashes.push(await getPaymentKeyHash(address));
+ }
+ }
+ }
+
+ const keyKind: string[] = [];
+ requiredKeyHashes = [...new Set(requiredKeyHashes)];
+
+ if (requiredKeyHashes.includes(paymentKeyHash)) keyKind.push('payment');
+ if (requiredKeyHashes.includes(stakeKeyHash)) keyKind.push('stake');
+ if (keyKind.length <= 0) {
+ return {
+ error: 'Signature not possible',
+ };
+ }
+ return { key: requiredKeyHashes, kind: keyKind };
+};
+
+export const getValue = async (tx, utxos, account) => {
+ let inputValue = Loader.Cardano.Value.new(
+ Loader.Cardano.BigNum.from_str('0'),
+ );
+ const inputs = tx.body().inputs();
+ for (let i = 0; i < inputs.len(); i++) {
+ const input = inputs.get(i);
+ const inputTxHash = Buffer.from(input.transaction_id().to_bytes()).toString(
+ 'hex',
+ );
+ const inputTxId = parseInt(input.index().to_str());
+ const utxo = utxos.find(utxo => {
+ const utxoTxHash = Buffer.from(
+ utxo.input().transaction_id().to_bytes(),
+ ).toString('hex');
+ const utxoTxId = parseInt(utxo.input().index().to_str());
+ return inputTxHash === utxoTxHash && inputTxId === utxoTxId;
+ });
+ if (utxo) {
+ inputValue = inputValue.checked_add(utxo.output().amount());
+ }
+ }
+ const outputs = tx.body().outputs();
+ let ownOutputValue = Loader.Cardano.Value.new(
+ Loader.Cardano.BigNum.from_str('0'),
+ );
+ const externalOutputs = {};
+ if (!outputs) return;
+ for (let i = 0; i < outputs.len(); i++) {
+ const output = outputs.get(i);
+ const address = output.address().to_bech32();
+ const hashBech32 = await extractKeyOrScriptHash(
+ Buffer.from(output.address().to_bytes()).toString('hex'),
+ );
+ // making sure funds at mangled addresses are also included
+ if (hashBech32 === account.paymentKeyHashBech32) {
+ //own
+ ownOutputValue = ownOutputValue.checked_add(output.amount());
+ } else {
+ //external
+ if (!externalOutputs[address]) {
+ const value = Loader.Cardano.Value.new(output.amount().coin());
+ if (output.amount().multiasset())
+ value.set_multiasset(output.amount().multiasset());
+ externalOutputs[address] = { value };
+ } else
+ externalOutputs[address].value = externalOutputs[
+ address
+ ].value.checked_add(output.amount());
+ const prefix = bytesAddressToBinary(output.address().to_bytes()).slice(
+ 0,
+ 4,
+ );
+ // from cardano ledger specs; if any of these prefixes match then it means the payment credential is a script hash, so it's a contract address
+ if (
+ prefix == '0111' ||
+ prefix == '0011' ||
+ prefix == '0001' ||
+ prefix == '0101'
+ ) {
+ externalOutputs[address].script = true;
+ }
+ const datum = output.datum();
+ if (datum)
+ externalOutputs[address].datumHash = Buffer.from(
+ datum.kind() === 0
+ ? datum.as_data_hash().to_bytes()
+ : Loader.Cardano.hash_plutus_data(datum.as_data().get()).to_bytes(),
+ ).toString('hex');
+ }
+ }
+
+ inputValue = await valueToAssets(inputValue);
+ ownOutputValue = await valueToAssets(ownOutputValue);
+
+ const involvedAssets = [
+ ...new Set([
+ ...inputValue.map(asset => asset.unit),
+ ...ownOutputValue.map(asset => asset.unit),
+ ]),
+ ];
+ const ownOutputValueDifference = involvedAssets.map(unit => {
+ const leftValue = inputValue.find(asset => asset.unit === unit);
+ const rightValue = ownOutputValue.find(asset => asset.unit === unit);
+ const difference =
+ BigInt(leftValue ? leftValue.quantity : '') -
+ BigInt(rightValue ? rightValue.quantity : '');
+ if (unit === 'lovelace') {
+ return { unit, quantity: difference };
+ }
+ const policy = unit.slice(0, 56);
+ const name = unit.slice(56);
+ const fingerprint = AssetFingerprint.fromParts(
+ Buffer.from(policy, 'hex'),
+ Buffer.from(name, 'hex'),
+ ).fingerprint();
+ return {
+ unit,
+ quantity: difference,
+ fingerprint,
+ name: (leftValue || rightValue).name,
+ policy,
+ };
+ });
+
+ const externalValue = {};
+ for (const address of Object.keys(externalOutputs)) {
+ externalValue[address] = {
+ ...externalOutputs[address],
+ value: await valueToAssets(externalOutputs[address].value),
+ };
+ }
+
+ const ownValue = ownOutputValueDifference.filter(
+ v => v.quantity != BigInt(0),
+ );
+ return { ownValue, externalValue };
+};
diff --git a/packages/nami/src/ui/app/pages/index.ts b/packages/nami/src/ui/app/pages/index.ts
new file mode 100644
index 000000000..3c5958cf6
--- /dev/null
+++ b/packages/nami/src/ui/app/pages/index.ts
@@ -0,0 +1 @@
+export * from './wallet';
diff --git a/packages/nami/src/ui/app/pages/send.stories.tsx b/packages/nami/src/ui/app/pages/send.stories.tsx
new file mode 100644
index 000000000..5c2854b22
--- /dev/null
+++ b/packages/nami/src/ui/app/pages/send.stories.tsx
@@ -0,0 +1,638 @@
+import React from 'react';
+
+import { Box, useColorMode } from '@chakra-ui/react';
+import type { Meta, StoryObj } from '@storybook/react';
+import { screen, userEvent, within } from '@storybook/test';
+
+import { Route } from '../../../../.storybook/mocks/react-router-dom.mock';
+import {
+ createTab,
+ isValidAddress,
+ getAdaHandle,
+} from '../../../api/extension/api.mock';
+import { buildTx } from '../../../api/extension/wallet.mock';
+import { minAdaRequired, valueToAssets } from '../../../api/util.mock';
+import { account, account1, currentAccount } from '../../../mocks/account.mock';
+import { store } from '../../../mocks/store.mock';
+import { useStoreState, useStoreActions } from '../../store.mock';
+import { Cardano } from '../../../../.storybook/mocks/cardano-sdk.mock';
+
+import Send from './send';
+import { of } from 'rxjs';
+import { Wallet } from '@lace/cardano';
+
+const txInfo = {
+ minUtxo: '969750',
+};
+
+const address = {
+ display:
+ 'addr_test1qznkfw45dhtkr6f60hgw6rktmza7ll7achyv2w7vsx2khhcvec23vqjpq7wzwfq78j44xkyy6rg6435skpst6ju0j4tqfcx0ze',
+ result:
+ 'addr_test1qznkfw45dhtkr6f60hgw6rktmza7ll7achyv2w7vsx2khhcvec23vqjpq7wzwfq78j44xkyy6rg6435skpst6ju0j4tqfcx0ze',
+};
+
+const inMemoryWallet: Wallet.ObservableWallet = {
+ protocolParameters$: of({
+ coinsPerUtxoByte: '4310',
+ }),
+ assetInfo$: of(
+ new Map([
+ [
+ '0b23996b05afb3a76cc802dcb1d854a2b3596b208bf775c162cec2d34e6f6e5371756172654e66743235',
+
+ {
+ assetId:
+ '0b23996b05afb3a76cc802dcb1d854a2b3596b208bf775c162cec2d34e6f6e5371756172654e66743235',
+ policyId: '0b23996b05afb3a76cc802dcb1d854a2b3596b208bf775c162cec2d3',
+ name: 'NonSquareNft25',
+ fingerprint: 'asset15tfh93yjsffr7v9fepepuq2w4scl58eeaszmx7',
+ quantity: BigInt('100000000000'),
+ },
+ ],
+ [
+ '212a16adbc2aec5cab350fc8e8a32defae6d766f7a774142d5ae995f54657374546f6b656e',
+ {
+ assetId:
+ '212a16adbc2aec5cab350fc8e8a32defae6d766f7a774142d5ae995f54657374546f6b656e',
+ policyId: '212a16adbc2aec5cab350fc8e8a32defae6d766f7a774142d5ae995f',
+ name: 'TestToken',
+ fingerprint: 'asset16cee8gr79j5k4ag5v8wlk5ygg5fjyech5ugykj',
+ quantity: BigInt('9'),
+ },
+ ],
+ [
+ '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff198444149',
+ {
+ assetId:
+ '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff198444149',
+ policyId: '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff198',
+ name: 'DAI',
+ fingerprint: 'asset1vdkz0fx34r9km5xf4l5jk3emyysfamw5xr3yc2',
+ quantity: BigInt('9000000'),
+ },
+ ],
+ [
+ '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff198446a6564',
+ {
+ assetId:
+ '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff198446a6564',
+ policyId: '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff198',
+ name: 'Djed',
+ fingerprint: 'asset1spcamsngdptfa0nr2r48e8720ry4k8mt6me5e4',
+ quantity: BigInt('10999999'),
+ },
+ ],
+ [
+ '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff19855534443',
+ {
+ assetId:
+ '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff19855534443',
+ policyId: '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff198',
+ name: 'USDC',
+ fingerprint: 'asset1qketn3dc3hq5eudhpfrfnet9f7uk3ffpkt3vn5',
+ quantity: BigInt('4000000'),
+ },
+ ],
+ [
+ '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff19855534454',
+ {
+ assetId:
+ '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff19855534454',
+ policyId: '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff198',
+ name: 'USDT',
+ fingerprint: 'asset1tnlqa0d3qqjrpsx3h9vjq9e3x6yurq7w7pwl2d',
+ quantity: BigInt('9000000'),
+ },
+ ],
+ [
+ '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff19869555344',
+ {
+ assetId:
+ '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff19869555344',
+ policyId: '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff198',
+ name: 'iUSD',
+ fingerprint: 'asset1z68cfhqv29phnmlcczdjc9p28j2jl9f5jx8kqa',
+ quantity: BigInt('10999999'),
+ },
+ ],
+ [
+ 'e517b38693b633f1bc0dd3eb69cb1ad0f0c198c67188405901ae63a3001bc28068616e646c65735f6e61747572652d6c616b65',
+ {
+ assetId:
+ 'e517b38693b633f1bc0dd3eb69cb1ad0f0c198c67188405901ae63a3001bc28068616e646c65735f6e61747572652d6c616b65',
+ policyId: 'e517b38693b633f1bc0dd3eb69cb1ad0f0c198c67188405901ae63a3',
+ name: '\u0000\u001Bhandles_nature-lake',
+ fingerprint: 'asset1juxtmgjasyr58hp523sn4n24yk0feqga6wxfh9',
+ quantity: BigInt('1'),
+ },
+ ],
+ [
+ 'f6f49b186751e61f1fb8c64e7504e771f968cea9f4d11f5222b169e3744d494e',
+ {
+ assetId:
+ 'f6f49b186751e61f1fb8c64e7504e771f968cea9f4d11f5222b169e3744d494e',
+ policyId: 'f6f49b186751e61f1fb8c64e7504e771f968cea9f4d11f5222b169e3',
+ name: 'tMIN',
+ fingerprint: 'asset1dcspl93vqst7k7fcz2vx4mu6jvq7hsrse7zlpv',
+ quantity: BigInt('22471977'),
+ },
+ ],
+ [
+ 'f6f49b186751e61f1fb8c64e7504e771f968cea9f4d11f5222b169e374484f534b59',
+ {
+ assetId:
+ 'f6f49b186751e61f1fb8c64e7504e771f968cea9f4d11f5222b169e374484f534b59',
+ policyId: 'f6f49b186751e61f1fb8c64e7504e771f968cea9f4d11f5222b169e3',
+ name: 'tHOSKY',
+ fingerprint: 'asset15qks69wv4vk7clnhp4lq7x0rpk6vs0s6exw0ry',
+ quantity: BigInt('101'),
+ },
+ ],
+ ]),
+ ),
+ handles$: of(),
+ balance: {
+ utxo: {
+ total$: of({
+ coins: BigInt('12732198240'),
+ assets: new Map([
+ [
+ '0b23996b05afb3a76cc802dcb1d854a2b3596b208bf775c162cec2d34e6f6e5371756172654e66743235',
+ '100000000000',
+ ],
+ [
+ '212a16adbc2aec5cab350fc8e8a32defae6d766f7a774142d5ae995f54657374546f6b656e',
+ '9',
+ ],
+ [
+ '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff198444149',
+ '9000000',
+ ],
+ [
+ '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff198446a6564',
+ '10999999',
+ ],
+ [
+ '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff19855534443',
+ '4000000',
+ ],
+ [
+ '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff19855534454',
+ '9000000',
+ ],
+ [
+ '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff19869555344',
+ '10999999',
+ ],
+ [
+ 'e517b38693b633f1bc0dd3eb69cb1ad0f0c198c67188405901ae63a3001bc28068616e646c65735f6e61747572652d6c616b65',
+ '1',
+ ],
+ [
+ 'f6f49b186751e61f1fb8c64e7504e771f968cea9f4d11f5222b169e3744d494e',
+ '22471977',
+ ],
+ [
+ 'f6f49b186751e61f1fb8c64e7504e771f968cea9f4d11f5222b169e374484f534b59',
+ '101',
+ ],
+ ]),
+ }),
+ },
+ rewardAccounts: {
+ rewards$: of(BigInt(0)),
+ },
+ },
+};
+
+const noop = (async () => {}) as any;
+
+const SendStory = ({
+ colorMode,
+}: Readonly<{ colorMode: 'dark' | 'light' }>): React.ReactElement => {
+ const { setColorMode } = useColorMode();
+ setColorMode(colorMode);
+
+ return (
+
+
+
+ );
+};
+
+const customViewports = {
+ popup: {
+ name: 'Popup',
+ styles: {
+ width: '400px',
+ height: '600px',
+ },
+ },
+};
+
+const meta: Meta = {
+ title: 'Send',
+ component: SendStory,
+ parameters: {
+ viewport: {
+ viewports: customViewports,
+ defaultViewport: 'popup',
+ },
+ layout: 'centered',
+ },
+ beforeEach: () => {
+ createTab.mockImplementation(async () => {
+ await Promise.resolve();
+ });
+ isValidAddress.mockImplementation(() => {
+ return Wallet.HexBlob('');
+ });
+ useStoreState.mockImplementation((callback: any) => {
+ return callback({
+ ...store,
+ globalModel: {
+ sendStore: {
+ ...store.globalModel.sendStore,
+ txInfo,
+ },
+ },
+ });
+ });
+ useStoreActions.mockImplementation(() => {
+ return (): void => void 0;
+ });
+ getAdaHandle.mockImplementation(async () => {
+ return Wallet.Cardano.PaymentAddress(address.result);
+ });
+ minAdaRequired.mockImplementation(() => '969750');
+ buildTx.mockImplementation(async () => {
+ const tx = {
+ inspect: () => ({ inputSelection: { fee: '' } }),
+ };
+ return await Promise.resolve(tx);
+ });
+
+ Route.mockImplementation(({ path, component: Component }) => {
+ return <>{path === 'send' ? : null}>;
+ });
+
+ Cardano.Address.fromBech32.mockImplementation(() => ({
+ asBase: () => ({
+ getPaymentCredential: () => ({}),
+ }),
+ }));
+
+ return () => {
+ createTab.mockReset();
+ isValidAddress.mockReset();
+ useStoreState.mockReset();
+ useStoreActions.mockReset();
+ getAdaHandle.mockReset();
+ minAdaRequired.mockReset();
+ valueToAssets.mockReset();
+ buildTx.mockReset();
+ Route.mockReset();
+ Cardano.Address.fromBech32.mockReset();
+ };
+ },
+};
+type Story = StoryObj;
+export default meta;
+
+export const LayoutLight: Story = {
+ parameters: {
+ colorMode: 'light',
+ },
+};
+export const LayoutDark: Story = {
+ parameters: {
+ colorMode: 'dark',
+ },
+};
+
+export const RecentAddressLight: Story = {
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('Recent address popover', async () => {
+ await userEvent.click(
+ await canvas.findByPlaceholderText('Address or $handle'),
+ );
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ },
+};
+export const RecentAddressDark: Story = {
+ ...RecentAddressLight,
+ parameters: {
+ colorMode: 'dark',
+ },
+};
+
+export const AddressSuccessLight: Story = {
+ beforeEach: () => {
+ useStoreState.mockImplementation((callback: any) => {
+ return callback({
+ ...store,
+ globalModel: {
+ sendStore: {
+ ...store.globalModel.sendStore,
+ txInfo,
+ address,
+ },
+ },
+ });
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ },
+};
+
+export const AddressSuccessDark: Story = {
+ ...AddressSuccessLight,
+ parameters: {
+ colorMode: 'dark',
+ },
+};
+
+export const AmountErrorLight: Story = {
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('Ammount input with error', async () => {
+ const amountInput = await canvas.findByPlaceholderText('0.000000');
+ await userEvent.type(amountInput, '123123123123');
+ await userEvent.click(amountInput.parentElement);
+ });
+ },
+ beforeEach: () => {
+ useStoreState.mockImplementation((callback: any) => {
+ return callback({
+ ...store,
+ globalModel: {
+ sendStore: {
+ ...store.globalModel.sendStore,
+ txInfo,
+ address,
+ fee: { error: 'Transaction not possible' },
+ },
+ },
+ });
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ },
+};
+
+export const AmountErrorDark: Story = {
+ ...AmountErrorLight,
+ parameters: {
+ colorMode: 'dark',
+ },
+};
+
+export const AssetsLight: Story = {
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('Assets popover', async () => {
+ await userEvent.click(await canvas.findByText('+ Assets'));
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ },
+};
+
+export const AssetsDark: Story = {
+ ...AssetsLight,
+ parameters: {
+ colorMode: 'dark',
+ },
+};
+
+export const AssetsEmptyLight: Story = {
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('Asset popover empty', async () => {
+ await userEvent.click(await canvas.findByText('+ Assets'));
+ await userEvent.type(
+ await canvas.findByPlaceholderText('Search policy, asset, name'),
+ 'asd',
+ );
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ },
+};
+
+export const AssetsEmptyDark: Story = {
+ ...AssetsEmptyLight,
+ parameters: {
+ colorMode: 'dark',
+ },
+};
+
+export const AssetsSetQuantityLight: Story = {
+ beforeEach: () => {
+ useStoreState.mockImplementation((callback: any) => {
+ return callback({
+ ...store,
+ globalModel: {
+ sendStore: {
+ ...store.globalModel.sendStore,
+ txInfo,
+ address,
+ fee: { error: 'Asset quantity not set' },
+ value: {
+ ada: '23',
+ assets: [
+ {
+ unit: '212a16adbc2aec5cab350fc8e8a32defae6d766f7a774142d5ae995f54657374546f6b656e',
+ quantity: '9',
+ policy:
+ '212a16adbc2aec5cab350fc8e8a32defae6d766f7a774142d5ae995f',
+ name: 'TestToken',
+ fingerprint: 'asset16cee8gr79j5k4ag5v8wlk5ygg5fjyech5ugykj',
+ decimals: 0,
+ },
+ {
+ unit: '0b23996b05afb3a76cc802dcb1d854a2b3596b208bf775c162cec2d34e6f6e5371756172654e66743235',
+ quantity: '1',
+ policy:
+ '0b23996b05afb3a76cc802dcb1d854a2b3596b208bf775c162cec2d3',
+ name: 'NonSquareNft25',
+ fingerprint: 'asset15tfh93yjsffr7v9fepepuq2w4scl58eeaszmx7',
+ input: '1',
+ decimals: 0,
+ image:
+ 'https://ipfs.blockfrost.dev/ipfs/QmPmYGX7Vob7X9BkfHQeHskTJQJzgd9oZupugVSLXBJYLV',
+ },
+ ],
+ personalAda: '',
+ minAda: '0',
+ },
+ },
+ },
+ });
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ },
+};
+
+export const AssetsSetQuantityDark: Story = {
+ ...AssetsSetQuantityLight,
+ parameters: {
+ colorMode: 'dark',
+ },
+};
+
+export const AssetsWithQuantityLight: Story = {
+ beforeEach: () => {
+ useStoreState.mockImplementation((callback: any) => {
+ return callback({
+ ...store,
+ globalModel: {
+ sendStore: {
+ ...store.globalModel.sendStore,
+ message: '123',
+ txInfo,
+ tx: '84a5008282582092f5bfb3a21075094b37dbec4f487901946771f9b2e9875b7d4b611c5a55014e0582582092f5bfb3a21075094b37dbec4f487901946771f9b2e9875b7d4b611c5a55014e00018382583900a764bab46dd761e93a7dd0ed0ecbd8bbefffddc5c8c53bcc81956bdf0cce15160241079c27241e3cab535884d0d1aac690b060bd4b8f9556821a015ef3c0a2581c0b23996b05afb3a76cc802dcb1d854a2b3596b208bf775c162cec2d3a14e4e6f6e5371756172654e6674323501581c212a16adbc2aec5cab350fc8e8a32defae6d766f7a774142d5ae995fa14954657374546f6b656e0282583900e8fc28480c73486d288074c5ac7660ad0611ae5ce505de194353466961ea70af1de71795df52e62d1c0f2c8817f13b5cd4b40e04cab5ad6a821a0011b0dea1581c212a16adbc2aec5cab350fc8e8a32defae6d766f7a774142d5ae995fa14954657374546f6b656e0782583900e8fc28480c73486d288074c5ac7660ad0611ae5ce505de194353466961ea70af1de71795df52e62d1c0f2c8817f13b5cd4b40e04cab5ad6a1a705dc0e2021a0002c24d031a03bd4756075820538b6e75ff24315983465942e5a21f63496e3e4506f362ac47c36f9ee1d33f19a0f5a11902a2a1636d73678163313233',
+ address,
+ fee: { fee: '180813' },
+ value: {
+ ada: '23',
+ assets: [
+ {
+ unit: '212a16adbc2aec5cab350fc8e8a32defae6d766f7a774142d5ae995f54657374546f6b656e',
+ quantity: '9',
+ policy:
+ '212a16adbc2aec5cab350fc8e8a32defae6d766f7a774142d5ae995f',
+ name: 'TestToken',
+ displayName: 'TestToken',
+ fingerprint: 'asset16cee8gr79j5k4ag5v8wlk5ygg5fjyech5ugykj',
+ input: '2',
+ decimals: 0,
+ },
+ {
+ unit: '0b23996b05afb3a76cc802dcb1d854a2b3596b208bf775c162cec2d34e6f6e5371756172654e66743235',
+ quantity: '1',
+ policy:
+ '0b23996b05afb3a76cc802dcb1d854a2b3596b208bf775c162cec2d3',
+ name: 'NonSquareNft25',
+ displayName: 'NonSquareNft25',
+ fingerprint: 'asset15tfh93yjsffr7v9fepepuq2w4scl58eeaszmx7',
+ input: '1',
+ decimals: 0,
+ image:
+ 'https://ipfs.blockfrost.dev/ipfs/QmPmYGX7Vob7X9BkfHQeHskTJQJzgd9oZupugVSLXBJYLV',
+ },
+ ],
+ personalAda: '',
+ minAda: '0',
+ },
+ },
+ },
+ });
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ },
+};
+
+export const AssetsWithQuantityDark: Story = {
+ ...AssetsWithQuantityLight,
+ parameters: {
+ colorMode: 'dark',
+ },
+};
+
+export const ConfirmTransactionLight: Story = {
+ ...AssetsWithQuantityLight,
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('Confirm popover', async () => {
+ await userEvent.click(await canvas.findByTestId('sendBtn'));
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ },
+};
+
+export const ConfirmTransactionDark: Story = {
+ ...ConfirmTransactionLight,
+ parameters: {
+ colorMode: 'dark',
+ },
+};
+
+export const ConfirmTransactionSendingAssetsLight: Story = {
+ ...ConfirmTransactionLight,
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('Confirm popover sending assets', async () => {
+ await userEvent.click(await canvas.findByTestId('sendBtn'));
+ await userEvent.click(await screen.findByTestId('assetsBtn'));
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ },
+};
+
+export const ConfirmTransactionSendingAssetsDark: Story = {
+ ...ConfirmTransactionSendingAssetsLight,
+ parameters: {
+ colorMode: 'dark',
+ },
+};
+
+export const ConfirmTransactionSendingAssetsUncollapsedLight: Story = {
+ ...ConfirmTransactionSendingAssetsLight,
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('Confirm popover sending assets', async () => {
+ await userEvent.click(await canvas.findByTestId('sendBtn'));
+ await userEvent.click(await screen.findByTestId('assetsBtn'));
+ const assets = await screen.findAllByTestId('asset');
+ [...assets].forEach(async asset => {
+ await userEvent.click(asset);
+ });
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ },
+};
+
+export const ConfirmTransactionSendingAssetsUncollapsedDark: Story = {
+ ...ConfirmTransactionSendingAssetsUncollapsedLight,
+ parameters: {
+ colorMode: 'dark',
+ },
+};
diff --git a/packages/nami/src/ui/app/pages/send.tsx b/packages/nami/src/ui/app/pages/send.tsx
new file mode 100644
index 000000000..589427646
--- /dev/null
+++ b/packages/nami/src/ui/app/pages/send.tsx
@@ -0,0 +1,1394 @@
+import React from 'react';
+import { useHistory } from 'react-router-dom';
+import {
+ createTab,
+ displayUnit,
+ getAdaHandle,
+ isValidAddress,
+ toUnit,
+} from '../../../api/extension';
+import Account from '../components/account';
+import { Scrollbars } from '../components/scrollbar';
+import ConfirmModal from '../components/confirmModal';
+import {
+ CheckIcon,
+ ChevronLeftIcon,
+ CloseIcon,
+ SmallCloseIcon,
+} from '@chakra-ui/icons';
+import {
+ Box,
+ Stack,
+ Text,
+ Button,
+ Avatar,
+ IconButton,
+ Input,
+ InputGroup,
+ Popover,
+ PopoverArrow,
+ PopoverBody,
+ PopoverContent,
+ PopoverHeader,
+ PopoverTrigger,
+ useDisclosure,
+ InputRightElement,
+ InputLeftElement,
+ Spinner,
+ useColorModeValue,
+ useToast,
+ Icon,
+} from '@chakra-ui/react';
+import MiddleEllipsis from 'react-middle-ellipsis';
+import UnitDisplay from '../components/unitDisplay';
+import {
+ buildTx,
+ signAndSubmit,
+ signAndSubmitHW,
+} from '../../../api/extension/wallet';
+import { assetsToValue, minAdaRequired } from '../../../api/util';
+import { FixedSizeList as List } from 'react-window';
+import AssetBadge from '../components/assetBadge';
+import { ERROR, HW, TAB } from '../../../config/config';
+import { Planet } from 'react-kawaii';
+import { useStoreActions, useStoreState } from '../../store';
+import { action } from 'easy-peasy';
+import AvatarLoader from '../components/avatarLoader';
+import { NumericFormat } from 'react-number-format';
+import Copy from '../components/copy';
+import AssetsModal from '../components/assetsModal';
+import { MdModeEdit } from 'react-icons/md';
+import useConstant from 'use-constant';
+import { useCaptureEvent } from '../../../features/analytics/hooks';
+import { Events } from '../../../features/analytics/events';
+import debouncePromise from 'debounce-promise';
+import latest from 'promise-latest';
+import { Cardano, Serialization, ProviderUtil } from '@cardano-sdk/core';
+import { Ed25519KeyHashHex } from '@cardano-sdk/crypto';
+import type { Wallet } from '@lace/cardano';
+import { useObservable } from '@lace/common';
+import { useHandleResolver } from '../../../features/ada-handle/useHandleResolver';
+import { toAsset, withHandleInfo } from '../../../adapters/assets';
+import type { Asset as NamiAsset } from '../../../types/assets';
+import { UseAccount } from '../../../adapters/account';
+import { useOutsideHandles } from '../../../features/outside-handles-provider';
+
+interface Props {
+ walletAddress: string;
+ inMemoryWallet: Wallet.ObservableWallet;
+ currentChain: Wallet.Cardano.ChainId;
+ accounts: UseAccount['nonActiveAccounts'];
+ activeAccount: UseAccount['activeAccount'];
+ updateAccountMetadata: UseAccount['updateAccountMetadata'];
+ withSignTxConfirmation: (
+ action: () => Promise,
+ password?: string,
+ ) => Promise;
+}
+
+const useIsMounted = () => {
+ const isMounted = React.useRef(false);
+ React.useEffect(() => {
+ isMounted.current = true;
+ return () => (isMounted.current = false);
+ }, []);
+ return isMounted;
+};
+
+let timer = null;
+
+const initialState = {
+ fee: { fee: '0' },
+ value: { ada: '', assets: [], personalAda: '', minAda: '0' },
+ address: { result: '', display: '', error: '' },
+ message: '',
+ tx: null,
+ txInfo: {
+ minUtxo: 0,
+ },
+};
+
+export const sendStore = {
+ ...initialState,
+ setFee: action((state, fee) => {
+ state.fee = fee;
+ }),
+ setValue: action((state, value) => {
+ state.value = value;
+ }),
+ setMessage: action((state, message) => {
+ state.message = message;
+ }),
+ setTx: action((state, tx) => {
+ state.tx = tx;
+ }),
+ setAddress: action((state, address) => {
+ state.address = address;
+ }),
+ setTxInfo: action((state, txInfo) => {
+ state.txInfo = txInfo;
+ }),
+ reset: action(state => {
+ state.fee = initialState.fee;
+ state.value = initialState.value;
+ state.message = initialState.message;
+ state.address = initialState.address;
+ state.tx = initialState.tx;
+ state.txInfo = initialState.txInfo;
+ }),
+};
+
+const Send = ({
+ accounts,
+ activeAccount,
+ inMemoryWallet,
+ walletAddress,
+ currentChain,
+ updateAccountMetadata,
+ withSignTxConfirmation,
+}: Props) => {
+ const capture = useCaptureEvent();
+ const isMounted = useIsMounted();
+ const { cardanoCoin } = useOutsideHandles();
+ const [address, setAddress] = [
+ useStoreState(state => state.globalModel.sendStore.address),
+ useStoreActions(actions => actions.globalModel.sendStore.setAddress),
+ ];
+ const [value, setValue] = [
+ useStoreState(state => state.globalModel.sendStore.value),
+ useStoreActions(actions => actions.globalModel.sendStore.setValue),
+ ];
+ const [message, setMessage] = [
+ useStoreState(state => state.globalModel.sendStore.message),
+ useStoreActions(actions => actions.globalModel.sendStore.setMessage),
+ ];
+ const [txInfo, setTxInfo] = [
+ useStoreState(state => state.globalModel.sendStore.txInfo),
+ useStoreActions(actions => actions.globalModel.sendStore.setTxInfo),
+ ];
+
+ const [fee, setFee] = [
+ useStoreState(state => state.globalModel.sendStore.fee),
+ useStoreActions(actions => actions.globalModel.sendStore.setFee),
+ ];
+ const [tx, setTx] = [
+ useStoreState(state => state.globalModel.sendStore.tx),
+ useStoreActions(actions => actions.globalModel.sendStore.setTx),
+ ];
+
+ const [txUpdate, setTxUpdate] = React.useState(false);
+ const triggerTxUpdate = stateChange => {
+ stateChange();
+ setTxUpdate(update => !update);
+ };
+
+ const assets = React.useRef({});
+ const account = React.useRef(null);
+ const resetState = useStoreActions(
+ actions => actions.globalModel.sendStore.reset,
+ );
+ const history = useHistory();
+ const navigate = history.push;
+ const toast = useToast();
+ const ref = React.useRef();
+ const [isLoading, setIsLoading] = React.useState(true);
+ const focus = React.useRef(false);
+ const background = useColorModeValue('gray.100', 'gray.600');
+ const containerBg = useColorModeValue('white', 'gray.800');
+
+ const assetsModalRef = React.useRef();
+ const protocolParameters = useObservable(inMemoryWallet.protocolParameters$);
+ const utxoTotal = useObservable(inMemoryWallet?.balance.utxo.total$);
+ const assetsInfo = withHandleInfo(
+ useObservable(inMemoryWallet.assetInfo$),
+ useObservable(inMemoryWallet.handles$),
+ );
+ const rewards = useObservable(
+ inMemoryWallet?.balance.rewardAccounts.rewards$,
+ );
+
+ const paymentKeyHash = Ed25519KeyHashHex(
+ Cardano.Address.fromBech32(walletAddress).asBase()!.getPaymentCredential()
+ .hash,
+ );
+
+ const walletAssets = Array.from(utxoTotal?.assets || [])
+ .filter(([assetId]) => assetsInfo.has(assetId))
+ .map(([assetId, quantity]) => toAsset(assetsInfo.get(assetId)!, quantity));
+
+ const prepareTx = async (
+ _,
+ data: {
+ value: any;
+ address: any;
+ message: any;
+ protocolParameters: Cardano.ProtocolParameters;
+ },
+ ) => {
+ if (!isMounted.current) return;
+
+ const _value = data.value;
+ const _address = data.address;
+ const _message = data.message;
+ const protocolParameters = data.protocolParameters;
+ if (!_value.ada && _value.assets.length <= 0) {
+ setFee({ fee: '0' });
+ setTx(null);
+ return;
+ }
+ if (
+ _address.error ||
+ !_address.result ||
+ (!_value.ada && _value.assets.length <= 0)
+ ) {
+ setFee({ fee: '0' });
+ setTx(null);
+ return;
+ }
+
+ setFee({ fee: '' });
+ setTx(null);
+ await new Promise((res, rej) => setTimeout(() => res(null)));
+ try {
+ const output = {
+ address: _address.result,
+ amount: [
+ {
+ unit: 'lovelace',
+ quantity: toUnit(_value.ada || '10000000'),
+ },
+ ],
+ };
+
+ for (const asset of _value.assets) {
+ if (
+ !asset.input ||
+ BigInt(toUnit(asset.input, asset.decimals) || '0') < 1
+ ) {
+ setFee({ error: 'Asset quantity not set' });
+ return;
+ }
+ output.amount.push({
+ unit: asset.unit,
+ quantity: toUnit(asset.input, asset.decimals),
+ });
+ }
+
+ const checkOutput = new Serialization.TransactionOutput(
+ Cardano.Address.fromBytes(
+ isValidAddress(_address.result, currentChain),
+ ),
+ assetsToValue(output.amount),
+ );
+
+ const minAda = minAdaRequired(
+ checkOutput,
+ BigInt(protocolParameters.coinsPerUtxoByte),
+ );
+
+ if (BigInt(minAda) <= BigInt(toUnit(_value.personalAda || '0'))) {
+ const displayAda = parseFloat(
+ _value.personalAda.replace(/[,\s]/g, ''),
+ ).toLocaleString('en-EN', { minimumFractionDigits: 6 });
+ output.amount[0].quantity = toUnit(_value.personalAda || '0');
+ !focus.current && setValue({ ..._value, ada: displayAda });
+ } else if (_value.assets.length > 0) {
+ output.amount[0].quantity = minAda;
+ const minAdaDisplay = parseFloat(
+ displayUnit(minAda).toString().replace(/[,\s]/g, ''),
+ ).toLocaleString('en-EN', { minimumFractionDigits: 6 });
+ setValue({
+ ..._value,
+ ada: minAdaDisplay,
+ });
+ }
+
+ if (BigInt(minAda) > BigInt(output.amount[0].quantity || '0')) {
+ setFee({ error: 'Transaction not possible' });
+ return;
+ }
+
+ const transactionOutput = new Serialization.TransactionOutput(
+ Cardano.Address.fromBytes(
+ isValidAddress(_address.result, currentChain),
+ ),
+ assetsToValue(output.amount),
+ );
+
+ const generalMetadata: Map =
+ new Map();
+ const auxiliaryData = new Serialization.AuxiliaryData();
+
+ // setting metadata for optional message (CIP-0020)
+ if (_message) {
+ function chunkSubstr(str, size) {
+ const numChunks = Math.ceil(str.length / size);
+ const chunks = new Array(numChunks);
+
+ for (let i = 0, o = 0; i < numChunks; ++i, o += size) {
+ chunks[i] = str.substr(o, size);
+ }
+
+ return chunks;
+ }
+ const msg = { msg: chunkSubstr(_message, 64) };
+ generalMetadata.set(
+ BigInt('674'),
+ Serialization.TransactionMetadatum.fromCore(
+ ProviderUtil.jsonToMetadatum(msg),
+ ),
+ );
+ }
+
+ if (generalMetadata.size > 0) {
+ auxiliaryData.setMetadata(
+ new Serialization.GeneralTransactionMetadata(generalMetadata),
+ );
+ }
+
+ const tx = await buildTx(
+ transactionOutput,
+ auxiliaryData,
+ inMemoryWallet,
+ );
+ const inspection = await tx.inspect();
+ setFee({ fee: inspection.inputSelection.fee.toString() });
+ setTx(tx);
+ } catch (e) {
+ setFee({ error: 'Transaction not possible' });
+ }
+ };
+
+ const prepareTxDebounced = useConstant(() =>
+ debouncePromise(latest(prepareTx), 300),
+ );
+
+ const init = async () => {
+ if (!isMounted.current || !protocolParameters) return;
+ addAssets(value.assets);
+
+ account.current = {};
+
+ const checkOutput = new Serialization.TransactionOutput(
+ Cardano.Address.fromBech32(walletAddress),
+ new Serialization.Value(BigInt(0)),
+ );
+ const minUtxo = await minAdaRequired(
+ checkOutput,
+ BigInt(protocolParameters.coinsPerUtxoByte),
+ );
+
+ setIsLoading(false);
+ setTxInfo({ minUtxo });
+ };
+
+ const objectToArray = obj => Object.keys(obj).map(key => obj[key]);
+
+ const addAssets = _assets => {
+ _assets.forEach(asset => {
+ assets.current[asset.unit] = { ...asset };
+ });
+ const assetsList = objectToArray(assets.current);
+ triggerTxUpdate(() => setValue({ ...value, assets: assetsList }));
+ };
+
+ const removeAsset = asset => {
+ delete assets.current[asset.unit];
+ const assetsList = objectToArray(assets.current);
+ triggerTxUpdate(() => setValue({ ...value, assets: assetsList }));
+ };
+
+ React.useEffect(() => {
+ if (protocolParameters) {
+ setTx(null);
+ setFee({ fee: '' });
+ prepareTxDebounced(0, {
+ value,
+ address,
+ message,
+ protocolParameters,
+ });
+ }
+ }, [txUpdate]);
+
+ React.useEffect(() => {
+ init();
+ }, [protocolParameters]);
+
+ React.useEffect(() => {
+ return () => {
+ resetState();
+ };
+ }, []);
+ return (
+ <>
+
+ {protocolParameters && isLoading ? (
+
+
+
+ ) : (
+ <>
+
+
+ {
+ history.goBack();
+ }}
+ variant="ghost"
+ icon={ }
+ />
+
+
+
+ Send
+
+
+
+
+ {address.error && (
+
+ {address.error}
+
+ )}
+
+
+
+
+ {!isLoading ? (
+ {cardanoCoin.symbol}
+ ) : (
+
+ )}
+
+ }
+ />
+ {
+ const val = e.target.value;
+ value.ada = val;
+ value.personalAda = val;
+ const v = value;
+ triggerTxUpdate(() =>
+ setValue({
+ ...v,
+ }),
+ );
+ }}
+ variant="filled"
+ isDisabled={isLoading}
+ isInvalid={
+ value.ada &&
+ (BigInt(toUnit(value.ada)) < BigInt(txInfo.minUtxo) ||
+ BigInt(toUnit(value.ada)) >
+ BigInt(
+ BigInt(utxoTotal?.coins || 0) +
+ BigInt(rewards || 0) || '0',
+ ))
+ }
+ onFocus={() => (focus.current = true)}
+ placeholder="0.000000"
+ customInput={Input}
+ />
+
+
+
+
+
+
+
+ } />
+ {
+ const msg = e.target.value;
+ triggerTxUpdate(() => setMessage(msg));
+ }}
+ size={'sm'}
+ variant={'flushed'}
+ placeholder="Optional message"
+ fontSize={'xs'}
+ />
+
+
+
+
+
+ {value.assets.map(asset => (
+
+ {
+ removeAsset(asset);
+ }}
+ onInput={async val => {
+ if (!assets.current[asset.unit]) return;
+ assets.current[asset.unit].input = val;
+ const v = value;
+ v.assets = objectToArray(assets.current);
+ triggerTxUpdate(() =>
+ setValue({ ...v, assets: v.assets }),
+ );
+ }}
+ asset={asset}
+ />
+
+ ))}
+
+
+
+
+
+ 0)
+ }
+ width={'366px'}
+ height={'50px'}
+ isDisabled={!tx || !address.result || fee.error}
+ colorScheme="orange"
+ onClick={() => {
+ capture(Events.SendTransactionDataReviewTransactionClick);
+ ref.current.openModal(account.current.index);
+ }}
+ >
+ {fee.error ? fee.error : 'Send'}
+
+
+ >
+ )}
+
+
+
+
+ {value.assets.length > 0 && (
+
+ assetsModalRef.current.openModal({
+ userInput: true,
+ assets: value.assets.map(asset => ({
+ ...asset,
+ quantity: toUnit(asset.input, asset.decimals),
+ })),
+ background: 'red.400',
+ color: 'white',
+ title: (
+
+ Sending{' '}
+
+ {value.assets.length}
+ {' '}
+ {value.assets.length == 1 ? 'asset' : 'assets'}
+
+ ),
+ })
+ }
+ >
+ + {value.assets.length}{' '}
+ {value.assets.length > 1 ? 'Assets' : 'Asset'}
+
+ )}
+
+ to
+
+
+ {' '}
+
+
+
+
+ {address.result}
+
+
+
+
+
+
+
+ {' '}
+
+ fee
+
+
+
+
+ }
+ ref={ref}
+ sign={async (password, hw) => {
+ capture(Events.SendTransactionConfirmationConfirmClick);
+ if (hw) {
+ if (hw.device === HW.trezor) {
+ return createTab(TAB.trezorTx, `?tx=${tx}`);
+ }
+ return await signAndSubmitHW(txDes, {
+ keyHashes: [paymentKeyHash],
+ account: account.current,
+ hw,
+ });
+ } else
+ return await signAndSubmit(
+ tx,
+ password,
+ withSignTxConfirmation,
+ inMemoryWallet,
+ );
+ }}
+ onConfirm={async (status, signedTx) => {
+ if (status === true) {
+ capture(Events.SendTransactionConfirmed);
+ toast({
+ title: 'Transaction submitted',
+ status: 'success',
+ duration: 5000,
+ });
+ if (await isValidAddress(address.result, currentChain)) {
+ await updateAccountMetadata({
+ namiMode: { recentSendToAddress: address.result },
+ });
+ }
+ } else if (signedTx === ERROR.fullMempool) {
+ toast({
+ title: 'Transaction failed',
+ description: 'Mempool full. Try again.',
+ status: 'error',
+ duration: 3000,
+ isClosable: true,
+ });
+ ref.current.closeModal();
+ return; // don't go back to home screen. let user try to submit same tx again
+ } else
+ toast({
+ title: 'Transaction failed',
+ status: 'error',
+ duration: 3000,
+ });
+ ref.current?.closeModal();
+ setTimeout(() => {
+ navigate(-1);
+ }, 200);
+ }}
+ />
+ >
+ );
+};
+
+// Address Popup
+const AddressPopup = ({
+ accounts,
+ currentChain,
+ setAddress,
+ address,
+ triggerTxUpdate,
+ isLoading,
+ recentSendToAddress,
+}: {
+ accounts: {
+ name: string;
+ avatar?: string;
+ address?: string;
+ }[];
+ recentSendToAddress?: string;
+ currentChain: Wallet.Cardano.ChainId;
+ setAddress: any;
+ address: { result: string; display: string; error?: string };
+ triggerTxUpdate: any;
+ isLoading: boolean;
+}) => {
+ const { isOpen, onOpen, onClose } = useDisclosure();
+ const checkColor = useColorModeValue('teal.500', 'teal.200');
+ const ref = React.useRef(false);
+ const latestHandleInputToken = React.useRef(0);
+ const handleResolver = useHandleResolver(currentChain.networkMagic);
+
+ const handleInput = async e => {
+ const value = e.target.value;
+ let addr;
+ let isHandle = false;
+ if (!e.target.value) {
+ addr = { result: '', display: '' };
+ } else if (value.startsWith('$')) {
+ isHandle = true;
+ addr = { display: value };
+ } else if (isValidAddress(value, currentChain)) {
+ addr = { result: value, display: value };
+ } else {
+ addr = {
+ result: value,
+ display: value,
+ error: 'Address is invalid',
+ };
+ }
+
+ if (isHandle) {
+ const handle = value;
+
+ const resolvedAddress = await getAdaHandle(
+ handle.slice(1),
+ handleResolver,
+ );
+ if (
+ handle.length > 1 &&
+ resolvedAddress &&
+ isValidAddress(resolvedAddress, currentChain)
+ ) {
+ addr = {
+ result: resolvedAddress,
+ display: handle,
+ };
+ } else {
+ addr = {
+ result: '',
+ display: handle,
+ error: '$handle not found',
+ };
+ }
+ }
+
+ return addr;
+ };
+
+ const handleInputDebounced = useConstant(() =>
+ debouncePromise(latest(handleInput), 700),
+ );
+
+ return (
+ 0) && isOpen}
+ onOpen={() => !isLoading && !address.result && !address.error && onOpen()}
+ autoFocus={false}
+ onClose={async () => {
+ await new Promise((res, rej) => setTimeout(() => res()));
+ if (ref.current) {
+ ref.current = false;
+ return;
+ }
+ onClose();
+ }}
+ gutter={1}
+ >
+
+
+ {
+ await new Promise((res, rej) => setTimeout(() => res()));
+ if (ref.current) {
+ ref.current = false;
+ return;
+ }
+ onClose();
+ setTimeout(() => e.target.blur());
+ }}
+ fontSize="xs"
+ placeholder="Address or $handle"
+ onInput={async e => {
+ const handleInputToken = latestHandleInputToken.current + 1;
+ latestHandleInputToken.current = handleInputToken;
+ setAddress({ display: e.target.value });
+ const addr = await handleInputDebounced(e);
+
+ if (handleInputToken !== latestHandleInputToken.current) {
+ return;
+ }
+
+ triggerTxUpdate(() => setAddress(addr));
+ onClose();
+ }}
+ isInvalid={Boolean(address.error)}
+ />
+ {address.result && !address.error && (
+ }
+ />
+ )}
+
+
+ {
+ ref.current = false;
+ }}
+ onFocus={() => {
+ ref.current = true;
+ }}
+ _focus={{ outline: 'none' }}
+ >
+
+
+
+ {recentSendToAddress && (
+ {
+ const address = recentSendToAddress;
+ triggerTxUpdate(() =>
+ setAddress({
+ result: address,
+ display: address,
+ }),
+ );
+ onClose();
+ }}
+ >
+
+
+ Recent
+
+
+
+
+ {recentSendToAddress}
+
+
+
+
+ )}
+ {accounts.length > 0 && (
+ <>
+ {' '}
+
+ Accounts
+
+ {accounts.map(({ name, address, avatar }) => {
+ return (
+ {
+ clearTimeout(timer);
+ triggerTxUpdate(() =>
+ setAddress({
+ result: address,
+ display: address,
+ }),
+ );
+ onClose();
+ }}
+ >
+
+
+
+
+
+
+ {name}
+
+
+
+ {address}
+
+
+
+
+
+ );
+ })}{' '}
+ >
+ )}
+
+
+
+
+
+ );
+};
+
+// Asset Popup
+
+const CustomScrollbars = ({ onScroll, forwardedRef, style, children }) => {
+ const refSetter = React.useCallback(scrollbarsRef => {
+ if (scrollbarsRef) {
+ forwardedRef(scrollbarsRef.view);
+ } else {
+ forwardedRef(null);
+ }
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+};
+
+const CustomScrollbarsVirtualList = React.forwardRef((props, ref) => (
+
+));
+
+const AssetsSelector = ({
+ assets,
+ addAssets,
+ value,
+}: {
+ assets: NamiAsset[];
+ addAssets: any;
+ value: any;
+}) => {
+ const { isOpen, onOpen, onClose } = useDisclosure();
+ const [search, setSearch] = React.useState('');
+ const select = React.useRef(false);
+ const [choice, setChoice] = React.useState({});
+
+ const filterAssets = () => {
+ const filter1 = asset =>
+ value.assets.every(asset2 => asset.unit !== asset2.unit);
+ const filter2 = asset =>
+ search
+ ? asset.name.toLowerCase().includes(search.toLowerCase()) ||
+ asset.policy.includes(search) ||
+ asset.fingerprint.includes(search)
+ : true;
+ return assets.filter(asset => filter1(asset) && filter2(asset));
+ };
+
+ return (
+
+
+
+ + Assets
+
+
+
+
+
+ 0 && 3}
+ size="sm"
+ >
+ {
+ setSearch(e.target.value);
+ }}
+ />
+ setSearch('')}
+ />
+ }
+ />
+
+ {Object.keys(choice).length > 0 && (
+ <>
+
+
+ setChoice({})}
+ icon={ }
+ />
+
+
+ {
+ onClose();
+ setTimeout(() => {
+ addAssets(assets.filter(asset => choice[asset.unit]));
+ setChoice({});
+ }, 100);
+ }}
+ icon={ }
+ />
+
+ >
+ )}
+
+
+
+ {assets ? (
+ filterAssets().length > 0 ? (
+
+ {({ index, style }) => {
+ const asset = filterAssets()[index];
+ return (
+
+
+
+ );
+ }}
+
+ ) : (
+
+
+
+
+ No Assets
+
+
+ )
+ ) : (
+
+
+
+ )}
+
+
+
+
+ );
+};
+
+const Asset = ({
+ asset,
+ choice,
+ select,
+ setChoice,
+ onClose,
+ addAssets,
+}: {
+ asset: NamiAsset;
+ choice;
+ select;
+ setChoice;
+ onClose;
+ addAssets;
+}) => {
+ const hoverColor = useColorModeValue('gray.100', 'gray.600');
+
+ return (
+ {
+ if (select.current) {
+ select.current = false;
+ return;
+ }
+ onClose();
+ addAssets([asset]);
+ }}
+ mr="3"
+ ml="4"
+ display="flex"
+ alignItems="center"
+ justifyContent="start"
+ variant="ghost"
+ >
+
+
+
+
+
+
+ {asset.labeledName}
+
+
+
+
+ Policy: {asset.policy}
+
+
+
+
+
+
+
+
+ );
+};
+
+const Selection = ({
+ select,
+ asset,
+ choice,
+ setChoice,
+}: {
+ select;
+ asset: NamiAsset;
+ choice;
+ setChoice;
+}) => {
+ const selectColor = useColorModeValue('orange.500', 'orange.200');
+ return (
+ (select.current = true)}
+ >
+ {choice[asset.unit] ? (
+ {
+ delete choice[asset.unit];
+ setChoice({ ...choice });
+ }}
+ >
+
+
+ ) : (
+ {
+ choice[asset.unit] = true;
+ setChoice({ ...choice });
+ }}
+ userSelect="none"
+ size="xs"
+ name={asset.name}
+ />
+ )}
+
+ );
+};
+
+export default Send;
diff --git a/packages/nami/src/ui/app/pages/settings.stories.tsx b/packages/nami/src/ui/app/pages/settings.stories.tsx
new file mode 100644
index 000000000..86648eafc
--- /dev/null
+++ b/packages/nami/src/ui/app/pages/settings.stories.tsx
@@ -0,0 +1,295 @@
+import React from 'react';
+
+import { Box, useColorMode } from '@chakra-ui/react';
+import type { Meta, StoryObj } from '@storybook/react';
+import { userEvent, within } from '@storybook/test';
+
+import {
+ getCurrentAccount,
+ getFavoriteIcon,
+} from '../../../api/extension/api.mock';
+import { currentAccount } from '../../../mocks/account.mock';
+
+import Settings from './settings';
+import { useStoreState, useStoreActions } from '../../store.mock';
+import { store } from '../../../mocks/store.mock';
+import {
+ Route,
+ mockedHistory,
+ useHistory,
+} from '../../../../.storybook/mocks/react-router-dom.mock';
+import { CurrencyCode } from '../../../adapters/currency';
+import { Wallet } from '@lace/cardano';
+
+const SettingsStory = ({
+ colorMode,
+ path,
+ connectedDapps,
+}: Readonly<{
+ colorMode: 'dark' | 'light';
+ path: string;
+ connectedDapps: Wallet.DappInfo[];
+}>): React.ReactElement => {
+ const { setColorMode } = useColorMode();
+ setColorMode(colorMode);
+ const history = useHistory();
+ history.replace(path ?? '*');
+
+ return (
+
+ true}
+ availableChains={['Mainnet', 'Preprod', 'Preview', 'Sanchonet']}
+ environmentName="Preprod"
+ getCustomSubmitApiForNetwork={() => ({
+ status: true,
+ url: 'https://cardano-preprod.blockfrost.io/api/v0',
+ })}
+ connectedDapps={connectedDapps}
+ removeDapp={async () => false}
+ accountName={currentAccount.name}
+ accountAvatar={currentAccount.avatar}
+ changePassword={async () => {}}
+ currency={CurrencyCode.USD}
+ deleteWallet={async () => {}}
+ setCurrency={() => {}}
+ setTheme={() => {}}
+ theme="light"
+ updateAccountMetadata={async () => undefined}
+ isAnalyticsOptIn={false}
+ handleAnalyticsChoice={async () => {}}
+ switchWalletMode={async () => {}}
+ switchNetwork={async () => {}}
+ enableCustomNode={async () => {}}
+ defaultSubmitApi=""
+ />
+
+ );
+};
+
+const customViewports = {
+ popup: {
+ name: 'Popup',
+ styles: {
+ width: '400px',
+ height: '600px',
+ },
+ },
+};
+
+const meta: Meta = {
+ title: 'Settings',
+ component: SettingsStory,
+ parameters: {
+ viewport: {
+ viewports: customViewports,
+ defaultViewport: 'popup',
+ },
+ layout: 'centered',
+ },
+ beforeEach: () => {
+ getCurrentAccount.mockImplementation(async () => {
+ return await Promise.resolve(currentAccount);
+ });
+ useStoreState.mockImplementation((callback: any) => {
+ return callback(store);
+ });
+ useStoreActions.mockImplementation(() => {
+ return () => void 0;
+ });
+ Route.mockImplementation(({ path, children }) => {
+ return (mockedHistory[0] === '' && path === '*') ||
+ mockedHistory[0] === path
+ ? children
+ : null;
+ });
+
+ return () => {
+ getCurrentAccount.mockReset();
+ useStoreState.mockReset();
+ useStoreActions.mockReset();
+ Route.mockReset();
+ };
+ },
+};
+type Story = StoryObj;
+export default meta;
+
+export const SettingsLight: Story = {
+ parameters: {
+ colorMode: 'light',
+ path: '',
+ },
+};
+
+export const SettingsDark: Story = {
+ ...SettingsLight,
+ parameters: {
+ ...SettingsLight.parameters,
+ colorMode: 'dark',
+ },
+};
+
+export const GeneralSettingsLight: Story = {
+ parameters: {
+ colorMode: 'light',
+ path: '/settings/general',
+ },
+};
+
+export const GeneralSettingsDark: Story = {
+ parameters: {
+ ...GeneralSettingsLight.parameters,
+ colorMode: 'dark',
+ },
+};
+
+export const GeneralChangePasswordLight: Story = {
+ parameters: {
+ colorMode: 'light',
+ path: '/settings/general',
+ },
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('Toggle', async () => {
+ await userEvent.click(canvas.getByText('Change Password'));
+ });
+ },
+};
+
+export const GeneralChangePasswordDark: Story = {
+ ...GeneralChangePasswordLight,
+ parameters: {
+ ...GeneralChangePasswordLight.parameters,
+ colorMode: 'dark',
+ },
+};
+export const GeneralResetWalletLight: Story = {
+ parameters: {
+ colorMode: 'light',
+ path: '/settings/general',
+ },
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('Toggle', async () => {
+ await userEvent.click(canvas.getByText('Reset Wallet'));
+ });
+ },
+};
+
+export const GeneralResetWalletDark: Story = {
+ ...GeneralResetWalletLight,
+ parameters: {
+ ...GeneralResetWalletLight.parameters,
+ colorMode: 'dark',
+ },
+};
+
+export const WhitelistedLight: Story = {
+ parameters: {
+ colorMode: 'light',
+ path: '/settings/whitelisted',
+ connectedDapps: [
+ {
+ url: 'https://app.sundae.fi',
+ },
+ ],
+ },
+ beforeEach: () => {
+ getFavoriteIcon.mockImplementation(() => {
+ return 'https://app.sundae.fi/static/images/favicon.png';
+ });
+ },
+};
+
+export const WhitelistedDark: Story = {
+ ...WhitelistedLight,
+ parameters: {
+ ...WhitelistedLight.parameters,
+ colorMode: 'dark',
+ },
+};
+
+export const WhitelistedEmptyLight: Story = {
+ parameters: {
+ colorMode: 'light',
+ path: '/settings/whitelisted',
+ connectedDapps: [],
+ },
+};
+export const WhitelistedEmptyDark: Story = {
+ parameters: {
+ ...WhitelistedEmptyLight.parameters,
+ colorMode: 'dark',
+ },
+};
+
+export const NetworkLight: Story = {
+ parameters: {
+ colorMode: 'light',
+ path: '/settings/network',
+ },
+};
+
+export const NetworkDark: Story = {
+ parameters: {
+ ...NetworkLight.parameters,
+ colorMode: 'dark',
+ },
+};
+
+export const LegalLight: Story = {
+ parameters: {
+ colorMode: 'light',
+ path: '/settings/legal',
+ },
+};
+
+export const LegalDark: Story = {
+ parameters: {
+ ...LegalLight.parameters,
+ colorMode: 'dark',
+ },
+};
+
+export const LegalTermsOfUseLight: Story = {
+ parameters: {
+ colorMode: 'light',
+ path: '/settings/legal',
+ },
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('modal', async () => {
+ await userEvent.click(canvas.getByText('Terms of Use'));
+ });
+ },
+};
+
+export const LegalTermsOfUseDark: Story = {
+ ...LegalTermsOfUseLight,
+ parameters: {
+ ...LegalTermsOfUseLight.parameters,
+ colorMode: 'dark',
+ },
+};
+
+export const LegalPrivacyPolicyLight: Story = {
+ parameters: {
+ colorMode: 'light',
+ path: '/settings/legal',
+ },
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('modal', async () => {
+ await userEvent.click(canvas.getByText('Privacy Policy'));
+ });
+ },
+};
+
+export const LegalPrivacyPolicyDark: Story = {
+ ...LegalPrivacyPolicyLight,
+ parameters: {
+ ...LegalPrivacyPolicyLight.parameters,
+ colorMode: 'dark',
+ },
+};
diff --git a/packages/nami/src/ui/app/pages/settings.tsx b/packages/nami/src/ui/app/pages/settings.tsx
new file mode 100644
index 000000000..f21f2acc4
--- /dev/null
+++ b/packages/nami/src/ui/app/pages/settings.tsx
@@ -0,0 +1,643 @@
+/* eslint-disable react/no-multi-comp */
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+
+import {
+ ChevronLeftIcon,
+ ChevronRightIcon,
+ SunIcon,
+ SmallCloseIcon,
+ RepeatIcon,
+ CheckIcon,
+} from '@chakra-ui/icons';
+import {
+ Box,
+ Button,
+ IconButton,
+ Text,
+ useColorMode,
+ Switch as ButtonSwitch,
+ Image,
+ SkeletonCircle,
+ Checkbox,
+ Input,
+ InputGroup,
+ InputRightElement,
+ Icon,
+ Select,
+ useColorModeValue,
+} from '@chakra-ui/react';
+import { MdModeEdit } from 'react-icons/md';
+import { Route, Switch, useHistory } from 'react-router-dom';
+
+import { CurrencyCode } from '../../../adapters/currency';
+import { getFavoriteIcon } from '../../../api/extension';
+import { Events } from '../../../features/analytics/events';
+import { useCaptureEvent } from '../../../features/analytics/hooks';
+import { LegalSettings } from '../../../features/settings/legal/LegalSettings';
+import { useStoreActions } from '../../store';
+import Account from '../components/account';
+import AvatarLoader from '../components/avatarLoader';
+import { ChangePasswordModal } from '../components/changePasswordModal';
+import ConfirmModal from '../components/confirmModal';
+
+import type { UseAccount } from '../../../adapters/account';
+import type { OutsideHandlesContextValue } from '../../../features/outside-handles-provider';
+import type { Wallet } from '@lace/cardano';
+
+type Props = Pick<
+ OutsideHandlesContextValue,
+ | 'availableChains'
+ | 'connectedDapps'
+ | 'defaultSubmitApi'
+ | 'enableCustomNode'
+ | 'environmentName'
+ | 'getCustomSubmitApiForNetwork'
+ | 'handleAnalyticsChoice'
+ | 'isAnalyticsOptIn'
+ | 'isValidURL'
+ | 'removeDapp'
+ | 'setTheme'
+ | 'switchNetwork'
+ | 'theme'
+> & {
+ currency: CurrencyCode;
+ setCurrency: (currency: CurrencyCode) => void;
+ changePassword: (
+ currentPassword: string,
+ newPassword: string,
+ ) => Promise;
+ deleteWallet: (password: string) => Promise;
+ accountName: string;
+ accountAvatar?: string;
+ updateAccountMetadata: UseAccount['updateAccountMetadata'];
+};
+
+const Settings = ({
+ currency,
+ setCurrency,
+ theme,
+ setTheme,
+ accountName,
+ accountAvatar,
+ isAnalyticsOptIn,
+ connectedDapps,
+ removeDapp,
+ handleAnalyticsChoice,
+ changePassword,
+ deleteWallet,
+ updateAccountMetadata,
+ environmentName,
+ switchNetwork,
+ availableChains,
+ enableCustomNode,
+ getCustomSubmitApiForNetwork,
+ defaultSubmitApi,
+ isValidURL,
+}: Readonly) => {
+ const history = useHistory();
+ const containerBg = useColorModeValue('white', 'gray.800');
+ const textColor = useColorModeValue('rgb(26, 32, 44)', 'inherit');
+ const setIsLaceSwitchInProgress = useStoreActions(
+ actions => actions.globalModel.laceSwitchStore.setIsLaceSwitchInProgress,
+ );
+
+ return (
+
+
+
+ {
+ history.goBack();
+ }}
+ variant="ghost"
+ icon={ }
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ setIsLaceSwitchInProgress(true);
+ }}
+ />
+
+
+
+ );
+};
+
+const Overview = ({ onShowLaceBanner }: { onShowLaceBanner: () => void }) => {
+ const capture = useCaptureEvent();
+ const history = useHistory();
+ const navigate = history.push;
+
+ const handleShowLaceBannerClick = () => {
+ onShowLaceBanner();
+ void capture(Events.SettingsSwitchToLaceModeClick);
+ };
+
+ return (
+ <>
+
+
+ Settings
+
+
+
+
+
+ New
+
+
+
+ >
+ }
+ variant="ghost"
+ onClick={handleShowLaceBannerClick}
+ >
+ Switch to Lace Mode
+
+
+ }
+ variant="ghost"
+ onClick={() => {
+ navigate('/settings/general');
+ }}
+ >
+ General settings
+
+
+ }
+ variant="ghost"
+ onClick={() => {
+ capture(Events.SettingsAuthorizedDappsClick);
+ navigate('whitelisted');
+ }}
+ >
+ Whitelisted sites
+
+
+ }
+ variant="ghost"
+ onClick={() => {
+ navigate('network');
+ }}
+ >
+ Network
+
+
+ }
+ variant="ghost"
+ onClick={() => {
+ navigate('legal');
+ }}
+ >
+ Legal
+
+ >
+ );
+};
+
+const GeneralSettings = ({
+ currency,
+ setCurrency,
+ theme,
+ setTheme,
+ accountName,
+ accountAvatar,
+ changePassword,
+ deleteWallet,
+ updateAccountMetadata,
+}: Readonly<
+ Pick<
+ Props,
+ | 'accountAvatar'
+ | 'accountName'
+ | 'changePassword'
+ | 'currency'
+ | 'deleteWallet'
+ | 'setCurrency'
+ | 'setTheme'
+ | 'theme'
+ | 'updateAccountMetadata'
+ >
+>) => {
+ const capture = useCaptureEvent();
+ const [name, setName] = useState(accountName);
+ const [originalName, setOriginalName] = useState(accountName);
+ const { setColorMode } = useColorMode();
+ const ref = useRef();
+ const changePasswordRef = useRef();
+
+ const nameHandler = async () => {
+ await updateAccountMetadata({ name });
+ setOriginalName(name);
+ };
+
+ const avatarHandler = async () => {
+ await updateAccountMetadata({
+ namiMode: { avatar: Math.random().toString() },
+ });
+ capture(Events.SettingsChangeAvatarClick);
+ };
+
+ return (
+ <>
+
+
+ General settings
+
+
+
+ {
+ if (e.key == 'Enter' && name.length > 0 && name != originalName)
+ nameHandler();
+ }}
+ placeholder="Change name"
+ value={name}
+ onChange={e => {
+ setName(e.target.value);
+ }}
+ pr="4.5rem"
+ />
+
+ {name == originalName ? (
+
+ ) : (
+
+ Apply
+
+ )}
+
+
+
+
+
+
+
+
+ {
+ avatarHandler();
+ }}
+ rounded="md"
+ size="sm"
+ icon={ }
+ />
+
+
+ {
+ const newTheme = theme == 'dark' ? 'light' : 'dark';
+ if (theme === 'dark') {
+ capture(Events.SettingsThemeLightModeClick);
+ } else {
+ capture(Events.SettingsThemeDarkModeClick);
+ }
+ setTheme(newTheme);
+ setColorMode(newTheme);
+ }}
+ rightIcon={ }
+ >
+ {theme == 'dark' ? 'Light' : 'Dark'}
+
+
+
+
+ USD
+
+ {
+ if (e.target.checked) {
+ setCurrency(CurrencyCode.EUR);
+ } else {
+ setCurrency(CurrencyCode.USD);
+ }
+ }}
+ />
+
+ EUR
+
+
+
+ {
+ capture(Events.SettingsChangePasswordClick);
+ changePasswordRef.current.openModal();
+ }}
+ >
+ Change Password
+
+
+ {
+ capture(Events.SettingsRemoveWalletClick);
+ ref.current.openModal();
+ }}
+ >
+ Reset Wallet
+
+
+ The wallet will be reset.{' '}
+ Make sure you have written down your seed phrase. It's the
+ only way to recover your current wallet!
+ Type your password below, if you want to continue.
+
+ }
+ ref={ref}
+ onCloseBtn={() => {
+ capture(Events.SettingsHoldUpBackClick);
+ }}
+ sign={async password => {
+ capture(Events.SettingsHoldUpRemoveWalletClick);
+ return deleteWallet(password);
+ }}
+ onConfirm={async status => {
+ if (status) window.close();
+ }}
+ />
+
+ >
+ );
+};
+
+const Whitelisted = ({
+ connectedDapps,
+ removeDapp,
+}: Readonly>) => {
+ const capture = useCaptureEvent();
+
+ return (
+
+
+
+ Whitelisted sites
+
+
+ {connectedDapps?.length > 0 ? (
+ connectedDapps.map(({ url, logo }, index) => (
+
+ }
+ />
+ {url.split('//')[1]}
+ {
+ capture(Events.SettingsAuthorizedDappsTrashBinIconClick);
+ await removeDapp(url);
+ }}
+ />
+
+ ))
+ ) : (
+
+ No whitelisted sites
+
+ )}
+
+
+ );
+};
+
+type NetworkProps = Pick<
+ OutsideHandlesContextValue,
+ | 'availableChains'
+ | 'defaultSubmitApi'
+ | 'enableCustomNode'
+ | 'environmentName'
+ | 'getCustomSubmitApiForNetwork'
+ | 'isValidURL'
+ | 'switchNetwork'
+>;
+
+const Network = ({
+ switchNetwork,
+ environmentName,
+ availableChains,
+ enableCustomNode,
+ getCustomSubmitApiForNetwork,
+ defaultSubmitApi,
+ isValidURL,
+}: Readonly) => {
+ const capture = useCaptureEvent();
+ const {
+ status: isCustomApiEnabledForCurrentNetwork,
+ url: customSubmitTxUrl,
+ } = getCustomSubmitApiForNetwork(environmentName);
+
+ const [value, setValue] = useState(customSubmitTxUrl);
+ const [isEnabled, setIsEnabled] = useState(
+ Boolean(isCustomApiEnabledForCurrentNetwork),
+ );
+ const [applied, setApplied] = useState(false);
+
+ const endpointHandler = useCallback(async () => {
+ await capture(Events.SettingsNetworkCustomNodeClick);
+ await enableCustomNode(environmentName, value);
+ setApplied(true);
+ setTimeout(() => {
+ setApplied(false);
+ }, 600);
+ }, [environmentName, value]);
+
+ useEffect(() => {
+ setValue(customSubmitTxUrl);
+ setIsEnabled(Boolean(isCustomApiEnabledForCurrentNetwork));
+ }, [customSubmitTxUrl, isCustomApiEnabledForCurrentNetwork]);
+
+ return (
+ <>
+
+
+ Network
+
+
+
+ {
+ switch (value) {
+ case 'Mainnet': {
+ capture(Events.SettingsNetworkMainnetClick);
+ break;
+ }
+ case 'Preprod': {
+ capture(Events.SettingsNetworkPreprodClick);
+ break;
+ }
+ case 'Preview': {
+ capture(Events.SettingsNetworkPreviewClick);
+ break;
+ }
+ default: {
+ break;
+ }
+ }
+
+ await switchNetwork(value as Wallet.ChainName);
+ }}
+ >
+ {availableChains.map(network => (
+
+ {network}
+
+ ))}
+
+
+
+
+ {
+ setIsEnabled(e.target.checked);
+ if (!e.target.checked) {
+ await enableCustomNode(environmentName, '');
+ }
+ }}
+ size="md"
+ />{' '}
+ Custom node
+
+
+
+ {
+ if (e.key == 'Enter' && isValidURL(value)) {
+ endpointHandler();
+ }
+ }}
+ onChange={e => {
+ setValue(e.target.value);
+ }}
+ pr="4.5rem"
+ />
+
+
+ {applied ? : 'Apply'}
+
+
+
+ >
+ );
+};
+
+export default Settings;
diff --git a/packages/nami/src/ui/app/pages/wallet.stories.tsx b/packages/nami/src/ui/app/pages/wallet.stories.tsx
new file mode 100644
index 000000000..43948575c
--- /dev/null
+++ b/packages/nami/src/ui/app/pages/wallet.stories.tsx
@@ -0,0 +1,1099 @@
+import React from 'react';
+
+import { Box, useColorMode } from '@chakra-ui/react';
+import type { Meta, StoryObj } from '@storybook/react';
+import {
+ expect,
+ fn,
+ screen,
+ userEvent,
+ waitFor,
+ within,
+} from '@storybook/test';
+
+import {
+ createTab,
+ getAccounts,
+ onAccountChange,
+ updateAccount,
+ getCurrentAccountIndex,
+ getCurrentAccount,
+ getDelegation,
+ getNetwork,
+ getTransactions,
+ getAsset,
+} from '../../../api/extension/api.mock';
+import {
+ account,
+ account1,
+ account2,
+ accountHW,
+ currentAccount,
+} from '../../../mocks/account.mock';
+import { transactions } from '../../../mocks/history.mock';
+import { network } from '../../../mocks/network.mock';
+import { store } from '../../../mocks/store.mock';
+import { tokens } from '../../../mocks/token.mock';
+import { currentlyDelegating } from '../../../mocks/transaction.mock';
+import { useStoreState, useStoreActions } from '../../store.mock';
+
+import Wallet, { Props } from './wallet';
+import { useHistory } from '../../../../.storybook/mocks/react-router-dom.mock';
+import { CurrencyCode } from '../../../adapters/currency';
+import { useDelegation } from '../../../adapters/delegation.mock';
+import { Wallet as CardanoWallet } from '@lace/cardano';
+import { useOutsideHandles } from '../../../features/outside-handles-provider/useOutsideHandles.mock';
+import { useCollateral } from '../../../adapters/collateral.mock';
+
+const noop = (async () => {}) as any;
+
+const cardanoCoin = {
+ id: '1',
+ name: 'Cardano',
+ decimals: 6,
+ symbol: 't₳',
+};
+
+process.env.APP_VERSION = '0.1.0';
+
+const WalletStory = ({
+ colorMode,
+ assets,
+ nfts,
+ ...props
+}: Readonly<
+ Partial & { colorMode: 'dark' | 'light' }
+>): React.ReactElement => {
+ const { setColorMode } = useColorMode();
+ setColorMode(colorMode);
+
+ return (
+
+ void 0}
+ {...props}
+ />
+
+ );
+};
+
+const coingecoResponse = {
+ cardano: {
+ usd: 0.444_945,
+ eur: 0.413_961,
+ },
+};
+
+const customViewports = {
+ popup: {
+ name: 'Popup',
+ styles: {
+ width: '400px',
+ height: '600px',
+ },
+ },
+};
+
+const meta: Meta = {
+ title: 'Wallet',
+ component: WalletStory,
+ parameters: {
+ viewport: {
+ viewports: customViewports,
+ defaultViewport: 'popup',
+ },
+ layout: 'centered',
+ },
+ beforeEach: () => {
+ createTab.mockImplementation(async () => {
+ await Promise.resolve();
+ });
+ getAccounts.mockImplementation(async () => {
+ return await Promise.resolve([account]);
+ });
+ onAccountChange.mockImplementation(() => {
+ return {
+ // @ts-ignore
+ remove: () => void 0,
+ };
+ });
+ updateAccount.mockImplementation(async () => {
+ await Promise.resolve();
+ });
+ getCurrentAccountIndex.mockImplementation(async () => {
+ return await Promise.resolve(0);
+ });
+ getDelegation.mockImplementation(async () => {
+ return await Promise.resolve({});
+ });
+ getCurrentAccount.mockImplementation(async () => {
+ return await Promise.resolve(currentAccount);
+ });
+ getNetwork.mockImplementation(async () => {
+ return await Promise.resolve(network);
+ });
+ getTransactions.mockImplementation(async () => {
+ return await Promise.resolve(transactions);
+ });
+ useStoreState.mockImplementation((callback: any) => {
+ return callback(store);
+ });
+ useStoreActions.mockImplementation(() => {
+ // @ts-ignore
+ return () => void 0;
+ });
+ getAsset.mockImplementation(async (unit: keyof typeof tokens) => {
+ return await Promise.resolve(tokens[unit]);
+ });
+ useHistory.mockImplementation(
+ () =>
+ ({
+ push: () => {},
+ }) as any,
+ );
+
+ const originalSetInterval = window.setInterval;
+
+ (window.setInterval as unknown as any) = fn();
+
+ process.env.npm_package_version = '0.1.0';
+
+ // 👇 Reset the Date after each story
+ return () => {
+ createTab.mockReset();
+ getAccounts.mockReset();
+ onAccountChange.mockReset();
+ updateAccount.mockReset();
+ getCurrentAccountIndex.mockReset();
+ getDelegation.mockReset();
+ getCurrentAccount.mockReset();
+ getNetwork.mockReset();
+ getTransactions.mockReset();
+ useStoreState.mockReset();
+ useStoreActions.mockReset();
+ getAsset.mockReset();
+ useHistory.mockReset();
+ window.setInterval = originalSetInterval;
+ };
+ },
+};
+type Story = StoryObj;
+export default meta;
+
+export const LayoutLight: Story = {
+ beforeEach: () => {
+ useDelegation.mockImplementation(() => {
+ return {
+ delegation: undefined,
+ initDelegation: async (
+ pool?: Readonly,
+ ) => {
+ await pool;
+ },
+ stakeRegistration: '2000000',
+ };
+ });
+ useOutsideHandles.mockImplementation(() => {
+ return {
+ passwordUtil: {},
+ cardanoCoin,
+ collateralFee: BigInt(0),
+ isInitializingCollateral: false,
+ };
+ });
+ useCollateral.mockImplementation(() => {
+ return {
+ reclaimCollateral: async () => {},
+ submitCollateral: async () => {},
+ hasCollateral: false,
+ };
+ });
+
+ return () => {
+ useDelegation.mockReset();
+ useOutsideHandles.mockReset();
+ useCollateral.mockReset();
+ };
+ },
+ parameters: {
+ colorMode: 'light',
+ },
+};
+
+export const LayoutDark: Story = {
+ ...LayoutLight,
+ parameters: {
+ colorMode: 'dark',
+ },
+};
+
+export const ReceiveLight: Story = {
+ ...LayoutLight,
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('Receive popover', async () => {
+ await userEvent.click(canvas.getByText('Receive'));
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ },
+};
+
+export const ReceiveDark: Story = {
+ ...ReceiveLight,
+ parameters: {
+ colorMode: 'dark',
+ },
+};
+
+export const AssetsSearchLight: Story = {
+ ...LayoutLight,
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('Asset search popover', async () => {
+ await userEvent.click(canvas.getByTestId('searchIcon'));
+ await userEvent.click(canvas.getByTestId('searchInput'));
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ },
+};
+
+export const AssetsSearchDark: Story = {
+ ...AssetsSearchLight,
+ parameters: {
+ colorMode: 'dark',
+ },
+};
+
+export const WalletBalanceLight: Story = {
+ ...LayoutLight,
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('Wallet balance tooltip', async () => {
+ await userEvent.hover(await canvas.findByTestId('balanceInfo'));
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ },
+};
+
+export const WalletBalanceDark: Story = {
+ ...WalletBalanceLight,
+ parameters: {
+ colorMode: 'dark',
+ },
+};
+
+export const MenuLight: Story = {
+ ...LayoutLight,
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('Menu dropdown', async () => {
+ const menu = await canvas.findByTestId('menu');
+ await userEvent.click(menu.children[0]);
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ activeAccount: { index: 0, name: account.name, avatar: account.avatar },
+ accounts: [
+ {
+ index: 0,
+ name: account.name,
+ avatar: account.avatar,
+ balance: BigInt(account.lovelace),
+ },
+ ],
+ },
+};
+
+export const MenuDark: Story = {
+ ...MenuLight,
+ parameters: {
+ ...MenuLight.parameters,
+ colorMode: 'dark',
+ },
+};
+
+export const MenuWithTwoAccountsLight: Story = {
+ ...LayoutLight,
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('Menu dropdown', async () => {
+ const menu = await canvas.findByTestId('menu');
+ await userEvent.click(menu.children[0]);
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ accounts: [
+ {
+ walletId: 'walletId1',
+ index: 1,
+ name: account.name,
+ avatar: account.avatar,
+ balance: BigInt(account.lovelace),
+ },
+ {
+ walletId: 'walletId1',
+ index: 2,
+ name: account1.name,
+ avatar: account1.avatar,
+ balance: BigInt(account1.lovelace),
+ },
+ ],
+ },
+};
+
+export const MenuWithTwoAccountsDark: Story = {
+ ...MenuWithTwoAccountsLight,
+ parameters: {
+ ...MenuWithTwoAccountsLight.parameters,
+ colorMode: 'dark',
+ },
+};
+
+export const MenuWithHWLight: Story = {
+ ...LayoutLight,
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('Menu dropdown', async () => {
+ const menu = await canvas.findByTestId('menu');
+
+ await userEvent.click(menu.children[0]);
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ activeAccount: { index: 0, name: account.name, avatar: account.avatar },
+ accounts: [
+ {
+ walletId: 'walletId1',
+ index: 1,
+ name: account.name,
+ avatar: account.avatar,
+ balance: BigInt(account.lovelace),
+ },
+ {
+ walletId: 'hw1',
+ index: 1,
+ name: account1.name,
+ avatar: account1.avatar,
+ balance: BigInt(account1.lovelace),
+ },
+ {
+ walletId: 'hw1',
+ index: 2,
+ name: accountHW.name,
+ avatar: accountHW.avatar,
+ balance: BigInt(0),
+ },
+ ],
+ },
+};
+
+export const MenuWithHWDark: Story = {
+ ...MenuWithHWLight,
+ parameters: {
+ ...MenuWithHWLight.parameters,
+ colorMode: 'dark',
+ },
+};
+
+const sleep = async (ms = 1000): Promise =>
+ new Promise(resolve =>
+ setTimeout(resolve, process.env.STORYBOOK_TEST ?? '' ? 0 : ms),
+ );
+
+export const AssetLight: Story = {
+ ...LayoutLight,
+ play: async ({ canvasElement, step }) => {
+ await sleep(300);
+ const canvas = within(canvasElement);
+ await step('Asset collapse', async () => {
+ const assets = await canvas.findAllByTestId('asset');
+
+ await userEvent.click(assets[1]);
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ assets: [
+ {
+ unit: 'lovelace',
+ quantity: (
+ BigInt(currentAccount.lovelace) -
+ BigInt(currentAccount.minAda) -
+ BigInt(account.collateral.lovelace)
+ ).toString(),
+ },
+ ...[
+ '212a16adbc2aec5cab350fc8e8a32defae6d766f7a774142d5ae995f4d657368546f6b656e',
+ '212a16adbc2aec5cab350fc8e8a32defae6d766f7a774142d5ae995f54657374546f6b656e',
+ ]
+ .map(id => tokens[id])
+ .filter(id => id),
+ ],
+ },
+};
+
+export const AssetDark: Story = {
+ ...AssetLight,
+ parameters: {
+ ...AssetLight.parameters,
+ colorMode: 'dark',
+ },
+};
+
+export const CollectiblesLight: Story = {
+ ...LayoutLight,
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('NFTs tab', async () => {
+ const menu = await canvas.findByTestId('collectibles');
+ await userEvent.click(menu.children[0]);
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ nfts: [
+ ...[
+ '648823ffdad1610b4162f4dbc87bd47f6f9cf45d772ddef661eff198444149',
+ '0b23996b05afb3a76cc802dcb1d854a2b3596b208bf775c162cec2d34e6f6e5371756172654e66743235',
+ ]
+ .map(id => tokens[id])
+ .filter(id => id),
+ ],
+ },
+};
+
+export const CollectiblesDark: Story = {
+ ...CollectiblesLight,
+ parameters: {
+ ...CollectiblesLight.parameters,
+ colorMode: 'dark',
+ },
+};
+
+export const CollectiblesEmptyListLight: Story = {
+ ...LayoutLight,
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('NFTs tab', async () => {
+ const menu = await canvas.findByTestId('collectibles');
+ await userEvent.click(menu.children[0]);
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ },
+};
+
+export const CollectiblesEmptyListDark: Story = {
+ ...CollectiblesEmptyListLight,
+ parameters: {
+ colorMode: 'dark',
+ },
+};
+
+export const CollectibleMetadataLight: Story = {
+ ...LayoutLight,
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('NFTs tab', async () => {
+ const menu = await canvas.findByTestId('collectibles');
+ await userEvent.click(menu.children[0]);
+
+ const nft = await canvas.findByTestId('collectible-0');
+ await userEvent.click(nft.children[0]);
+ });
+ },
+ parameters: {
+ ...CollectiblesLight.parameters,
+ colorMode: 'light',
+ },
+};
+
+export const CollectibleMetadataDark: Story = {
+ ...CollectibleMetadataLight,
+ parameters: {
+ ...CollectibleMetadataLight.parameters,
+ colorMode: 'dark',
+ },
+};
+
+export const StakePoolDelegationLight: Story = {
+ ...LayoutLight,
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('Delegation', async () => {
+ const delegate = await canvas.findByText('Delegate');
+
+ await userEvent.click(delegate);
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ },
+};
+
+export const StakePoolDelegationDark: Story = {
+ ...StakePoolDelegationLight,
+ parameters: {
+ colorMode: 'dark',
+ },
+};
+
+export const StakePoolDelegatingLight: Story = {
+ ...LayoutLight,
+ beforeEach: () => {
+ useDelegation.mockImplementation(() => {
+ return {
+ delegation: currentlyDelegating,
+ initDelegation: async (
+ pool?: Readonly,
+ ) => {
+ await pool;
+ },
+ stakeRegistration: '2000000',
+ };
+ });
+ useOutsideHandles.mockImplementation(() => {
+ return {
+ passwordUtil: {},
+ cardanoCoin,
+ collateralFee: BigInt(0),
+ isInitializingCollateral: false,
+ delegationTxFee: BigInt(176281),
+ };
+ });
+ useCollateral.mockImplementation(() => {
+ return {
+ reclaimCollateral: async () => {},
+ submitCollateral: async () => {},
+ hasCollateral: false,
+ };
+ });
+
+ return () => {
+ useDelegation.mockReset();
+ useOutsideHandles.mockReset();
+ useCollateral.mockReset();
+ };
+ },
+ parameters: {
+ colorMode: 'light',
+ },
+};
+
+export const StakePoolDelegatingDark: Story = {
+ ...StakePoolDelegatingLight,
+ parameters: {
+ colorMode: 'dark',
+ },
+};
+
+export const StakePoolDeregistrationLight: Story = {
+ ...StakePoolDelegatingLight,
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('Deregistration popover', async () => {
+ const delegating = await canvas.findByTestId('delegating');
+ await userEvent.click(delegating);
+ await sleep(300);
+ await userEvent.click(await canvas.findByText('Unstake'));
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ },
+};
+
+export const StakePoolDeregistrationDark: Story = {
+ ...StakePoolDeregistrationLight,
+ parameters: {
+ colorMode: 'dark',
+ },
+};
+
+export const StakePoolWithdrawalLight: Story = {
+ ...StakePoolDelegatingLight,
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('Withdrawal popover', async () => {
+ const delegating = await canvas.findByTestId('delegating');
+ await userEvent.click(delegating);
+ await sleep(300);
+ await userEvent.hover(await canvas.findByTestId('withdrawInfo'));
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ },
+};
+
+export const StakePoolWithdrawalDark: Story = {
+ ...StakePoolWithdrawalLight,
+ parameters: {
+ colorMode: 'dark',
+ },
+};
+
+export const StakePoolStakingInfoLight: Story = {
+ ...StakePoolDelegatingLight,
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('Withdrawal popover', async () => {
+ const delegating = await canvas.findByTestId('delegating');
+ await userEvent.click(delegating);
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ },
+};
+
+export const StakePoolStakingInfoDark: Story = {
+ ...StakePoolStakingInfoLight,
+ parameters: {
+ colorMode: 'dark',
+ },
+};
+
+export const AddAccountLight: Story = {
+ ...LayoutLight,
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('Menu dropdown', async () => {
+ const menu = await canvas.findByTestId('menu');
+ await userEvent.click(menu.children[0]);
+ });
+ await step('Open new account modal', async () => {
+ const button = await canvas.findByText('New Account');
+ await userEvent.click(button.parentElement!);
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ accounts: [
+ {
+ name: account.name,
+ avatar: account.avatar,
+ balance: BigInt(account.lovelace),
+ },
+ ],
+ },
+};
+
+export const AddAccountDark: Story = {
+ ...AddAccountLight,
+ parameters: {
+ ...AddAccountLight.parameters,
+ colorMode: 'dark',
+ },
+};
+
+export const DeleteAccountLight: Story = {
+ ...LayoutLight,
+ play: async ({ canvasElement, step }) => {
+ const canvas = within(canvasElement);
+ await step('Menu dropdown', async () => {
+ const menu = await canvas.findByTestId('menu');
+ await userEvent.click(menu.children[0]);
+ });
+ await step('Open delete account modal', async () => {
+ const button = await canvas.findByText('Delete Account');
+ await userEvent.click(button.parentElement!);
+ });
+ },
+ parameters: {
+ colorMode: 'light',
+ activeAccount: {
+ walletId: 1,
+ index: 1,
+ name: account1.name,
+ avatar: account1.avatar,
+ },
+ accounts: [
+ {
+ walletId: 1,
+ index: 1,
+ name: account1.name,
+ avatar: account1.avatar,
+ balance: BigInt(0),
+ },
+ {
+ walletId: 1,
+ index: 0,
+ name: account.name,
+ avatar: account.avatar,
+ balance: BigInt(account.lovelace),
+ },
+ ],
+ },
+};
+
+export const DeleteAccountDark: Story = {
+ ...DeleteAccountLight,
+ parameters: {
+ ...DeleteAccountLight.parameters,
+ colorMode: 'dark',
+ },
+};
+
+export const RemoveCollateralLight: Story = {
+ ...LayoutLight,
+ beforeEach: () => {
+ useDelegation.mockImplementation(() => {
+ return {
+ delegation: undefined,
+ initDelegation: async (
+ pool?: Readonly