diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 425e8a3ad..53665a2bf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -7,6 +7,7 @@ yarn.lock @input-output-hk/lace-core /packages/common/ @input-output-hk/lace-core /packages/core/ @input-output-hk/lace-core /packages/e2e-tests/ @input-output-hk/xsy-lace-test-engineers +/packages/nami/ @input-output-hk/lace-core /packages/staking/ @input-output-hk/lace-core /packages/translation/ @input-output-hk/lace-core diff --git a/.github/workflows/chromatic-nami.yml b/.github/workflows/chromatic-nami.yml new file mode 100644 index 000000000..b9e4edeab --- /dev/null +++ b/.github/workflows/chromatic-nami.yml @@ -0,0 +1,42 @@ +name: Chromatic deploy packages/nami + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - packages/nami/** + push: + paths: + - packages/nami/** + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + chromatic-deployment: + name: Chromatic Nami + if: github.event.pull_request.draft == false + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js and install dependencies + uses: ./.github/actions/install + with: + WALLET_PASSWORD: ${{ secrets.WALLET_PASSWORD_TESTNET }} + + - name: Build + run: yarn workspaces foreach -Rpt -v --from '@lace/nami' run build + + - name: Chromatic packages-nami + uses: ./.github/actions/chromatic + with: + DIR: packages/nami + NAME: packages-nami + TOKEN: ${{ secrets.CHROMATIC_LACE_NAMI_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3b36b1ae..578edcea2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,12 @@ jobs: DIR: packages/staking NAME: packages-staking + - name: Build nami + uses: ./.github/actions/build/package + with: + DIR: packages/nami + NAME: packages-nami + unitTests: name: Unit tests runs-on: ubuntu-20.04 @@ -116,6 +122,12 @@ jobs: name: packages-staking path: packages/staking/dist + - name: Download packages-nami + uses: actions/download-artifact@v4 + with: + name: packages-nami + path: packages/nami/dist + - name: Collect Workflow Telemetry Unit Tests uses: catchpoint/workflow-telemetry-action@v2 with: @@ -169,6 +181,12 @@ jobs: name: packages-staking path: packages/staking/dist + - name: Download packages-nami + uses: actions/download-artifact@v4 + with: + name: packages-nami + path: packages/nami/dist + - name: Collect Workflow Telemetry Smoke Tests uses: catchpoint/workflow-telemetry-action@v2 with: diff --git a/.gitignore b/.gitignore index 075d370e1..4ab0d37d4 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,5 @@ result !.yarn/releases !.yarn/sdks !.yarn/versions + +yalc.lock \ No newline at end of file diff --git a/apps/browser-extension-wallet/package.json b/apps/browser-extension-wallet/package.json index 2b256507b..c504d2e6e 100644 --- a/apps/browser-extension-wallet/package.json +++ b/apps/browser-extension-wallet/package.json @@ -47,6 +47,7 @@ "@cardano-sdk/input-selection": "0.13.19", "@cardano-sdk/tx-construction": "0.21.4", "@cardano-sdk/util": "0.15.5", + "@cardano-sdk/util-rxjs": "0.7.32", "@cardano-sdk/wallet": "0.44.3", "@cardano-sdk/web-extension": "0.34.2", "@emurgo/cip14-js": "~3.0.1", @@ -54,12 +55,14 @@ "@lace/cardano": "0.1.0", "@lace/common": "0.1.0", "@lace/core": "0.1.0", + "@lace/nami": "^0.1.0", "@lace/staking": "0.1.0", "@lace/translation": "0.1.0", "@pdfme/generator": "^4.0.2", "@react-rxjs/core": "^0.9.8", "@react-rxjs/utils": "^0.9.5", "@shiroyasha9/axios-fetch-adapter": "^1.0.3", + "@xsy/nami-migration-tool": "file:./xsy-nami-migration-tool-0.0.39.tgz", "antd": "^4.24.10", "are-you-es5": "^2.1.2", "bignumber.js": "9.0.1", @@ -90,7 +93,7 @@ "react-router-dom": "5.2.0", "readable-stream": "^3.6.0", "rxjs": "7.4.0", - "webextension-polyfill": "0.8.0", + "webextension-polyfill": "0.10.0", "zustand": "3.5.14" }, "devDependencies": { @@ -106,13 +109,13 @@ "@types/text-encoding-utf-8": "^1", "@types/uuid": "^8.3.4", "@types/w3c-web-hid": "^1.0.3", - "@types/webextension-polyfill": "0.8.0", + "@types/webextension-polyfill": "0.10.0", "dotenv-defaults": "5.0.2", "dotenv-webpack": "8.0.1", "eslint-plugin-prettier": "^4.0.0", "fake-indexeddb": "3.1.3", "fork-ts-checker-webpack-plugin": "^7.2.1", - "jest-webextension-mock": "^3.7.19", + "jest-webextension-mock": "^3.9.0", "text-encoding-utf-8": "^1.0.2", "tsconfig-paths-webpack-plugin": "3.5.2", "webassembly-loader-sw": "^1.1.0" diff --git a/apps/browser-extension-wallet/src/assets/icons/browser-view/warning-icon.svg b/apps/browser-extension-wallet/src/assets/icons/browser-view/warning-icon.svg new file mode 100644 index 000000000..ee61e6a33 --- /dev/null +++ b/apps/browser-extension-wallet/src/assets/icons/browser-view/warning-icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/browser-extension-wallet/src/assets/videos/lace.mp4 b/apps/browser-extension-wallet/src/assets/videos/lace.mp4 new file mode 100644 index 000000000..00c0aaf60 Binary files /dev/null and b/apps/browser-extension-wallet/src/assets/videos/lace.mp4 differ diff --git a/apps/browser-extension-wallet/src/assets/videos/nami.mp4 b/apps/browser-extension-wallet/src/assets/videos/nami.mp4 new file mode 100644 index 000000000..c1ed4c63c Binary files /dev/null and b/apps/browser-extension-wallet/src/assets/videos/nami.mp4 differ diff --git a/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/WalletAccounts.tsx b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/WalletAccounts.tsx index c296440dd..eabbb1651 100644 --- a/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/WalletAccounts.tsx +++ b/apps/browser-extension-wallet/src/components/MainMenu/DropdownMenuOverlay/components/WalletAccounts.tsx @@ -236,14 +236,16 @@ export const WalletAccounts = ({ isPopup, onBack }: { isPopup: boolean; onBack: const renameAccount = useCallback( async (newAccountName: string) => { + const account = wallet.accounts.find(({ accountIndex }) => accountIndex === editAccountDrawer.data.accountNumber); + await walletRepository.updateAccountMetadata({ walletId: wallet.walletId, accountIndex: editAccountDrawer.data.accountNumber, - metadata: { name: newAccountName } + metadata: { ...account?.metadata, name: newAccountName } }); editAccountDrawer.hide(); }, - [walletRepository, wallet.walletId, editAccountDrawer] + [walletRepository, wallet.walletId, editAccountDrawer, wallet.accounts] ); return ( diff --git a/apps/browser-extension-wallet/src/config.ts b/apps/browser-extension-wallet/src/config.ts index 3e6ad690f..900cf29dc 100644 --- a/apps/browser-extension-wallet/src/config.ts +++ b/apps/browser-extension-wallet/src/config.ts @@ -25,6 +25,7 @@ export type Config = { CEXPLORER_BASE_URL: Record; CEXPLORER_URL_PATHS: CExplorerUrlPaths; SAVED_PRICE_DURATION: number; + DEFAULT_SUBMIT_API: string; }; // eslint-disable-next-line complexity @@ -94,6 +95,7 @@ export const config = (): Config => { }, SAVED_PRICE_DURATION: !Number.isNaN(Number(process.env.SAVED_PRICE_DURATION_IN_MINUTES)) ? Number(process.env.SAVED_PRICE_DURATION_IN_MINUTES) - : 720 + : 720, + DEFAULT_SUBMIT_API: 'http://localhost:8090/api/submit/tx' }; }; diff --git a/apps/browser-extension-wallet/src/features/delegation/stores/createDelegationStore.ts b/apps/browser-extension-wallet/src/features/delegation/stores/createDelegationStore.ts index 0aa93234c..ff614102e 100644 --- a/apps/browser-extension-wallet/src/features/delegation/stores/createDelegationStore.ts +++ b/apps/browser-extension-wallet/src/features/delegation/stores/createDelegationStore.ts @@ -63,7 +63,7 @@ DelegationStore): stakePoolDetailsSelectorProps => { */ export const useDelegationStore = create((set) => ({ delegationTxFee: '0', - setSelectedStakePool: (pool: CardanoStakePool) => set({ selectedStakePool: pool }), + setSelectedStakePool: (pool: CardanoStakePool | undefined) => set({ selectedStakePool: pool }), setDelegationTxBuilder: (txBuilder?: TxBuilder) => set({ delegationTxBuilder: txBuilder }), setDelegationTxFee: (fee?: string) => set({ delegationTxFee: fee }) })); diff --git a/apps/browser-extension-wallet/src/features/nami-migration/Activating.tsx b/apps/browser-extension-wallet/src/features/nami-migration/Activating.tsx new file mode 100644 index 000000000..5951cfc9f --- /dev/null +++ b/apps/browser-extension-wallet/src/features/nami-migration/Activating.tsx @@ -0,0 +1,53 @@ +import React, { useEffect } from 'react'; + +import { NamiMigrationUpdatingYourWallet } from '@lace/core'; +import { consumeRemoteApi, RemoteApiPropertyType } from '@cardano-sdk/web-extension'; +import { NamiMigrationAPI, NamiMigrationChannels } from '@lib/scripts/background/nami-migration'; +import { runtime } from 'webextension-polyfill'; +import { useHistory } from 'react-router-dom'; +import { walletRoutePaths as routes } from '@routes/wallet-paths'; +import { useCurrencyStore } from '@providers/currency'; +import { MigrationState } from '@xsy/nami-migration-tool/dist/migrator/migration-state.data'; +import { useTheme } from '@providers/ThemeProvider/context'; + +const namiMigrationRemoteApi = consumeRemoteApi>( + { + baseChannel: NamiMigrationChannels.MIGRATION, + properties: { + startMigration: RemoteApiPropertyType.MethodReturningPromise, + checkMigrationStatus: RemoteApiPropertyType.MethodReturningPromise + } + }, + { + logger: console, + runtime + } +); + +export const Activating = (): JSX.Element => { + const history = useHistory(); + const { setFiatCurrency } = useCurrencyStore(); + const { setTheme } = useTheme(); + + useEffect(() => { + const startMigration = async () => { + const migrationStatus = await namiMigrationRemoteApi.checkMigrationStatus(); + + if (migrationStatus !== MigrationState.InProgress && migrationStatus !== MigrationState.Completed) { + history.push(routes.namiMigration.welcome); + return; + } + + const result = await namiMigrationRemoteApi.startMigration(); + const namiTheme = result.themeColor === 'light' ? 'light' : 'dark'; + + setTheme(namiTheme); + setFiatCurrency(result.currency); + history.push(routes.namiMigration.welcome); + }; + + startMigration(); + }, [setFiatCurrency, history, setTheme]); + + return ; +}; diff --git a/apps/browser-extension-wallet/src/features/nami-migration/AllDone.tsx b/apps/browser-extension-wallet/src/features/nami-migration/AllDone.tsx new file mode 100644 index 000000000..0eefb39ed --- /dev/null +++ b/apps/browser-extension-wallet/src/features/nami-migration/AllDone.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import { AllDone as View } from '@lace/core'; + +export const AllDone = (): JSX.Element => ( + { + window.close(); + }} + /> +); diff --git a/apps/browser-extension-wallet/src/features/nami-migration/Customize.tsx b/apps/browser-extension-wallet/src/features/nami-migration/Customize.tsx new file mode 100644 index 000000000..886f57c55 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/nami-migration/Customize.tsx @@ -0,0 +1,60 @@ +import React, { useEffect } from 'react'; + +import { Customize as View } from '@lace/core'; +import nami from '@assets/videos/nami.mp4'; +import lace from '@assets/videos/lace.mp4'; +import { useHistory } from 'react-router-dom'; +import { walletRoutePaths } from '@routes'; +import { setBackgroundStorage } from '@lib/scripts/background/storage'; +import { useAnalyticsContext } from '@providers'; +import { postHogNamiMigrationActions } from '@providers/AnalyticsProvider/analyticsTracker'; + +export const Customize = (): JSX.Element => { + const history = useHistory(); + const analytics = useAnalyticsContext(); + + useEffect(() => { + analytics.sendEventToPostHog(postHogNamiMigrationActions.onboarding.CUSTOMIZE_STEP); + }, [analytics]); + + const completeMigrationAndRedirect = async (mode: 'lace' | 'nami') => { + await (mode === 'lace' + ? analytics.sendEventToPostHog(postHogNamiMigrationActions.onboarding.CUSTOMIZE_STEP_LACE_MODE_CLICK) + : analytics.sendEventToPostHog(postHogNamiMigrationActions.onboarding.CUSTOMIZE_STEP_NAMI_MODE_CLICK)); + await analytics.sendEventToPostHog(postHogNamiMigrationActions.onboarding.MIGRATION_COMPLETE); + + // Actually complete migration + await setBackgroundStorage({ + namiMigration: { + completed: true, + mode + } + }); + + if (mode === 'lace') { + history.push(walletRoutePaths.assets); + } else { + history.push(walletRoutePaths.namiMigration.allDone); + } + }; + + const onModeChange = (mode: 'lace' | 'nami') => { + if (mode === 'lace') { + analytics.sendEventToPostHog(postHogNamiMigrationActions.onboarding.CUSTOMIZE_STEP_LACE_TAB_CLICK); + } else { + analytics.sendEventToPostHog(postHogNamiMigrationActions.onboarding.CUSTOMIZE_STEP_NAMI_TAB_CLICK); + } + }; + + return ( + history.goBack()} + onDone={completeMigrationAndRedirect} + videosURL={{ + lace, + nami + }} + /> + ); +}; diff --git a/apps/browser-extension-wallet/src/features/nami-migration/NamiMigration.tsx b/apps/browser-extension-wallet/src/features/nami-migration/NamiMigration.tsx new file mode 100644 index 000000000..6a9d3b28e --- /dev/null +++ b/apps/browser-extension-wallet/src/features/nami-migration/NamiMigration.tsx @@ -0,0 +1,36 @@ +import React, { useEffect } from 'react'; +import { walletRoutePaths } from '@routes'; +import { Route, Switch } from 'react-router-dom'; +import { Activating } from './Activating'; +import { Welcome } from './Welcome'; +import { Customize } from './Customize'; +import { AllDone } from './AllDone'; +import { WalletSetupLayout } from '@views/browser/components'; +import { Portal } from '@views/browser/features/wallet-setup/components/Portal'; +import { useAnalyticsContext } from '@providers'; +import { postHogNamiMigrationActions } from '@providers/AnalyticsProvider/analyticsTracker'; + +const urlPath = walletRoutePaths.namiMigration; + +export const NamiMigration = (): JSX.Element => { + const analytics = useAnalyticsContext(); + + useEffect(() => { + analytics.sendEventToPostHog(postHogNamiMigrationActions.onboarding.OPEN); + }, [analytics]); + + return ( + + + + <> + + + + + + + + + ); +}; diff --git a/apps/browser-extension-wallet/src/features/nami-migration/NamiMigrationGuard.tsx b/apps/browser-extension-wallet/src/features/nami-migration/NamiMigrationGuard.tsx new file mode 100644 index 000000000..8c6398627 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/nami-migration/NamiMigrationGuard.tsx @@ -0,0 +1,138 @@ +import { consumeRemoteApi, RemoteApiPropertyType } from '@cardano-sdk/web-extension'; +import { NamiMigrationAPI, NamiMigrationChannels } from '@lib/scripts/background/nami-migration'; +import { getBackgroundStorage } from '@lib/scripts/background/storage'; +import { MigrationState } from '@xsy/nami-migration-tool/dist/migrator/migration-state.data'; +import React, { Fragment, useCallback, useEffect, useState } from 'react'; +import { Route, Switch, useHistory, useLocation } from 'react-router-dom'; +import { runtime, storage } from 'webextension-polyfill'; +import { walletRoutePaths as routes } from '@routes/wallet-paths'; +import { useWalletStore } from '@src/stores'; +import { APP_MODE_POPUP } from '@src/utils/constants'; +import { useBackgroundServiceAPIContext } from '@providers'; +import { BrowserViewSections } from '@lib/scripts/types'; +import { NamiMigration } from './NamiMigration'; + +const namiMigrationRemoteApi = consumeRemoteApi>( + { + baseChannel: NamiMigrationChannels.MIGRATION, + properties: { + checkMigrationStatus: RemoteApiPropertyType.MethodReturningPromise, + abortMigration: RemoteApiPropertyType.MethodReturningPromise + } + }, + { + logger: console, + runtime + } +); + +if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore ignore this + window.namiMigration = { + checkMigrationStatus: namiMigrationRemoteApi.checkMigrationStatus, + abortMigration: async () => { + await namiMigrationRemoteApi.abortMigration(); + const backgroundStorage = await getBackgroundStorage(); + + await storage.local.set({ + BACKGROUND_STORAGE: { + ...backgroundStorage, + namiMigration: undefined + } + }); + } + }; +} + +interface Props { + children: React.ReactNode; +} + +interface State { + isReady: boolean; + requiresMigration: boolean; +} + +export const NamiMigrationGuard = ({ children }: Props): JSX.Element => { + const history = useHistory(); + const backgroundServices = useBackgroundServiceAPIContext(); + const location = useLocation(); + const { + walletUI: { appMode } + } = useWalletStore(); + const [state, setState] = useState({ + isReady: false, + requiresMigration: location.pathname.startsWith(routes.namiMigration.root) + }); + + const redirectToActivating = useCallback(async () => { + if (appMode === APP_MODE_POPUP) { + await backgroundServices?.handleOpenBrowser({ section: BrowserViewSections.NAMI_MIGRATION }); + return; + } + + history.push(routes.namiMigration.activating); + }, [appMode, backgroundServices, history]); + + useEffect(() => { + const checkMigrationStatus = async (): Promise => { + try { + const [migrationStatus, backgroundStorage] = await Promise.all([ + namiMigrationRemoteApi.checkMigrationStatus(), + getBackgroundStorage() + ]); + + const conditions = [ + migrationStatus === MigrationState.InProgress, + !backgroundStorage.namiMigration?.completed && migrationStatus === MigrationState.Completed + ]; + + if (conditions.some(Boolean)) { + await redirectToActivating(); + setState({ + isReady: true, + requiresMigration: true + }); + + return; + } + + setState({ + isReady: true, + requiresMigration: false + }); + } catch { + setState({ + isReady: true, + requiresMigration: false + }); + } + }; + + checkMigrationStatus(); + }, [history, redirectToActivating]); + + useEffect(() => { + if (state.requiresMigration && location.pathname.startsWith(routes.assets)) { + setState({ + isReady: true, + requiresMigration: false + }); + } + }, [location.pathname, state]); + + if (!state.isReady) { + return ; + } + + if (state.requiresMigration) { + return ( + + + + ); + } + + return <>{children}; +}; diff --git a/apps/browser-extension-wallet/src/features/nami-migration/Welcome.tsx b/apps/browser-extension-wallet/src/features/nami-migration/Welcome.tsx new file mode 100644 index 000000000..8eefd4896 --- /dev/null +++ b/apps/browser-extension-wallet/src/features/nami-migration/Welcome.tsx @@ -0,0 +1,90 @@ +import React, { useEffect, useState } from 'react'; + +import { AnalyticsConfirmationBanner, WalletAnalyticsInfo, Welcome as View } from '@lace/core'; +import { walletRoutePaths } from '@routes'; +import { useHistory } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { + EnhancedAnalyticsOptInStatus, + postHogNamiMigrationActions, + UserTrackingType +} from '@providers/AnalyticsProvider/analyticsTracker'; +import { useLocalStorage } from '@hooks'; +import { ENHANCED_ANALYTICS_OPT_IN_STATUS_LS_KEY } from '@providers/AnalyticsProvider/config'; +import styles from '@views/browser/features/wallet-setup/components/WalletSetup.module.scss'; +import { useAnalyticsContext } from '@providers'; +import { useTheme } from '@input-output-hk/lace-ui-toolkit'; +import { WarningModal } from '@views/browser/components'; + +export const Welcome = (): JSX.Element => { + const history = useHistory(); + const { t: translate } = useTranslation(); + const [enhancedAnalyticsStatus, { updateLocalStorage: setDoesUserAllowAnalytics }] = useLocalStorage( + ENHANCED_ANALYTICS_OPT_IN_STATUS_LS_KEY, + EnhancedAnalyticsOptInStatus.NotSet + ); + const { colorScheme } = useTheme(); + const [isAnalyticsModalOpen, setIsAnalyticsModalOpen] = useState(false); + + const analytics = useAnalyticsContext(); + + useEffect(() => { + // Send pageview if user already had opted-in before + analytics.sendEventToPostHog(postHogNamiMigrationActions.onboarding.INTRODUCTION_STEP); + }, [analytics]); + + const handleAnalyticsChoice = async (isAccepted: boolean) => { + const analyticsStatus = isAccepted ? EnhancedAnalyticsOptInStatus.OptedIn : EnhancedAnalyticsOptInStatus.OptedOut; + setDoesUserAllowAnalytics(analyticsStatus); + await analytics.setOptedInForEnhancedAnalytics( + isAccepted ? EnhancedAnalyticsOptInStatus.OptedIn : EnhancedAnalyticsOptInStatus.OptedOut + ); + + await analytics.sendEventToPostHog(postHogNamiMigrationActions.onboarding.ANALYTICS_AGREE_CLICK, { + // eslint-disable-next-line camelcase + $set: { user_tracking_type: isAccepted ? UserTrackingType.Enhanced : UserTrackingType.Basic } + }); + // Send pageview if user opts-in via banner + await analytics.sendEventToPostHog(postHogNamiMigrationActions.onboarding.INTRODUCTION_STEP); + }; + + return ( + <> + history.push(walletRoutePaths.namiMigration.customize)} + /> + + {translate('analyticsConfirmationBanner.message')} + { + setIsAnalyticsModalOpen(true); + }} + > + {translate('analyticsConfirmationBanner.learnMore')} + + + } + onConfirm={() => handleAnalyticsChoice(true)} + onReject={() => handleAnalyticsChoice(false)} + show={enhancedAnalyticsStatus === EnhancedAnalyticsOptInStatus.NotSet} + /> + {translate('core.walletAnalyticsInfo.title')}} + content={} + visible={isAnalyticsModalOpen} + confirmLabel={translate('core.walletAnalyticsInfo.gotIt')} + onConfirm={() => { + setIsAnalyticsModalOpen(false); + }} + /> + + ); +}; diff --git a/apps/browser-extension-wallet/src/features/settings/components/Settings.tsx b/apps/browser-extension-wallet/src/features/settings/components/Settings.tsx index 48c76c46b..55208f318 100644 --- a/apps/browser-extension-wallet/src/features/settings/components/Settings.tsx +++ b/apps/browser-extension-wallet/src/features/settings/components/Settings.tsx @@ -3,6 +3,8 @@ import { ContentLayout } from '@components/Layout'; import { useTranslation } from 'react-i18next'; import { SettingsWallet, SettingsSecurity, SettingsHelp, SettingsLegal, SettingsPreferences } from '..'; import { SettingsRemoveWallet } from '@src/views/browser-view/features/settings/components/SettingsRemoveWallet'; +import { SettingsSwitchToNami } from '@src/views/browser-view/features/settings/components/SettingsSwitchToNami'; +import { usePostHogClientContext } from '@providers/PostHogClientProvider'; export interface SettingsProps { defaultPassphraseVisible?: boolean; @@ -11,6 +13,8 @@ export interface SettingsProps { export const Settings = ({ defaultPassphraseVisible, defaultMnemonic }: SettingsProps): React.ReactElement => { const { t } = useTranslation(); + const posthog = usePostHogClientContext(); + const useSwitchToNamiMode = posthog?.isFeatureFlagEnabled('use-switch-to-nami-mode'); return ( @@ -24,6 +28,7 @@ export const Settings = ({ defaultPassphraseVisible, defaultMnemonic }: Settings /> + {useSwitchToNamiMode && } diff --git a/apps/browser-extension-wallet/src/hooks/__tests__/useBuildDelegation.test.ts b/apps/browser-extension-wallet/src/hooks/__tests__/useBuildDelegation.test.ts index e6d8a4540..9946baaa0 100644 --- a/apps/browser-extension-wallet/src/hooks/__tests__/useBuildDelegation.test.ts +++ b/apps/browser-extension-wallet/src/hooks/__tests__/useBuildDelegation.test.ts @@ -54,7 +54,8 @@ const flushPromises = () => new Promise(setImmediate); describe('Testing useBuildDelegation hook', () => { describe('Testing build delegation transaction function', () => { test('should build delegation using txBuilder', async () => { - renderHook(() => useBuildDelegation()); + const { result } = renderHook(() => useBuildDelegation()); + await result.current.buildDelegation(); expect(mockSetIsBuildingTx).toBeCalled(); expect(mockCreateTxBuilder).toBeCalled(); diff --git a/apps/browser-extension-wallet/src/hooks/__tests__/useWalletManager.test.tsx b/apps/browser-extension-wallet/src/hooks/__tests__/useWalletManager.test.tsx index 9fe473e44..c2dc4ebd4 100644 --- a/apps/browser-extension-wallet/src/hooks/__tests__/useWalletManager.test.tsx +++ b/apps/browser-extension-wallet/src/hooks/__tests__/useWalletManager.test.tsx @@ -585,7 +585,7 @@ describe('Testing useWalletManager hook', () => { ] }); expect(clearBackgroundStorage).toBeCalledWith({ - except: ['fiatPrices', 'userId', 'usePersistentUserId', 'featureFlags', 'customSubmitTxUrl'] + except: ['fiatPrices', 'userId', 'usePersistentUserId', 'featureFlags', 'customSubmitTxUrl', 'namiMigration'] }); expect(resetWalletLock).toBeCalledWith(); expect(setCardanoWallet).toBeCalledWith(); diff --git a/apps/browser-extension-wallet/src/hooks/useBuildDelegation.ts b/apps/browser-extension-wallet/src/hooks/useBuildDelegation.ts index 1cdf3a108..07a2c7bfa 100644 --- a/apps/browser-extension-wallet/src/hooks/useBuildDelegation.ts +++ b/apps/browser-extension-wallet/src/hooks/useBuildDelegation.ts @@ -1,29 +1,31 @@ -import { useEffect } from 'react'; +import { useCallback } from 'react'; import { useStakePoolDetails } from '@src/features/stake-pool-details/store'; import { StakingError } from '@src/views/browser-view/features/staking/types'; import { useWalletStore } from '../stores'; import { useDelegationStore } from '../features/delegation/stores'; import { InputSelectionFailure } from '@cardano-sdk/input-selection'; +import { Wallet } from '@lace/cardano'; const ERROR_MESSAGES: { [key: string]: StakingError } = { [InputSelectionFailure.UtxoFullyDepleted]: StakingError.UTXO_FULLY_DEPLETED, [InputSelectionFailure.UtxoBalanceInsufficient]: StakingError.UTXO_BALANCE_INSUFFICIENT }; -export const useBuildDelegation = (): void => { +export const useBuildDelegation = (): { buildDelegation: () => Promise } => { const { inMemoryWallet } = useWalletStore(); const { selectedStakePool, setDelegationTxBuilder, setDelegationTxFee } = useDelegationStore(); const { setIsBuildingTx, setStakingError } = useStakePoolDetails(); - useEffect(() => { - const buildDelegation = async () => { + const buildDelegation = useCallback( + async (hexId?: Wallet.Cardano.PoolIdHex) => { try { + const id = hexId ?? selectedStakePool?.hexId; + // eslint-disable-next-line unicorn/no-null + const pools = id ? { pools: [{ weight: 1, id }] } : null; + setIsBuildingTx(true); const txBuilder = inMemoryWallet.createTxBuilder(); - const tx = await txBuilder - .delegatePortfolio({ pools: [{ weight: 1, id: selectedStakePool.hexId }] }) - .build() - .inspect(); + const tx = await txBuilder.delegatePortfolio(pools).build().inspect(); setDelegationTxBuilder(txBuilder); setDelegationTxFee(tx.body.fee.toString()); setStakingError(); @@ -35,15 +37,16 @@ export const useBuildDelegation = (): void => { } finally { setIsBuildingTx(false); } - }; + }, + [ + inMemoryWallet, + selectedStakePool?.hexId, + setDelegationTxBuilder, + setDelegationTxFee, + setIsBuildingTx, + setStakingError + ] + ); - buildDelegation(); - }, [ - inMemoryWallet, - selectedStakePool.hexId, - setDelegationTxBuilder, - setDelegationTxFee, - setIsBuildingTx, - setStakingError - ]); + return { buildDelegation }; }; diff --git a/apps/browser-extension-wallet/src/hooks/useMaxAda.ts b/apps/browser-extension-wallet/src/hooks/useMaxAda.ts index 5183a55df..d576559f4 100644 --- a/apps/browser-extension-wallet/src/hooks/useMaxAda.ts +++ b/apps/browser-extension-wallet/src/hooks/useMaxAda.ts @@ -242,7 +242,7 @@ export const useMaxAda = (): bigint => { const assetInfo = useObservable(inMemoryWallet?.assetInfo$); const { outputsMap } = useTransactionProps(); const { setMaxAdaLoading } = useMaxAdaStatus(); - const address = walletInfo.addresses[0].address; + const address = walletInfo?.addresses[0].address; useEffect(() => { const abortController = new AbortController(); @@ -289,7 +289,7 @@ export const useMaxAda = (): bigint => { } }; - if (balance) { + if (balance && address) { calculate(); } return () => { diff --git a/apps/browser-extension-wallet/src/hooks/useSyncingTheFirstTime.ts b/apps/browser-extension-wallet/src/hooks/useSyncingTheFirstTime.ts index 49bdbfa32..8168bcd21 100644 --- a/apps/browser-extension-wallet/src/hooks/useSyncingTheFirstTime.ts +++ b/apps/browser-extension-wallet/src/hooks/useSyncingTheFirstTime.ts @@ -7,18 +7,19 @@ import { useObservable } from '@lace/common'; export const useSyncingTheFirstTime = (): boolean => { const { inMemoryWallet } = useWalletStore(); - const isSyncingForTheFirstTime$ = useMemo( - () => - concat( - of(true), - inMemoryWallet?.syncStatus?.isSettled$.pipe( - filter((s: boolean) => s), - map(() => false), - take(1) - ) - ), - [inMemoryWallet?.syncStatus?.isSettled$] - ); + const isSyncingForTheFirstTime$ = useMemo(() => { + if (!inMemoryWallet) { + return of(true); + } + return concat( + of(true), + inMemoryWallet?.syncStatus?.isSettled$.pipe( + filter((s: boolean) => s), + map(() => false), + take(1) + ) + ); + }, [inMemoryWallet]); return useObservable(isSyncingForTheFirstTime$); }; diff --git a/apps/browser-extension-wallet/src/hooks/useWalletAvatar.ts b/apps/browser-extension-wallet/src/hooks/useWalletAvatar.ts index 19b1003c4..e1046b700 100644 --- a/apps/browser-extension-wallet/src/hooks/useWalletAvatar.ts +++ b/apps/browser-extension-wallet/src/hooks/useWalletAvatar.ts @@ -3,6 +3,7 @@ import { getAssetImageUrl } from '@utils/get-asset-image-url'; import { useWalletStore } from '@stores'; import { useGetHandles } from '@hooks/useGetHandles'; import { useCallback } from 'react'; +import { walletRepository } from '@lib/wallet-api-ui'; interface UseWalletAvatar { activeWalletAvatar: string; @@ -16,6 +17,7 @@ export const useWalletAvatar = (): UseWalletAvatar => { const [avatars, { updateLocalStorage: setUserAvatar }] = useLocalStorage('userAvatar'); const activeWalletId = cardanoWallet?.source.wallet.walletId; + const { accountIndex, metadata } = cardanoWallet?.source.account ?? {}; const handleImage = handle?.profilePic; const activeWalletAvatar = (environmentName && avatars?.[`${environmentName}${activeWalletId}`]) || @@ -24,8 +26,15 @@ export const useWalletAvatar = (): UseWalletAvatar => { const setAvatar = useCallback( (image: string) => { setUserAvatar({ ...avatars, [`${environmentName}${activeWalletId}`]: image }); + if (metadata?.namiMode) { + walletRepository.updateAccountMetadata({ + accountIndex, + walletId: activeWalletId, + metadata: { ...metadata, namiMode: { ...metadata.namiMode, avatar: image } } + }); + } }, - [setUserAvatar, avatars, environmentName, activeWalletId] + [setUserAvatar, avatars, environmentName, activeWalletId, metadata, accountIndex] ); const getAvatar = useCallback( diff --git a/apps/browser-extension-wallet/src/hooks/useWalletManager.ts b/apps/browser-extension-wallet/src/hooks/useWalletManager.ts index 3f660c079..9f1565af3 100644 --- a/apps/browser-extension-wallet/src/hooks/useWalletManager.ts +++ b/apps/browser-extension-wallet/src/hooks/useWalletManager.ts @@ -625,7 +625,7 @@ export const useWalletManager = (): UseWalletManager => { deleteFromLocalStorage('userInfo'); deleteFromLocalStorage('keyAgentData'); await backgroundService.clearBackgroundStorage({ - except: ['fiatPrices', 'userId', 'usePersistentUserId', 'featureFlags', 'customSubmitTxUrl'] + except: ['fiatPrices', 'userId', 'usePersistentUserId', 'featureFlags', 'customSubmitTxUrl', 'namiMigration'] }); resetWalletLock(); setCardanoWallet(); diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/__tests__/cache-nami-metadata.test.ts b/apps/browser-extension-wallet/src/lib/scripts/background/__tests__/cache-nami-metadata.test.ts new file mode 100644 index 000000000..41a752c52 --- /dev/null +++ b/apps/browser-extension-wallet/src/lib/scripts/background/__tests__/cache-nami-metadata.test.ts @@ -0,0 +1,201 @@ +/* eslint-disable no-magic-numbers */ +/* eslint-disable unicorn/no-useless-undefined */ +import { Wallet } from '@lace/cardano'; +import { of } from 'rxjs'; +import { WalletManager, WalletRepository } from '@cardano-sdk/web-extension'; +import { cacheNamiMetadataSubscription } from '../cache-nami-metadata'; + +describe('cacheNamiMetadataSubscription', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should not trigger subscription for no active wallet', () => { + const mockWalletManager = { + activeWallet$: of({}), + activeWalletId$: of(undefined) + } as unknown as WalletManager; + + const mockWalletRepository = { + wallets$: of([]), + updateAccountMetadata: jest.fn() + } as unknown as WalletRepository; + + cacheNamiMetadataSubscription({ walletManager: mockWalletManager, walletRepository: mockWalletRepository }); + + expect(mockWalletRepository.updateAccountMetadata).not.toHaveBeenCalled(); + }); + + it('should return early if there is no accounts', () => { + const mockWalletManager = { + activeWallet$: of({ + observableWallet: { + addresses$: of(), + protocolParameters$: of(), + balance: { + rewardAccounts: { + rewards$: of() + }, + utxo: { + total$: of(), + unspendable$: of() + } + } + }, + props: { + walletId: 'walletId' + } + }) + } as unknown as WalletManager; + + const mockWalletRepository = { + wallets$: of([ + { + walletId: 'walletId', + metadata: {}, + accounts: [] + } + ]), + updateAccountMetadata: jest.fn() + } as unknown as WalletRepository; + + cacheNamiMetadataSubscription({ walletManager: mockWalletManager, walletRepository: mockWalletRepository }); + + expect(mockWalletRepository.updateAccountMetadata).not.toHaveBeenCalled(); + }); + + it('should subscribe and update account metadata for index 0', () => { + const mockWalletManager = { + activeWallet$: of({ + observableWallet: { + addresses$: of([{ address: 'address1' }]), + protocolParameters$: of({ coinsPerUtxoByte: BigInt(100) }), + balance: { + rewardAccounts: { + rewards$: of(BigInt(2000)) + }, + utxo: { + total$: of({ + coins: BigInt(5000), + assets: undefined + }), + unspendable$: of({ + coins: BigInt(1000) + }) + } + } + }, + props: { walletId: 'walletId', accountIndex: 0 } + }) + } as unknown as WalletManager; + + const mockWalletRepository = { + wallets$: of([ + { + walletId: 'walletId', + metadata: {}, + accounts: [{ accountIndex: 0, metadata: { name: 'account #0' } }] + } + ]), + updateAccountMetadata: jest.fn() + } as unknown as WalletRepository; + + const getBalanceMock = jest + .fn() + .mockReturnValue({ totalCoins: BigInt(7000), unspendableCoins: BigInt(1000), lockedCoins: BigInt(2000) }); + + cacheNamiMetadataSubscription({ + walletManager: mockWalletManager, + walletRepository: mockWalletRepository, + getBalance: getBalanceMock + }); + + expect(getBalanceMock).toHaveBeenCalledWith({ + total: { coins: BigInt(5000), assets: undefined }, + unspendable: { coins: BigInt(1000) }, + rewards: BigInt(2000), + protocolParameters: { coinsPerUtxoByte: BigInt(100) }, + address: 'address1' + }); + + expect(mockWalletRepository.updateAccountMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + walletId: 'walletId', + accountIndex: 0, + metadata: { + name: 'account #0', + namiMode: { + avatar: expect.any(String), + address: 'address1', + balance: '4000' + } + } + }) + ); + }); + + it('should subscribe and update wallet metadata and account metadata for index 1', () => { + const mockWalletManager = { + activeWallet$: of({ + observableWallet: { + addresses$: of([{ address: 'address2' }]), + protocolParameters$: of({ coinsPerUtxoByte: BigInt(100) }), + balance: { + rewardAccounts: { + rewards$: of(BigInt(2000)) + }, + utxo: { + total$: of({ + coins: BigInt(5000), + assets: undefined + }), + unspendable$: of({ + coins: BigInt(1000) + }) + } + } + }, + props: { walletId: 'walletId', accountIndex: 1 } + }) + } as unknown as WalletManager; + + const mockWalletRepository = { + wallets$: of([ + { + walletId: 'walletId', + metadata: {}, + accounts: [ + { accountIndex: 0, metadata: { name: 'account #0' } }, + { accountIndex: 1, metadata: { name: 'account #1', namiMode: { avatar: '0.123' } } } + ] + } + ]), + updateAccountMetadata: jest.fn() + } as unknown as WalletRepository; + + const getBalanceMock = jest + .fn() + .mockReturnValue({ totalCoins: BigInt(7000), unspendableCoins: BigInt(1000), lockedCoins: BigInt(0) }); + + cacheNamiMetadataSubscription({ + walletManager: mockWalletManager, + walletRepository: mockWalletRepository, + getBalance: getBalanceMock + }); + + expect(mockWalletRepository.updateAccountMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + walletId: 'walletId', + accountIndex: 1, + metadata: { + name: 'account #1', + namiMode: { + avatar: '0.123', + address: 'address2', + balance: '6000' + } + } + }) + ); + }); +}); diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/__tests__/nami-migration-runner.fixture.ts b/apps/browser-extension-wallet/src/lib/scripts/background/__tests__/nami-migration-runner.fixture.ts new file mode 100644 index 000000000..3cf98907c --- /dev/null +++ b/apps/browser-extension-wallet/src/lib/scripts/background/__tests__/nami-migration-runner.fixture.ts @@ -0,0 +1,107 @@ +import type * as Nami from '@xsy/nami-migration-tool/dist/migrator/migration-data.data'; + +export const state: Nami.State = { + encryptedPrivateKey: + 'da7af7c22eeaf4bb460c02b426792d556a9242a7e8dca47e7628350f4290d97a0078f3dad5812606f4fa993772dc1c0fc5b7941dc91796a111a8d789b8d3f473eb9c67b32f89f5a2518ff02bb5595a00638e99e7799858b42a639edab14d1bd997eeb8ebe518939ca0522a527219062d1585bfd34434dd84c2a3f895d34863158fce54c1c2c2ab8e8fd3d23d70bc3114fd302badca2c850160597443', + accounts: [ + { + index: 0, + name: 'Nami', + extendedAccountPublicKey: + 'a5f18f73dde7b6f11df448913d60a86bbb397a435269e5024193b293f28892fd33d1225d468aac8f5a9d3cfedceacabe80192fcf0beb5c5c9b7988151f3353cc', + collaterals: { + mainnet: { + lovelace: '5000000', + tx: { + hash: '5aeae083cceb3a930f3402d367096d2d524d03abf915fd9452cc59b3063a6aad', + index: 0 + } + }, + preview: undefined, + preprod: undefined + }, + paymentAddresses: { + mainnet: + 'addr1qymlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gn593a82g73tm9n0l6vehusxn3fxwwxhrssgmvnwnlaa6p4qw7srzg', + preprod: + 'addr_test1qqmlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gn593a82g73tm9n0l6vehusxn3fxwwxhrssgmvnwnlaa6p4qdgdrwh', + preview: + 'addr_test1qqmlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gn593a82g73tm9n0l6vehusxn3fxwwxhrssgmvnwnlaa6p4qdgdrwh' + } + }, + { + index: 1, + name: 'xxx', + extendedAccountPublicKey: + '5280ef1287dfa35605891eb788590dbfe43b59682ada939ee111f8667d4a0847b43c08b5dce7aab937e860626e95f05ef6cc12758fa9ee16a4fc394bd9f684e4', + collaterals: { + mainnet: undefined, + preview: undefined, + preprod: undefined + }, + paymentAddresses: { + mainnet: + 'addr1qxaqtjxrdnaxm2a74r0xtd0a5jkg3nd9zdzuvy7yc5a67qgte8c6lysmrq57uy7hgq9daj5sylgttehecjjrmgn0n2nsgwaxqs', + preprod: + 'addr_test1qzaqtjxrdnaxm2a74r0xtd0a5jkg3nd9zdzuvy7yc5a67qgte8c6lysmrq57uy7hgq9daj5sylgttehecjjrmgn0n2nstcqxv0', + preview: + 'addr_test1qzaqtjxrdnaxm2a74r0xtd0a5jkg3nd9zdzuvy7yc5a67qgte8c6lysmrq57uy7hgq9daj5sylgttehecjjrmgn0n2nstcqxv0' + } + } + ], + hardwareWallets: [ + { + index: 0, + name: 'Ledger 1', + extendedAccountPublicKey: + '7eefc2120ec17dc280f7f7adba233bcd75c00d59d9442ded45e44e00745e28d4d06673111ee5aad359f25fafbb787c55f1f80e0d9f0b567959d0a3587276210c', + collaterals: { + mainnet: undefined, + preview: { + lovelace: '5000000', + tx: { + hash: '5aeae083cceb3a930f3402d367096d2d524d03abf915fd9452cc59b3063a6aad', + index: 0 + } + }, + preprod: undefined + }, + paymentAddresses: { + mainnet: + 'addr1q85g8x8lpu6ydhe5ytggl6vrlt6prtdzzk7nljh6uf23ktknjausjhrf7f92l3ze9pp5njhkwrv45phhryjxjhtmq7rqnmrrjv', + preprod: + 'addr_test1qr5g8x8lpu6ydhe5ytggl6vrlt6prtdzzk7nljh6uf23ktknjausjhrf7f92l3ze9pp5njhkwrv45phhryjxjhtmq7rqsd7r7n', + preview: + 'addr_test1qr5g8x8lpu6ydhe5ytggl6vrlt6prtdzzk7nljh6uf23ktknjausjhrf7f92l3ze9pp5njhkwrv45phhryjxjhtmq7rqsd7r7n' + }, + vendor: 'ledger' + }, + { + index: 1, + name: 'Ledger 2', + extendedAccountPublicKey: + '18b35d8e07c1dd096ce359f4ce5ac669a27c8ac23583f9e6a53b7508efd28c849a7b1eda5ac98ed02d6048d0cbe84f91570b9f0cc3acff935cf229cd798da730', + collaterals: { + mainnet: undefined, + preview: undefined, + preprod: undefined + }, + paymentAddresses: { + mainnet: + 'addr1qyhxhljsyfuzv4f2dyaxpn6why7eher90twkcyjvpyzmm27wxzqczmxjrtvpm39rjcqj4tqt2vjwt3a4t92ujuwes4jqxx3xgd', + preprod: + 'addr_test1qqhxhljsyfuzv4f2dyaxpn6why7eher90twkcyjvpyzmm27wxzqczmxjrtvpm39rjcqj4tqt2vjwt3a4t92ujuwes4jq9svxyj', + preview: + 'addr_test1qqhxhljsyfuzv4f2dyaxpn6why7eher90twkcyjvpyzmm27wxzqczmxjrtvpm39rjcqj4tqt2vjwt3a4t92ujuwes4jq9svxyj' + }, + vendor: 'ledger' + } + ], + dapps: ['https://preview.handle.me'], + currency: 'usd', + analytics: { + enabled: true, + userId: 'b60f45ed66f596ebfd2ca19ff704cfee33e316795da50f295fc1f85d6ddf539c' + }, + themeColor: 'white' +}; diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/__tests__/nami-migration-runner.test.ts b/apps/browser-extension-wallet/src/lib/scripts/background/__tests__/nami-migration-runner.test.ts new file mode 100644 index 000000000..41ad11221 --- /dev/null +++ b/apps/browser-extension-wallet/src/lib/scripts/background/__tests__/nami-migration-runner.test.ts @@ -0,0 +1,392 @@ +/* eslint-disable sonarjs/no-identical-functions */ +/* eslint-disable unicorn/no-null */ +/* eslint-disable no-magic-numbers */ +import '@testing-library/jest-dom'; +import { run, WalletManager, WalletRepository, AnyWallet, CollateralRepository } from '../nami-migration-runner'; +import { Bip32WalletAccount, WalletType } from '@cardano-sdk/web-extension'; +import { Wallet } from '@lace/cardano'; +import { state } from './nami-migration-runner.fixture'; +import { BehaviorSubject } from 'rxjs'; + +test('fresh install', async () => { + const walletRepository: WalletRepository = { + wallets$: new BehaviorSubject([]), + addWallet: jest + .fn() + .mockResolvedValueOnce('0000000') + .mockResolvedValueOnce('1111111') + .mockResolvedValueOnce('2222222') + } as unknown as WalletRepository; + + const walletManager: WalletManager = { + activate: jest.fn() + } as unknown as WalletManager; + + const collateralRepository: CollateralRepository = jest.fn(); + + await run({ walletRepository, walletManager, collateralRepository, state }); + + expect(collateralRepository).toHaveBeenNthCalledWith(1, { + walletId: '0000000', + accountIndex: 0, + chainId: Wallet.Cardano.ChainIds.Mainnet, + utxo: [ + { + txId: Wallet.Cardano.TransactionId('5aeae083cceb3a930f3402d367096d2d524d03abf915fd9452cc59b3063a6aad'), + index: 0, + address: + 'addr1qymlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gn593a82g73tm9n0l6vehusxn3fxwwxhrssgmvnwnlaa6p4qw7srzg' + }, + { + address: + 'addr1qymlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gn593a82g73tm9n0l6vehusxn3fxwwxhrssgmvnwnlaa6p4qw7srzg', + value: { + coins: BigInt('5000000') + } + } + ] + }); + + expect(collateralRepository).toHaveBeenNthCalledWith(2, { + chainId: Wallet.Cardano.ChainIds.Preview, + walletId: '1111111', + accountIndex: 0, + utxo: [ + { + txId: Wallet.Cardano.TransactionId('5aeae083cceb3a930f3402d367096d2d524d03abf915fd9452cc59b3063a6aad'), + index: 0, + address: + 'addr_test1qr5g8x8lpu6ydhe5ytggl6vrlt6prtdzzk7nljh6uf23ktknjausjhrf7f92l3ze9pp5njhkwrv45phhryjxjhtmq7rqsd7r7n' + }, + { + address: + 'addr_test1qr5g8x8lpu6ydhe5ytggl6vrlt6prtdzzk7nljh6uf23ktknjausjhrf7f92l3ze9pp5njhkwrv45phhryjxjhtmq7rqsd7r7n', + value: { + coins: BigInt('5000000') + } + } + ] + }); + + expect(walletManager.activate).toHaveBeenCalledWith({ + walletId: '0000000', + accountIndex: 0, + chainId: Wallet.Cardano.ChainIds.Mainnet + }); + + expect(walletRepository.addWallet).toHaveBeenNthCalledWith(1, { + metadata: { name: 'Nami', lastActiveAccountIndex: 0 }, + encryptedSecrets: { + keyMaterial: '', + rootPrivateKeyBytes: + 'da7af7c22eeaf4bb460c02b426792d556a9242a7e8dca47e7628350f4290d97a0078f3dad5812606f4fa993772dc1c0fc5b7941dc91796a111a8d789b8d3f473eb9c67b32f89f5a2518ff02bb5595a00638e99e7799858b42a639edab14d1bd997eeb8ebe518939ca0522a527219062d1585bfd34434dd84c2a3f895d34863158fce54c1c2c2ab8e8fd3d23d70bc3114fd302badca2c850160597443' + }, + accounts: [ + { + accountIndex: 0, + metadata: { name: 'Account #0' }, + extendedAccountPublicKey: + 'a5f18f73dde7b6f11df448913d60a86bbb397a435269e5024193b293f28892fd33d1225d468aac8f5a9d3cfedceacabe80192fcf0beb5c5c9b7988151f3353cc' + }, + { + accountIndex: 1, + metadata: { name: 'Account #1' }, + extendedAccountPublicKey: + '5280ef1287dfa35605891eb788590dbfe43b59682ada939ee111f8667d4a0847b43c08b5dce7aab937e860626e95f05ef6cc12758fa9ee16a4fc394bd9f684e4' + } + ], + type: WalletType.InMemory + }); + + expect(walletRepository.addWallet).toHaveBeenNthCalledWith(2, { + metadata: { name: 'Ledger 1', lastActiveAccountIndex: 0 }, + type: 'Ledger', + accounts: [ + { + extendedAccountPublicKey: + '7eefc2120ec17dc280f7f7adba233bcd75c00d59d9442ded45e44e00745e28d4d06673111ee5aad359f25fafbb787c55f1f80e0d9f0b567959d0a3587276210c', + accountIndex: 0, + metadata: { name: 'Account #0' } + } + ] + }); + + expect(walletRepository.addWallet).toHaveBeenNthCalledWith(3, { + metadata: { name: 'Ledger 2', lastActiveAccountIndex: 1 }, + type: 'Ledger', + accounts: [ + { + extendedAccountPublicKey: + '18b35d8e07c1dd096ce359f4ce5ac669a27c8ac23583f9e6a53b7508efd28c849a7b1eda5ac98ed02d6048d0cbe84f91570b9f0cc3acff935cf229cd798da730', + accountIndex: 1, + metadata: { name: 'Account #1' } + } + ] + }); +}); + +test('lace already installed and has no conflict', async () => { + const walletRepository: WalletRepository = { + wallets$: new BehaviorSubject([ + { + accounts: [ + { + accountIndex: 0, + extendedAccountPublicKey: '000000' + } + ] as Bip32WalletAccount[], + type: WalletType.InMemory, + walletId: '0000000' + } + ] as AnyWallet[]), + addWallet: jest + .fn() + .mockResolvedValueOnce('0000000') + .mockResolvedValueOnce('1111111') + .mockResolvedValueOnce('2222222') + } as unknown as WalletRepository; + + const walletManager: WalletManager = { + activate: jest.fn() + } as unknown as WalletManager; + + const collateralRepository: CollateralRepository = jest.fn(); + + await run({ walletRepository, walletManager, collateralRepository, state }); + + expect(collateralRepository).toHaveBeenNthCalledWith(1, { + walletId: '0000000', + accountIndex: 0, + chainId: Wallet.Cardano.ChainIds.Mainnet, + utxo: [ + { + txId: Wallet.Cardano.TransactionId('5aeae083cceb3a930f3402d367096d2d524d03abf915fd9452cc59b3063a6aad'), + index: 0, + address: + 'addr1qymlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gn593a82g73tm9n0l6vehusxn3fxwwxhrssgmvnwnlaa6p4qw7srzg' + }, + { + address: + 'addr1qymlvzkhufx0f94vqtfsmfa7yzxtwql8ga8aq5yk6u98gn593a82g73tm9n0l6vehusxn3fxwwxhrssgmvnwnlaa6p4qw7srzg', + value: { + coins: BigInt('5000000') + } + } + ] + }); + + expect(collateralRepository).toHaveBeenNthCalledWith(2, { + chainId: Wallet.Cardano.ChainIds.Preview, + walletId: '1111111', + accountIndex: 0, + utxo: [ + { + txId: Wallet.Cardano.TransactionId('5aeae083cceb3a930f3402d367096d2d524d03abf915fd9452cc59b3063a6aad'), + index: 0, + address: + 'addr_test1qr5g8x8lpu6ydhe5ytggl6vrlt6prtdzzk7nljh6uf23ktknjausjhrf7f92l3ze9pp5njhkwrv45phhryjxjhtmq7rqsd7r7n' + }, + { + address: + 'addr_test1qr5g8x8lpu6ydhe5ytggl6vrlt6prtdzzk7nljh6uf23ktknjausjhrf7f92l3ze9pp5njhkwrv45phhryjxjhtmq7rqsd7r7n', + value: { + coins: BigInt('5000000') + } + } + ] + }); + + expect(walletManager.activate).toHaveBeenCalledWith({ + walletId: '0000000', + accountIndex: 0, + chainId: Wallet.Cardano.ChainIds.Mainnet + }); + + expect(walletRepository.addWallet).toHaveBeenNthCalledWith(1, { + metadata: { name: 'Nami', lastActiveAccountIndex: 0 }, + encryptedSecrets: { + keyMaterial: '', + rootPrivateKeyBytes: + 'da7af7c22eeaf4bb460c02b426792d556a9242a7e8dca47e7628350f4290d97a0078f3dad5812606f4fa993772dc1c0fc5b7941dc91796a111a8d789b8d3f473eb9c67b32f89f5a2518ff02bb5595a00638e99e7799858b42a639edab14d1bd997eeb8ebe518939ca0522a527219062d1585bfd34434dd84c2a3f895d34863158fce54c1c2c2ab8e8fd3d23d70bc3114fd302badca2c850160597443' + }, + accounts: [ + { + accountIndex: 0, + metadata: { name: 'Account #0' }, + extendedAccountPublicKey: + 'a5f18f73dde7b6f11df448913d60a86bbb397a435269e5024193b293f28892fd33d1225d468aac8f5a9d3cfedceacabe80192fcf0beb5c5c9b7988151f3353cc' + }, + { + accountIndex: 1, + metadata: { name: 'Account #1' }, + extendedAccountPublicKey: + '5280ef1287dfa35605891eb788590dbfe43b59682ada939ee111f8667d4a0847b43c08b5dce7aab937e860626e95f05ef6cc12758fa9ee16a4fc394bd9f684e4' + } + ], + type: WalletType.InMemory + }); + + expect(walletRepository.addWallet).toHaveBeenNthCalledWith(2, { + metadata: { name: 'Ledger 1', lastActiveAccountIndex: 0 }, + type: 'Ledger', + accounts: [ + { + extendedAccountPublicKey: + '7eefc2120ec17dc280f7f7adba233bcd75c00d59d9442ded45e44e00745e28d4d06673111ee5aad359f25fafbb787c55f1f80e0d9f0b567959d0a3587276210c', + accountIndex: 0, + metadata: { name: 'Account #0' } + } + ] + }); + + expect(walletRepository.addWallet).toHaveBeenNthCalledWith(3, { + metadata: { name: 'Ledger 2', lastActiveAccountIndex: 1 }, + type: 'Ledger', + accounts: [ + { + extendedAccountPublicKey: + '18b35d8e07c1dd096ce359f4ce5ac669a27c8ac23583f9e6a53b7508efd28c849a7b1eda5ac98ed02d6048d0cbe84f91570b9f0cc3acff935cf229cd798da730', + accountIndex: 1, + metadata: { name: 'Account #1' } + } + ] + }); +}); + +test('some accounts existing conflict', async () => { + const walletRepository: WalletRepository = { + wallets$: new BehaviorSubject([ + { + accounts: [ + { + accountIndex: 0, + extendedAccountPublicKey: + 'a5f18f73dde7b6f11df448913d60a86bbb397a435269e5024193b293f28892fd33d1225d468aac8f5a9d3cfedceacabe80192fcf0beb5c5c9b7988151f3353cc' + } + ] as Bip32WalletAccount[], + type: WalletType.InMemory, + walletId: '0000000' + }, + { + accounts: [ + { + accountIndex: 1, + extendedAccountPublicKey: + '18b35d8e07c1dd096ce359f4ce5ac669a27c8ac23583f9e6a53b7508efd28c849a7b1eda5ac98ed02d6048d0cbe84f91570b9f0cc3acff935cf229cd798da730' + } + ] as Bip32WalletAccount[], + type: WalletType.Ledger, + walletId: '111111' + } + ] as AnyWallet[]), + addWallet: jest.fn().mockResolvedValueOnce('2222222'), + addAccount: jest.fn() + } as unknown as WalletRepository; + + const walletManager: WalletManager = { + activate: jest.fn() + } as unknown as WalletManager; + + const collateralRepository: CollateralRepository = jest.fn(); + + await run({ walletRepository, walletManager, collateralRepository, state }); + + expect(collateralRepository).toHaveBeenCalledTimes(1); + expect(collateralRepository).toHaveBeenCalledWith({ + chainId: Wallet.Cardano.ChainIds.Preview, + accountIndex: 0, + walletId: '2222222', + utxo: [ + { + txId: Wallet.Cardano.TransactionId('5aeae083cceb3a930f3402d367096d2d524d03abf915fd9452cc59b3063a6aad'), + index: 0, + address: + 'addr_test1qr5g8x8lpu6ydhe5ytggl6vrlt6prtdzzk7nljh6uf23ktknjausjhrf7f92l3ze9pp5njhkwrv45phhryjxjhtmq7rqsd7r7n' + }, + { + address: + 'addr_test1qr5g8x8lpu6ydhe5ytggl6vrlt6prtdzzk7nljh6uf23ktknjausjhrf7f92l3ze9pp5njhkwrv45phhryjxjhtmq7rqsd7r7n', + value: { + coins: BigInt('5000000') + } + } + ] + }); + + expect(walletManager.activate).not.toHaveBeenCalled(); + + expect(walletRepository.addAccount).toHaveBeenNthCalledWith(1, { + accountIndex: 1, + metadata: { name: 'Account #1' }, + extendedAccountPublicKey: + '5280ef1287dfa35605891eb788590dbfe43b59682ada939ee111f8667d4a0847b43c08b5dce7aab937e860626e95f05ef6cc12758fa9ee16a4fc394bd9f684e4', + walletId: '0000000' + }); + + expect(walletRepository.addWallet).toHaveBeenNthCalledWith(1, { + metadata: { name: 'Ledger 1', lastActiveAccountIndex: 0 }, + type: 'Ledger', + accounts: [ + { + extendedAccountPublicKey: + '7eefc2120ec17dc280f7f7adba233bcd75c00d59d9442ded45e44e00745e28d4d06673111ee5aad359f25fafbb787c55f1f80e0d9f0b567959d0a3587276210c', + accountIndex: 0, + metadata: { name: 'Account #0' } + } + ] + }); +}); + +test('all accounts existing conflict', async () => { + const walletRepository: WalletRepository = { + wallets$: new BehaviorSubject([ + { + accounts: [ + { + accountIndex: 0, + extendedAccountPublicKey: + 'a5f18f73dde7b6f11df448913d60a86bbb397a435269e5024193b293f28892fd33d1225d468aac8f5a9d3cfedceacabe80192fcf0beb5c5c9b7988151f3353cc' + }, + { + accountIndex: 1, + extendedAccountPublicKey: + '5280ef1287dfa35605891eb788590dbfe43b59682ada939ee111f8667d4a0847b43c08b5dce7aab937e860626e95f05ef6cc12758fa9ee16a4fc394bd9f684e4' + } + ] as Bip32WalletAccount[], + type: WalletType.InMemory, + walletId: '0000000' + }, + { + accounts: [ + { + accountIndex: 0, + extendedAccountPublicKey: + '7eefc2120ec17dc280f7f7adba233bcd75c00d59d9442ded45e44e00745e28d4d06673111ee5aad359f25fafbb787c55f1f80e0d9f0b567959d0a3587276210c' + }, + { + accountIndex: 1, + extendedAccountPublicKey: + '18b35d8e07c1dd096ce359f4ce5ac669a27c8ac23583f9e6a53b7508efd28c849a7b1eda5ac98ed02d6048d0cbe84f91570b9f0cc3acff935cf229cd798da730' + } + ] as Bip32WalletAccount[], + type: WalletType.Ledger, + walletId: '111111' + } + ] as AnyWallet[]), + addWallet: jest.fn(), + addAccount: jest.fn() + } as unknown as WalletRepository; + + const walletManager: WalletManager = { + activate: jest.fn() + } as unknown as WalletManager; + + const collateralRepository: CollateralRepository = jest.fn(); + + await run({ walletRepository, walletManager, collateralRepository, state }); + + expect(collateralRepository).not.toHaveBeenCalled(); + expect(walletManager.activate).not.toHaveBeenCalled(); + expect(walletRepository.addAccount).not.toHaveBeenCalled(); + expect(walletRepository.addWallet).not.toHaveBeenCalled(); +}); diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/cache-nami-metadata.ts b/apps/browser-extension-wallet/src/lib/scripts/background/cache-nami-metadata.ts new file mode 100644 index 000000000..bdec36207 --- /dev/null +++ b/apps/browser-extension-wallet/src/lib/scripts/background/cache-nami-metadata.ts @@ -0,0 +1,82 @@ +import { filter, of, switchMap, zip } from 'rxjs'; +import merge from 'lodash/merge'; +import { Wallet } from '@lace/cardano'; +// eslint does not support exports property in package.json yet +// eslint-disable-next-line import/no-unresolved +import { getBalance as getBalanceFn } from '@lace/nami/adapters'; +import { WalletManager, WalletRepository } from '@cardano-sdk/web-extension'; +import { blockingWithLatestFrom } from '@cardano-sdk/util-rxjs'; +import { isNotNil } from '@cardano-sdk/util'; + +export const cacheNamiMetadataSubscription = ({ + getBalance = getBalanceFn, + walletManager, + walletRepository +}: { + getBalance?: typeof getBalanceFn; + walletManager: WalletManager; + walletRepository: WalletRepository; +}): void => { + walletManager.activeWallet$ + .pipe( + filter(isNotNil), + switchMap((wallet) => + zip([ + of(wallet.props.walletId), + of(wallet.props.accountIndex), + wallet.observableWallet.addresses$, + wallet.observableWallet.balance.utxo.total$, + wallet.observableWallet.balance.utxo.unspendable$, + wallet.observableWallet.balance.rewardAccounts.rewards$, + wallet.observableWallet.protocolParameters$ + ]) + ), + blockingWithLatestFrom(walletRepository.wallets$) + ) + .subscribe( + ([ + [activeWalletId, activeWalletAccountIndex, addresses, total, unspendable, rewards, protocolParameters], + wallets + ]) => { + const address = addresses[0].address; + const wallet = wallets.find(({ walletId }) => walletId === activeWalletId); + + if (!('accounts' in wallet)) { + return; + } + const account = wallet.accounts.find(({ accountIndex }) => accountIndex === activeWalletAccountIndex); + if (!account) { + return; + } + + const { metadata } = account; + const hasAvatar = Boolean(metadata.namiMode?.avatar); + const hasAddress = Boolean(metadata.namiMode?.address); + const balance = getBalance({ + address: wallet.metadata?.walletAddresses?.[0] || addresses[0].address, + total, + unspendable, + rewards, + protocolParameters + }); + const avatar = Math.random().toString(); + + const updatedMetadata = merge( + { ...metadata }, + { + namiMode: { + ...(!hasAvatar && { avatar }), + ...(!hasAddress && { address }), + balance: (balance.totalCoins - balance.lockedCoins - balance.unspendableCoins).toString() + } + } + ); + + walletRepository.updateAccountMetadata({ + walletId: activeWalletId, + accountIndex: activeWalletAccountIndex, + metadata: updatedMetadata + }); + } + ); +}; diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/config.ts b/apps/browser-extension-wallet/src/lib/scripts/background/config.ts index 8762c16a2..6b05c1885 100644 --- a/apps/browser-extension-wallet/src/lib/scripts/background/config.ts +++ b/apps/browser-extension-wallet/src/lib/scripts/background/config.ts @@ -13,7 +13,9 @@ export const backgroundServiceProperties: RemoteApiProperties tokenPrices$: RemoteApiPropertyType.HotObservable }, handleOpenBrowser: RemoteApiPropertyType.MethodReturningPromise, + handleOpenPopup: RemoteApiPropertyType.MethodReturningPromise, handleChangeTheme: RemoteApiPropertyType.MethodReturningPromise, + handleChangeMode: RemoteApiPropertyType.MethodReturningPromise, clearBackgroundStorage: RemoteApiPropertyType.MethodReturningPromise, getBackgroundStorage: RemoteApiPropertyType.MethodReturningPromise, setBackgroundStorage: RemoteApiPropertyType.MethodReturningPromise, diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/index.ts b/apps/browser-extension-wallet/src/lib/scripts/background/index.ts index a55c6e077..5b7d78ed7 100644 --- a/apps/browser-extension-wallet/src/lib/scripts/background/index.ts +++ b/apps/browser-extension-wallet/src/lib/scripts/background/index.ts @@ -5,3 +5,4 @@ import './services'; import './services/expose'; import './keep-alive-sw'; import './onUninstall'; +import './nami-migration'; diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/nami-migration-runner.ts b/apps/browser-extension-wallet/src/lib/scripts/background/nami-migration-runner.ts new file mode 100644 index 000000000..eec0a0aa1 --- /dev/null +++ b/apps/browser-extension-wallet/src/lib/scripts/background/nami-migration-runner.ts @@ -0,0 +1,254 @@ +import type * as Nami from '@xsy/nami-migration-tool/dist/migrator/migration-data.data'; +import * as Extension from '@cardano-sdk/web-extension'; +import { Wallet } from '@lace/cardano'; +import { HexBlob } from '@cardano-sdk/util'; +import { firstValueFrom } from 'rxjs'; +import { WalletId } from '@cardano-sdk/web-extension'; + +export type WalletRepository = Extension.WalletRepository; +export type AddWalletProps = Extension.AddWalletProps; +export type AnyWallet = Extension.AnyWallet; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WalletManager = Extension.WalletManager; +export type CollateralRepository = (args: { + walletId: WalletId; + chainId: Wallet.Cardano.ChainId; + accountIndex?: number; + utxo: Wallet.Cardano.Utxo; +}) => Promise; + +const accountName = (account: Nami.Account) => `Account #${account.index}`; + +const networkToChainId = (network: Nami.Networks): Wallet.Cardano.ChainId => { + switch (network) { + case 'mainnet': + return Wallet.Cardano.ChainIds.Mainnet; + case 'preview': + return Wallet.Cardano.ChainIds.Preview; + default: + return Wallet.Cardano.ChainIds.Preprod; + } +}; + +type SetCollateral = (args: { + collateralRepository: CollateralRepository; + account: Nami.Account; + walletId: string; +}) => Promise; + +const setCollateral: SetCollateral = async ({ collateralRepository, account, walletId }) => { + const collaterals = Object.entries(account.collaterals).filter(([, collateral]) => collateral !== undefined); + + for (const [network, collateral] of collaterals) { + if (collateral === undefined) { + continue; + } + + const address = Wallet.Cardano.PaymentAddress(account.paymentAddresses[network as Nami.Networks]); + + const utxo: Wallet.Cardano.Utxo = [ + { + txId: Wallet.Cardano.TransactionId(collateral.tx.hash), + index: collateral.tx.index, + address + }, + { + address, + value: { + coins: BigInt(collateral.lovelace) + } + } + ]; + + await collateralRepository({ + chainId: networkToChainId(network as Nami.Networks), + utxo, + walletId, + accountIndex: account.index + }); + } +}; + +type FreshInstall = (args: { + walletRepository: WalletRepository; + collateralRepository: CollateralRepository; + encryptedPrivateKey: string; + accounts: Nami.Account[]; +}) => Promise; + +const freshInstall: FreshInstall = async ({ + walletRepository, + collateralRepository, + encryptedPrivateKey, + accounts +}) => { + const addWalletProps: AddWalletProps = { + metadata: { name: 'Nami', lastActiveAccountIndex: 0 }, + encryptedSecrets: { + keyMaterial: HexBlob.fromBytes(Buffer.from('')), + rootPrivateKeyBytes: HexBlob.fromBytes(Buffer.from(encryptedPrivateKey, 'hex')) + }, + accounts: accounts.map((account) => ({ + accountIndex: account.index, + metadata: { name: accountName(account) }, + extendedAccountPublicKey: Wallet.Crypto.Bip32PublicKeyHex(account.extendedAccountPublicKey) + })), + type: Extension.WalletType.InMemory + }; + const walletId = await walletRepository.addWallet(addWalletProps); + + for (const account of accounts) { + await setCollateral({ collateralRepository, account, walletId }); + } + + return walletId; +}; + +type ImportHardwareWallet = (args: { + walletRepository: WalletRepository; + collateralRepository: CollateralRepository; + hardwareWallet: Nami.HarwareWallet; +}) => Promise; + +const importHardwareWallet: ImportHardwareWallet = async ({ + walletRepository, + collateralRepository, + hardwareWallet +}) => { + const addWalletProps: AddWalletProps = { + metadata: { name: hardwareWallet.name, lastActiveAccountIndex: hardwareWallet.index }, + type: hardwareWallet.vendor === 'ledger' ? Extension.WalletType.Ledger : Extension.WalletType.Trezor, + accounts: [ + { + extendedAccountPublicKey: Wallet.Crypto.Bip32PublicKeyHex(hardwareWallet.extendedAccountPublicKey), + accountIndex: hardwareWallet.index, + metadata: { name: accountName(hardwareWallet) } + } + ] + }; + + const walletId = await walletRepository.addWallet(addWalletProps); + + await setCollateral({ collateralRepository, account: hardwareWallet, walletId }); +}; + +type ImportAccounts = (args: { + walletRepository: WalletRepository; + collateralRepository: CollateralRepository; + walletId: string; + account: Nami.Account; +}) => Promise; + +const importAccounts: ImportAccounts = async ({ walletRepository, collateralRepository, walletId, account }) => { + await walletRepository.addAccount({ + accountIndex: account.index, + extendedAccountPublicKey: Wallet.Crypto.Bip32PublicKeyHex(account.extendedAccountPublicKey), + metadata: { name: accountName(account) }, + walletId + }); + + await setCollateral({ collateralRepository, account, walletId }); +}; + +const populateExistingAccounts = (existingWallets: AnyWallet[]) => { + const existingAccounts = new Map(); + + for (const wallet of existingWallets) { + if (wallet.type !== Extension.WalletType.Script) { + for (const account of wallet.accounts) { + existingAccounts.set(account.extendedAccountPublicKey, wallet.walletId); + } + } + } + + return existingAccounts; +}; + +type ImportHardwareWallets = (args: { + walletRepository: WalletRepository; + collateralRepository: CollateralRepository; + hardwareWallets: Nami.HarwareWallet[]; +}) => Promise; + +const importHardwareWallets: ImportHardwareWallets = async ({ + walletRepository, + collateralRepository, + hardwareWallets +}) => { + for (const hardwareWallet of hardwareWallets) { + await importHardwareWallet({ walletRepository, collateralRepository, hardwareWallet }); + } +}; + +type Runer = (args: { + walletRepository: WalletRepository; + walletManager: WalletManager; + collateralRepository: CollateralRepository; + state: Nami.State; +}) => Promise; + +export const run: Runer = async ({ walletRepository, collateralRepository, walletManager, state }) => { + const existingWallets = await firstValueFrom(walletRepository.wallets$); + + if (existingWallets.length === 0) { + const walletId = await freshInstall({ + walletRepository, + collateralRepository, + encryptedPrivateKey: state.encryptedPrivateKey, + accounts: state.accounts + }); + await importHardwareWallets({ walletRepository, collateralRepository, hardwareWallets: state.hardwareWallets }); + + await walletManager.activate({ + walletId, + chainId: Wallet.Cardano.ChainIds.Mainnet, + accountIndex: 0 + }); + return; + } + + const existingAccounts = populateExistingAccounts(existingWallets); + + const accountsToAdd = []; + let existingWalletId = ''; + + for (const account of state.accounts) { + if (!existingAccounts.has(account.extendedAccountPublicKey)) { + accountsToAdd.push(account); + } else { + existingWalletId = existingAccounts.get(account.extendedAccountPublicKey); + } + } + + for (const account of state.hardwareWallets) { + if (!existingAccounts.has(account.extendedAccountPublicKey)) { + accountsToAdd.push(account); + } + } + + if (accountsToAdd.length === state.accounts.length + state.hardwareWallets.length) { + const walletId = await freshInstall({ + walletRepository, + collateralRepository, + encryptedPrivateKey: state.encryptedPrivateKey, + accounts: state.accounts + }); + await importHardwareWallets({ walletRepository, collateralRepository, hardwareWallets: state.hardwareWallets }); + + await walletManager.activate({ + walletId, + chainId: Wallet.Cardano.ChainIds.Mainnet, + accountIndex: 0 + }); + } else { + for (const account of accountsToAdd) { + await ('vendor' in account + ? importHardwareWallet({ + walletRepository, + collateralRepository, + hardwareWallet: account as Nami.HarwareWallet + }) + : importAccounts({ walletRepository, collateralRepository, walletId: existingWalletId, account })); + } + } +}; diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/nami-migration.ts b/apps/browser-extension-wallet/src/lib/scripts/background/nami-migration.ts new file mode 100644 index 000000000..5591c1f79 --- /dev/null +++ b/apps/browser-extension-wallet/src/lib/scripts/background/nami-migration.ts @@ -0,0 +1,65 @@ +import { runtime } from 'webextension-polyfill'; +import * as laceMigrationClient from '@xsy/nami-migration-tool/dist/cross-extension-messaging/lace-migration-client.extension'; +import { MigrationState } from '@xsy/nami-migration-tool/dist/migrator/migration-state.data'; +import { walletRepository, walletManager, getBaseDbName } from './wallet'; + +import { run, CollateralRepository } from './nami-migration-runner'; +import { currencyCode } from '@providers/currency/constants'; +import { exposeApi, getWalletStoreId, RemoteApiPropertyType } from '@cardano-sdk/web-extension'; +import { storage } from '@cardano-sdk/wallet'; +import { of } from 'rxjs'; + +const collateralRepository: CollateralRepository = async ({ utxo, chainId, walletId, accountIndex }) => { + const walletStoreId = getWalletStoreId(walletId, chainId, accountIndex); + const baseDbName = getBaseDbName(walletStoreId); + const db = new storage.PouchDbUtxoStore({ dbName: `${baseDbName}UnspendableUtxo` }, console); + db.setAll([utxo]); +}; + +const startMigration = async () => { + const state = await laceMigrationClient.requestMigrationData(); + + await run({ walletRepository, walletManager, state, collateralRepository }); + + await laceMigrationClient.completeMigration(); + + return { + currency: state.currency === 'usd' ? currencyCode.USD : currencyCode.EUR, + analytics: state.analytics, + themeColor: state.themeColor + }; +}; + +export enum NamiMigrationChannels { + MIGRATION = 'migration' +} + +export interface NamiMigrationAPI { + checkMigrationStatus: () => Promise; + startMigration: () => Promise<{ + currency: currencyCode; + analytics: { + enabled: boolean; + userId: string; + }; + themeColor: string; + }>; + abortMigration: () => Promise; +} + +exposeApi( + { + api$: of({ + startMigration, + checkMigrationStatus: laceMigrationClient.checkMigrationStatus, + abortMigration: laceMigrationClient.abortMigration + }), + baseChannel: NamiMigrationChannels.MIGRATION, + properties: { + startMigration: RemoteApiPropertyType.MethodReturningPromise, + checkMigrationStatus: RemoteApiPropertyType.MethodReturningPromise, + abortMigration: RemoteApiPropertyType.MethodReturningPromise + } + }, + { logger: console, runtime } +); diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/services/expose.ts b/apps/browser-extension-wallet/src/lib/scripts/background/services/expose.ts index 01f04917a..1568531f3 100644 --- a/apps/browser-extension-wallet/src/lib/scripts/background/services/expose.ts +++ b/apps/browser-extension-wallet/src/lib/scripts/background/services/expose.ts @@ -5,6 +5,7 @@ import { UserIdService } from './userIdService'; import { USER_ID_SERVICE_BASE_CHANNEL, UserIdService as UserIdServiceInterface } from '@lib/scripts/types'; import { of } from 'rxjs'; import { runtime } from 'webextension-polyfill'; +import * as laceMigrationClient from '@xsy/nami-migration-tool/dist/cross-extension-messaging/lace-migration-client.extension'; // This was hoisted from userIdService.ts so that it's not being exposed while running the unit tests of the class itself. // It might be a good idea to follow the pattern and hoist all exposeApi calls to this file. @@ -17,3 +18,7 @@ exposeApi( }, { logger: console, runtime } ); + +// eslint-disable-next-line no-console +console.log('[NAMI MIGRATION] handling nami requests'); +laceMigrationClient.handleNamiRequests(); diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/services/utilityServices.ts b/apps/browser-extension-wallet/src/lib/scripts/background/services/utilityServices.ts index f34ece8fc..706276a41 100644 --- a/apps/browser-extension-wallet/src/lib/scripts/background/services/utilityServices.ts +++ b/apps/browser-extension-wallet/src/lib/scripts/background/services/utilityServices.ts @@ -9,7 +9,8 @@ import { OpenBrowserData, MigrationState, TokenPrices, - CoinPrices + CoinPrices, + ChangeModeData } from '../../types'; import { Subject, of, BehaviorSubject } from 'rxjs'; import { walletRoutePaths } from '@routes/wallet-paths'; @@ -17,7 +18,7 @@ import { backgroundServiceProperties } from '../config'; import { exposeApi } from '@cardano-sdk/web-extension'; import { Cardano } from '@cardano-sdk/core'; import { config } from '@src/config'; -import { getADAPriceFromBackgroundStorage } from '../util'; +import { getADAPriceFromBackgroundStorage, closeAllLaceWindows } from '../util'; import { currencies as currenciesMap, currencyCode } from '@providers/currency/constants'; import { clearBackgroundStorage, getBackgroundStorage, setBackgroundStorage } from '../storage'; @@ -83,13 +84,27 @@ const handleOpenBrowser = async (data: OpenBrowserData) => { case BrowserViewSections.ADD_SHARED_WALLET: path = walletRoutePaths.sharedWallet.root; break; + case BrowserViewSections.NAMI_MIGRATION: + path = walletRoutePaths.namiMigration.root; + break; } const params = data.urlSearchParams ? `?${data.urlSearchParams}` : ''; await tabs.create({ url: `app.html#${path}${params}` }).catch((error) => console.error(error)); }; +const handleOpenPopup = async () => { + if (typeof chrome.action.openPopup !== 'function') return; + await closeAllLaceWindows(); + // behaves inconsistently if executed without setTimeout + setTimeout(async () => { + await chrome.action.openPopup(); + }); +}; + const handleChangeTheme = (data: ChangeThemeData) => requestMessage$.next({ type: MessageTypes.CHANGE_THEME, data }); +const handleChangeMode = (data: ChangeModeData) => requestMessage$.next({ type: MessageTypes.CHANGE_MODE, data }); + const { ADA_PRICE_CHECK_INTERVAL, SAVED_PRICE_DURATION, TOKEN_PRICE_CHECK_INTERVAL } = config(); const fetchTokenPrices = () => { fetch('https://muesliswap.live-mainnet.eks.lw.iog.io/lace/prices') @@ -169,10 +184,12 @@ exposeApi( { api$: of({ handleOpenBrowser, + handleOpenPopup, requestMessage$, migrationState$, coinPrices, handleChangeTheme, + handleChangeMode, clearBackgroundStorage, getBackgroundStorage, setBackgroundStorage, diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/util.ts b/apps/browser-extension-wallet/src/lib/scripts/background/util.ts index c6439aeff..2baa63baf 100644 --- a/apps/browser-extension-wallet/src/lib/scripts/background/util.ts +++ b/apps/browser-extension-wallet/src/lib/scripts/background/util.ts @@ -122,6 +122,14 @@ export const getActiveWallet = async ({ return { wallet, account }; }; +export const closeAllLaceWindows = async (): Promise => { + const openTabs = await tabs.query({ title: 'Lace' }); + // Close all previously opened lace dapp connector windows + for (const tab of openTabs) { + if (DAPP_CONNECTOR_REGEX.test(tab.url)) await tabs.remove(tab.id); + } +}; + export const ensureUiIsOpenAndLoaded = async ( services: WalletManagementServices, url?: string, @@ -135,11 +143,7 @@ export const ensureUiIsOpenAndLoaded = async ( : undefined; const windowType: Windows.CreateType = isHardwareWallet ? 'normal' : 'popup'; - const openTabs = await tabs.query({ title: 'Lace' }); - // Close all previously opened lace dapp connector windows - for (const tab of openTabs) { - if (DAPP_CONNECTOR_REGEX.test(tab.url)) await tabs.remove(tab.id); - } + await closeAllLaceWindows(); const tab = await launchCip30Popup(url, windowType); if (tab.status !== 'complete') { diff --git a/apps/browser-extension-wallet/src/lib/scripts/background/wallet.ts b/apps/browser-extension-wallet/src/lib/scripts/background/wallet.ts index cfaa3c1ef..e1554f967 100644 --- a/apps/browser-extension-wallet/src/lib/scripts/background/wallet.ts +++ b/apps/browser-extension-wallet/src/lib/scripts/background/wallet.ts @@ -31,6 +31,7 @@ import { cacheActivatedWalletAddressSubscription } from './cache-wallets-address import axiosFetchAdapter from '@shiroyasha9/axios-fetch-adapter'; import { SharedWalletScriptKind } from '@lace/core'; import { getBaseUrlForChain } from '@utils/chain'; +import { cacheNamiMetadataSubscription } from './cache-nami-metadata'; const logger = console; @@ -132,9 +133,11 @@ const walletFactory: WalletFactory name.replace(/[^\da-z]/gi, ''); + const storesFactory: StoresFactory = { create: ({ name }) => { - const baseDbName = name.replace(/[^\da-z]/gi, ''); + const baseDbName = getBaseDbName(name); const docsDbName = `${baseDbName}Docs`; return { addresses: new storage.PouchDbAddressesStore(docsDbName, 'addresses', logger), @@ -237,4 +240,6 @@ walletManager cacheActivatedWalletAddressSubscription(walletManager, walletRepository); +cacheNamiMetadataSubscription({ walletManager, walletRepository }); + export const wallet$ = walletManager.activeWallet$; diff --git a/apps/browser-extension-wallet/src/lib/scripts/types/background-service.ts b/apps/browser-extension-wallet/src/lib/scripts/types/background-service.ts index dab29d43e..89dd9a63e 100644 --- a/apps/browser-extension-wallet/src/lib/scripts/types/background-service.ts +++ b/apps/browser-extension-wallet/src/lib/scripts/types/background-service.ts @@ -16,11 +16,17 @@ export interface ChangeThemeData { theme: themes; } +export interface ChangeModeData { + mode: 'lace' | 'nami'; + completed?: boolean; +} + export enum MessageTypes { OPEN_BROWSER_VIEW = 'open-browser-view', CHANGE_THEME = 'change-theme', HTTP_CONNECTION = 'http-connnection', - OPEN_COLLATERAL_SETTINGS = 'open-collateral-settings' + OPEN_COLLATERAL_SETTINGS = 'open-collateral-settings', + CHANGE_MODE = 'change-mode' } export enum BrowserViewSections { @@ -36,7 +42,8 @@ export enum BrowserViewSections { COLLATERAL_SETTINGS = 'collateral-settings', FORGOT_PASSWORD = 'forgot_password', NEW_WALLET = 'new_wallet', - ADD_SHARED_WALLET = 'add_shared_wallet' + ADD_SHARED_WALLET = 'add_shared_wallet', + NAMI_MIGRATION = 'nami_migration' } export interface OpenBrowserData { @@ -56,14 +63,21 @@ interface OpenBrowserMessage { type: MessageTypes.OPEN_BROWSER_VIEW | MessageTypes.OPEN_COLLATERAL_SETTINGS; data: OpenBrowserData; } -export type Message = ChangeThemeMessage | HTTPConnectionMessage | OpenBrowserMessage; + +interface ChangeMode { + type: MessageTypes.CHANGE_MODE; + data: ChangeModeData; +} +export type Message = ChangeThemeMessage | HTTPConnectionMessage | OpenBrowserMessage | ChangeMode; export type BackgroundService = { handleOpenBrowser: (data: OpenBrowserData, urlSearchParams?: string) => Promise; + handleOpenPopup: () => Promise; requestMessage$: Subject; migrationState$: BehaviorSubject; coinPrices: CoinPrices; handleChangeTheme: (data: ChangeThemeData) => void; + handleChangeMode: (data: ChangeModeData) => void; setBackgroundStorage: (data: BackgroundStorage) => Promise; getBackgroundStorage: () => Promise; clearBackgroundStorage: typeof clearBackgroundStorage; diff --git a/apps/browser-extension-wallet/src/lib/scripts/types/storage.ts b/apps/browser-extension-wallet/src/lib/scripts/types/storage.ts index e72cebe3b..5291b304c 100644 --- a/apps/browser-extension-wallet/src/lib/scripts/types/storage.ts +++ b/apps/browser-extension-wallet/src/lib/scripts/types/storage.ts @@ -31,6 +31,10 @@ export interface BackgroundStorage { usePersistentUserId?: boolean; featureFlags?: Record>; customSubmitTxUrl?: string; + namiMigration?: { + completed: boolean; + mode: 'lace' | 'nami'; + }; optedInBeta?: boolean; } diff --git a/apps/browser-extension-wallet/src/popup.tsx b/apps/browser-extension-wallet/src/popup.tsx index f2d74ea4d..a421f0ef2 100644 --- a/apps/browser-extension-wallet/src/popup.tsx +++ b/apps/browser-extension-wallet/src/popup.tsx @@ -1,4 +1,4 @@ -import * as React from 'react'; +import React, { useEffect, useState } from 'react'; import * as ReactDOM from 'react-dom'; import { HashRouter } from 'react-router-dom'; import { PopupView } from '@routes'; @@ -22,40 +22,65 @@ import { PostHogClientProvider } from '@providers/PostHogClientProvider'; import { ExperimentsProvider } from '@providers/ExperimentsProvider/context'; import { BackgroundPageProvider } from '@providers/BackgroundPageProvider'; import { AddressesDiscoveryOverlay } from 'components/AddressesDiscoveryOverlay'; +import { NamiPopup } from './views/nami-mode'; +import { getBackgroundStorage } from '@lib/scripts/background/storage'; +import { storage } from 'webextension-polyfill'; -const App = (): React.ReactElement => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -); +const App = (): React.ReactElement => { + const [mode, setMode] = useState<'lace' | 'nami'>(); + storage.onChanged.addListener((changes) => { + const oldModeValue = changes.BACKGROUND_STORAGE.oldValue?.namiMigration; + const newModeValue = changes.BACKGROUND_STORAGE.newValue?.namiMigration; + if (oldModeValue?.mode !== newModeValue?.mode) { + setMode(newModeValue); + // Force back to original routing + window.location.hash = '#'; + } + }); + + useEffect(() => { + const getWalletMode = async () => { + const { namiMigration } = await getBackgroundStorage(); + setMode(namiMigration?.mode || 'lace'); + }; + + getWalletMode(); + }, [mode]); + + return ( + + + + + + + + + + + + + + + + {mode === 'nami' ? : } + + + + + + + + + + + + + + + + ); +}; const mountNode = document.querySelector('#lace-popup'); ReactDOM.render(, mountNode); diff --git a/apps/browser-extension-wallet/src/providers/AnalyticsProvider/analyticsTracker/AnalyticsTracker.ts b/apps/browser-extension-wallet/src/providers/AnalyticsProvider/analyticsTracker/AnalyticsTracker.ts index a4e2b08f9..a4be85a3d 100644 --- a/apps/browser-extension-wallet/src/providers/AnalyticsProvider/analyticsTracker/AnalyticsTracker.ts +++ b/apps/browser-extension-wallet/src/providers/AnalyticsProvider/analyticsTracker/AnalyticsTracker.ts @@ -14,9 +14,15 @@ import { } from '../../PostHogClientProvider/client'; import { getUserIdService } from '@providers/AnalyticsProvider/getUserIdService'; import { UserIdService } from '@lib/scripts/types'; -import { PostHogMultiWalletAction, PostHogOnboardingAction } from './events'; +import { PostHogMultiWalletAction, PostHogOnboardingAction, PostHogNamiMigrationAction } from './events'; +import { NamiModeActions } from '@lace/nami'; -type Action = PostHogAction | PostHogMultiWalletAction | PostHogOnboardingAction; +export type Action = + | PostHogAction + | PostHogMultiWalletAction + | PostHogOnboardingAction + | PostHogNamiMigrationAction + | NamiModeActions; interface AnalyticsTrackerArgs { postHogClient?: PostHogClient; diff --git a/apps/browser-extension-wallet/src/providers/AnalyticsProvider/analyticsTracker/events/index.ts b/apps/browser-extension-wallet/src/providers/AnalyticsProvider/analyticsTracker/events/index.ts index 8345e2a50..c7f6b3012 100644 --- a/apps/browser-extension-wallet/src/providers/AnalyticsProvider/analyticsTracker/events/index.ts +++ b/apps/browser-extension-wallet/src/providers/AnalyticsProvider/analyticsTracker/events/index.ts @@ -2,3 +2,5 @@ export { postHogMultiWalletActions } from './multi-wallet'; export type { PostHogMultiWalletAction, PostHogMultiWalletActions } from './multi-wallet'; export { postHogOnboardingActions } from './onboarding'; export type { PostHogOnboardingAction, PostHogOnboardingActions } from './onboarding'; +export { postHogNamiMigrationActions } from './nami-migration'; +export type { PostHogNamiMigrationAction, PostHogNamiMigrationActions } from './nami-migration'; diff --git a/apps/browser-extension-wallet/src/providers/AnalyticsProvider/analyticsTracker/events/nami-migration.ts b/apps/browser-extension-wallet/src/providers/AnalyticsProvider/analyticsTracker/events/nami-migration.ts new file mode 100644 index 000000000..9b45bbb46 --- /dev/null +++ b/apps/browser-extension-wallet/src/providers/AnalyticsProvider/analyticsTracker/events/nami-migration.ts @@ -0,0 +1,21 @@ +import { ExtractActionsAsUnion } from './types'; + +const createEvent = (eventSuffix: E) => `nami tool | lace | ${eventSuffix}` as const; + +export const postHogNamiMigrationActions = { + onboarding: { + OPEN: createEvent('open'), + ANALYTICS_AGREE_CLICK: createEvent('analytics banner | agree | click'), + INTRODUCTION_STEP: createEvent('introduction step | pageview'), + CUSTOMIZE_STEP: createEvent('customize step | pageview'), + CUSTOMIZE_STEP_LACE_TAB_CLICK: createEvent('customize step | lace tab | click'), + CUSTOMIZE_STEP_NAMI_TAB_CLICK: createEvent('customize step | nami tab | click'), + CUSTOMIZE_STEP_LACE_MODE_CLICK: createEvent('customize step | lace mode | click'), + CUSTOMIZE_STEP_NAMI_MODE_CLICK: createEvent('customize step | nami mode | click'), + MIGRATION_COMPLETE: createEvent('migration successful'), + MIGRATION_ERROR: createEvent('migration error') + } +}; + +export type PostHogNamiMigrationActions = typeof postHogNamiMigrationActions; +export type PostHogNamiMigrationAction = ExtractActionsAsUnion; diff --git a/apps/browser-extension-wallet/src/providers/ExperimentsProvider/config.ts b/apps/browser-extension-wallet/src/providers/ExperimentsProvider/config.ts index 6a86d438d..c6739c933 100644 --- a/apps/browser-extension-wallet/src/providers/ExperimentsProvider/config.ts +++ b/apps/browser-extension-wallet/src/providers/ExperimentsProvider/config.ts @@ -3,6 +3,7 @@ import { ExperimentName, ExperimentsConfig, FallbackConfiguration } from './type export const getDefaultFeatureFlags = (): FallbackConfiguration => ({ [ExperimentName.CREATE_PAPER_WALLET]: false, [ExperimentName.RESTORE_PAPER_WALLET]: false, + [ExperimentName.USE_SWITCH_TO_NAMI_MODE]: false, [ExperimentName.SHARED_WALLETS]: false }); @@ -15,6 +16,10 @@ export const experiments: ExperimentsConfig = { value: false, default: false }, + [ExperimentName.USE_SWITCH_TO_NAMI_MODE]: { + value: false, + default: false + }, [ExperimentName.SHARED_WALLETS]: { value: false, default: false diff --git a/apps/browser-extension-wallet/src/providers/ExperimentsProvider/types.ts b/apps/browser-extension-wallet/src/providers/ExperimentsProvider/types.ts index f8fb87c93..2f467fb8e 100644 --- a/apps/browser-extension-wallet/src/providers/ExperimentsProvider/types.ts +++ b/apps/browser-extension-wallet/src/providers/ExperimentsProvider/types.ts @@ -8,6 +8,7 @@ export enum ExperimentsConfigStatus { export enum ExperimentName { CREATE_PAPER_WALLET = 'create-paper-wallet', RESTORE_PAPER_WALLET = 'restore-paper-wallet', + USE_SWITCH_TO_NAMI_MODE = 'use-switch-to-nami-mode', SHARED_WALLETS = 'shared-wallets' } diff --git a/apps/browser-extension-wallet/src/providers/PostHogClientProvider/client/PostHogClient.ts b/apps/browser-extension-wallet/src/providers/PostHogClientProvider/client/PostHogClient.ts index ebe288780..876ce7d9d 100644 --- a/apps/browser-extension-wallet/src/providers/PostHogClientProvider/client/PostHogClient.ts +++ b/apps/browser-extension-wallet/src/providers/PostHogClientProvider/client/PostHogClient.ts @@ -25,7 +25,7 @@ import { ExperimentName } from '@providers/ExperimentsProvider/types'; import { BehaviorSubject, distinctUntilChanged, Observable, Subscription } from 'rxjs'; import { PostHogAction, PostHogProperties } from '@lace/common'; -type FeatureFlag = 'create-paper-wallet' | 'restore-paper-wallet' | 'shared-wallets'; +type FeatureFlag = 'create-paper-wallet' | 'restore-paper-wallet' | 'shared-wallets' | 'use-switch-to-nami-mode'; type FeatureFlags = { [key in FeatureFlag]: boolean; diff --git a/apps/browser-extension-wallet/src/providers/ThemeProvider/context.tsx b/apps/browser-extension-wallet/src/providers/ThemeProvider/context.tsx index 5d2ddc795..334bd895a 100644 --- a/apps/browser-extension-wallet/src/providers/ThemeProvider/context.tsx +++ b/apps/browser-extension-wallet/src/providers/ThemeProvider/context.tsx @@ -43,6 +43,8 @@ export const ThemeProvider = ({ children, customTheme, defaultThemeName }: Theme setTheme(themes[name]); // save on local storage localStorage.setItem('mode', name); + // chakra-ui related theme settings + localStorage.setItem('chakra-ui-color-mode', name); // set css values for chosen theme document.documentElement.dataset.theme = name; }, diff --git a/apps/browser-extension-wallet/src/routes/wallet-paths.ts b/apps/browser-extension-wallet/src/routes/wallet-paths.ts index 4fffb528e..dae36fb23 100644 --- a/apps/browser-extension-wallet/src/routes/wallet-paths.ts +++ b/apps/browser-extension-wallet/src/routes/wallet-paths.ts @@ -33,6 +33,13 @@ export const walletRoutePaths = { generateKeys: '/shared-wallet/generate-key', create: '/shared-wallet/create', import: '/shared-wallet/import' + }, + namiMigration: { + root: '/nami/migration', + activating: '/nami/migration/activating', + welcome: '/nami/migration/welcome', + customize: '/nami/migration/customize', + allDone: '/nami/migration/all-done' } }; diff --git a/apps/browser-extension-wallet/src/stores/slices/activity-detail-slice.ts b/apps/browser-extension-wallet/src/stores/slices/activity-detail-slice.ts index 0bf9aa0f3..92a65ea86 100644 --- a/apps/browser-extension-wallet/src/stores/slices/activity-detail-slice.ts +++ b/apps/browser-extension-wallet/src/stores/slices/activity-detail-slice.ts @@ -82,7 +82,10 @@ const shouldIncludeFee = ( ); }; -const getPoolInfos = async (poolIds: Wallet.Cardano.PoolId[], stakePoolProvider: Wallet.StakePoolProvider) => { +export const getPoolInfos = async ( + poolIds: Wallet.Cardano.PoolId[], + stakePoolProvider: Wallet.StakePoolProvider +): Promise => { const filters: Wallet.QueryStakePoolsArgs = { filters: { identifier: { diff --git a/apps/browser-extension-wallet/src/styles/index.scss b/apps/browser-extension-wallet/src/styles/index.scss index 6e0f30faf..5b89f108d 100644 --- a/apps/browser-extension-wallet/src/styles/index.scss +++ b/apps/browser-extension-wallet/src/styles/index.scss @@ -11,6 +11,15 @@ } } +#lace-popup-body:has(#nami-mode) { + max-width: 400px; + min-width: 400px; +} + +#lace-popup:has(#nami-mode) { + width: 400px; +} + // TODO: confirm if we need to animate the transition between modes // $color-related-props: background-color, background, border, border-bottom-color, border-color, border-left-color, // border-right-color, border-top-color, box-shadow, color, column-rule, column-rule-color, filter, opacity, diff --git a/apps/browser-extension-wallet/src/typings/mp4.d.ts b/apps/browser-extension-wallet/src/typings/mp4.d.ts new file mode 100644 index 000000000..8a6df7155 --- /dev/null +++ b/apps/browser-extension-wallet/src/typings/mp4.d.ts @@ -0,0 +1,4 @@ +declare module '*.mp4' { + const value: string; + export default value; +} diff --git a/apps/browser-extension-wallet/src/views/browser-view/components/Lock/Lock.tsx b/apps/browser-extension-wallet/src/views/browser-view/components/Lock/Lock.tsx index 23af5c07d..bb92ae1a5 100644 --- a/apps/browser-extension-wallet/src/views/browser-view/components/Lock/Lock.tsx +++ b/apps/browser-extension-wallet/src/views/browser-view/components/Lock/Lock.tsx @@ -11,7 +11,13 @@ import { useTheme } from '@providers/ThemeProvider/context'; const { Text } = Typography; -export const Lock = (): React.ReactElement => { +type Props = { + message?: string; + description?: string; + icon?: string; +}; + +export const Lock = ({ message, description, icon }: Props): React.ReactElement => { const { t } = useTranslation(); const { theme } = useTheme(); const openExternalLink = useExternalLinkOpener(); @@ -33,12 +39,12 @@ export const Lock = (): React.ReactElement => {
- LACE + LACE - {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' ? ( + <> + + + + + 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 => ( + + wallet image + + {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" /> + + + Arrow + + + } 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); + }} + /> + + + + + + + + + ); +}; 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 ( + + + + + + +