From 625d27ee6c21749f9b3c55e2b2ef887a6df83967 Mon Sep 17 00:00:00 2001 From: Gohlub <62673775+Gohlub@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:21:11 -0500 Subject: [PATCH 1/6] UI for v0-to-v1 migration, WIP --- extension/popup/Router.tsx | 12 + extension/popup/assets/transferv0_icon.svg | 3 + extension/popup/assets/wallet-icon-yellow.svg | 3 + extension/popup/screens/SettingsScreen.tsx | 5 + .../popup/screens/V0MigrationIntroScreen.tsx | 65 ++++ .../popup/screens/V0MigrationReviewScreen.tsx | 99 ++++++ .../popup/screens/V0MigrationSetupScreen.tsx | 317 ++++++++++++++++++ .../screens/V0MigrationSubmittedScreen.tsx | 72 ++++ extension/popup/store.ts | 51 +++ 9 files changed, 627 insertions(+) create mode 100644 extension/popup/assets/transferv0_icon.svg create mode 100644 extension/popup/assets/wallet-icon-yellow.svg create mode 100644 extension/popup/screens/V0MigrationIntroScreen.tsx create mode 100644 extension/popup/screens/V0MigrationReviewScreen.tsx create mode 100644 extension/popup/screens/V0MigrationSetupScreen.tsx create mode 100644 extension/popup/screens/V0MigrationSubmittedScreen.tsx diff --git a/extension/popup/Router.tsx b/extension/popup/Router.tsx index 7069c2c..55f18df 100644 --- a/extension/popup/Router.tsx +++ b/extension/popup/Router.tsx @@ -36,6 +36,10 @@ import { WalletSettingsScreen } from './screens/WalletSettingsScreen'; import { WalletStylingScreen } from './screens/WalletStylingScreen'; import { AboutScreen } from './screens/AboutScreen'; import { RecoveryPhraseScreen } from './screens/RecoveryPhraseScreen'; +import { V0MigrationIntroScreen } from './screens/V0MigrationIntroScreen'; +import { V0MigrationSetupScreen } from './screens/V0MigrationSetupScreen'; +import { V0MigrationReviewScreen } from './screens/V0MigrationReviewScreen'; +import { V0MigrationSubmittedScreen } from './screens/V0MigrationSubmittedScreen'; export function Router() { const { currentScreen } = useStore(); @@ -85,6 +89,14 @@ export function Router() { return ; case 'recovery-phrase': return ; + case 'v0-migration-intro': + return ; + case 'v0-migration-setup': + return ; + case 'v0-migration-review': + return ; + case 'v0-migration-submitted': + return ; // Transactions case 'send': diff --git a/extension/popup/assets/transferv0_icon.svg b/extension/popup/assets/transferv0_icon.svg new file mode 100644 index 0000000..de28f13 --- /dev/null +++ b/extension/popup/assets/transferv0_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/extension/popup/assets/wallet-icon-yellow.svg b/extension/popup/assets/wallet-icon-yellow.svg new file mode 100644 index 0000000..a7d9356 --- /dev/null +++ b/extension/popup/assets/wallet-icon-yellow.svg @@ -0,0 +1,3 @@ + + + diff --git a/extension/popup/screens/SettingsScreen.tsx b/extension/popup/screens/SettingsScreen.tsx index 10e102c..e15ab17 100644 --- a/extension/popup/screens/SettingsScreen.tsx +++ b/extension/popup/screens/SettingsScreen.tsx @@ -4,6 +4,7 @@ import ThemeIcon from '../assets/theme-icon.svg'; import RpcSettingsIcon from '../assets/rpc-settings-icon.svg'; import KeyIcon from '../assets/key-icon.svg'; import ClockIcon from '../assets/clock-icon.svg'; +import TransferV0Icon from '../assets/transferv0_icon.svg'; import { CloseIcon } from '../components/icons/CloseIcon'; import { ChevronRightIcon } from '../components/icons/ChevronRightIcon'; import AboutIcon from '../assets/settings-gear-icon.svg'; @@ -30,6 +31,9 @@ export function SettingsScreen() { function handleAbout() { navigate('about'); } + function handleTransferV0() { + navigate('v0-migration-intro'); + } const Row = ({ icon, label, onClick }: { icon: string; label: string; onClick: () => void }) => ( +

Transfer v0 funds

+
+ + +
+
+ +

v0 Funds Migration

+

+ Transfer your balance from V0 to V1 +

+
+ +
+

+ The network has upgraded. If your wallet was created before October 25, 2025{' '} + (block 39,000), your funds need to be migrated to remain accessible on the current + network. +

+

+ This process transfers your full balance from your v0 wallet to v1. It only takes a + moment. +

+
+
+ +
+ +
+
+ ); +} + diff --git a/extension/popup/screens/V0MigrationReviewScreen.tsx b/extension/popup/screens/V0MigrationReviewScreen.tsx new file mode 100644 index 0000000..a40f101 --- /dev/null +++ b/extension/popup/screens/V0MigrationReviewScreen.tsx @@ -0,0 +1,99 @@ +import { useStore } from '../store'; +import { ChevronLeftIcon } from '../components/icons/ChevronLeftIcon'; +import { AccountIcon } from '../components/AccountIcon'; +import WalletIconYellow from '../assets/wallet-icon-yellow.svg'; +import { truncateAddress } from '../utils/format'; + +export function V0MigrationReviewScreen() { + const { navigate, wallet, v0MigrationDraft, priceUsd } = useStore(); + const destinationWallet = + wallet.accounts.find(account => account.index === v0MigrationDraft.destinationWalletIndex) || null; + const amount = v0MigrationDraft.v0BalanceNock; + const usdAmount = amount * priceUsd; + + return ( +
+
+ +

Review Transfer

+
+
+ +
+
+ +
+ {amount.toLocaleString('en-US')} NOCK +
+
+ ${usdAmount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +
+
+ +
+
+
+
+ vØ +
+
v0 Wallet
+
+ Imported seed +
+
+ +
+ › +
+ +
+
+ +
+
{destinationWallet?.name || 'Wallet'}
+
+ {truncateAddress(destinationWallet?.address)} +
+
+
+
+ +
+ Network fee + {v0MigrationDraft.feeNock} NOCK +
+
+ +
+
+ + +
+
+
+ ); +} + diff --git a/extension/popup/screens/V0MigrationSetupScreen.tsx b/extension/popup/screens/V0MigrationSetupScreen.tsx new file mode 100644 index 0000000..0ce095a --- /dev/null +++ b/extension/popup/screens/V0MigrationSetupScreen.tsx @@ -0,0 +1,317 @@ +import { useEffect, useRef, useState } from 'react'; +import { useStore } from '../store'; +import { ChevronLeftIcon } from '../components/icons/ChevronLeftIcon'; +import { AccountIcon } from '../components/AccountIcon'; +import LockIconYellow from '../assets/lock-icon-yellow.svg'; +import WalletIconYellow from '../assets/wallet-icon-yellow.svg'; +import ArrowUpIcon from '../assets/arrow-up-icon.svg'; +import ArrowDownIcon from '../assets/arrow-down-icon.svg'; +import ChevronDownIconAsset from '../assets/wallet-dropdown-arrow.svg'; +import InfoIconAsset from '../assets/info-icon.svg'; +import { truncateAddress } from '../utils/format'; +import { PlusIcon } from '../components/icons/PlusIcon'; + +const WORD_COUNT = 24; + +export function V0MigrationSetupScreen() { + const { navigate, wallet, v0MigrationDraft, setV0MigrationDraft } = useStore(); + const visibleAccounts = wallet.accounts.filter(account => !account.hidden); + const [showWalletPicker, setShowWalletPicker] = useState(false); + const fileInputRef = useRef(null); + + useEffect(() => { + if (v0MigrationDraft.destinationWalletIndex === null && visibleAccounts.length > 0) { + setV0MigrationDraft({ destinationWalletIndex: visibleAccounts[0].index }); + } + }, [v0MigrationDraft.destinationWalletIndex, visibleAccounts, setV0MigrationDraft]); + + const destinationWallet = + visibleAccounts.find(account => account.index === v0MigrationDraft.destinationWalletIndex) || + visibleAccounts[0] || + null; + const hasInsufficientFunds = v0MigrationDraft.v0BalanceNock <= v0MigrationDraft.feeNock; + + async function handlePasteAll() { + try { + const raw = await navigator.clipboard.readText(); + const pasted = raw.trim().toLowerCase().split(/\s+/).slice(0, WORD_COUNT); + const next = Array(WORD_COUNT).fill(''); + pasted.forEach((word, index) => { + next[index] = word; + }); + setV0MigrationDraft({ seedWords: next }); + } catch (error) { + console.warn('Paste failed:', error); + } + } + + function handleWordChange(index: number, value: string) { + const next = [...v0MigrationDraft.seedWords]; + next[index] = value.trim().toLowerCase(); + setV0MigrationDraft({ seedWords: next }); + } + + function handleFilePick(event: React.ChangeEvent) { + const file = event.target.files?.[0]; + if (!file) return; + setV0MigrationDraft({ keyfileName: file.name }); + } + + return ( +
+
+ +

Transfer v0 funds

+
+
+ +
+
+ +

+ Enter your 24-word recovery phrase. +
+ Paste into first field to auto-fill all words. +

+
+ +
+ + + +
+ + {v0MigrationDraft.keyfileName && ( +
+ Keyfile: {v0MigrationDraft.keyfileName} +
+ )} + +
+ {Array.from({ length: WORD_COUNT }).map((_, i) => ( +
+
+ {i + 1} +
+ handleWordChange(i, e.target.value)} + placeholder="word" + className="flex-1 bg-transparent outline-none text-[16px]" + /> +
+ ))} +
+ +
+ + +
+ +
+ +

Pick a wallet to receive your v0 funds.

+
+ +
+
v0 Wallet Balance
+
+ {v0MigrationDraft.v0BalanceNock.toLocaleString('en-US')} +
+
+ +
+
+ +
+
+ +
+
Receiving wallet
+ +
+ + {hasInsufficientFunds && ( +
+ Insufficient funds to cover transaction fee. +
+ )} + +
+
+ Fee + +
+
+ {v0MigrationDraft.feeNock} NOCK +
+
+
+ +
+
+ + +
+
+ + {showWalletPicker && ( +
+
+
+
+

Select wallet

+ +
+ + {visibleAccounts.map((account, index) => { + const isSelected = account.index === v0MigrationDraft.destinationWalletIndex; + const balance = wallet.accountBalances[account.address] ?? 0; + return ( + + ); + })} + + +
+
+ )} +
+ ); +} + diff --git a/extension/popup/screens/V0MigrationSubmittedScreen.tsx b/extension/popup/screens/V0MigrationSubmittedScreen.tsx new file mode 100644 index 0000000..5ec4c9b --- /dev/null +++ b/extension/popup/screens/V0MigrationSubmittedScreen.tsx @@ -0,0 +1,72 @@ +import { useStore } from '../store'; +import { ChevronLeftIcon } from '../components/icons/ChevronLeftIcon'; +import { SendPaperPlaneIcon } from '../components/icons/SendPaperPlaneIcon'; +import { PlusIcon } from '../components/icons/PlusIcon'; + +export function V0MigrationSubmittedScreen() { + const { navigate, v0MigrationDraft, resetV0MigrationDraft } = useStore(); + + function handleBackToOverview() { + resetV0MigrationDraft(); + navigate('home'); + } + + return ( +
+
+ +

Submitted

+
+
+ +
+
+
+ +
+

+ Your transaction +
+ was submitted +

+

+ Check the transaction activity below +

+
+ +
+
You sent
+
+
{v0MigrationDraft.v0BalanceNock.toLocaleString()} NOCK
+
+
+ + +
+ +
+ +
+
+ ); +} + diff --git a/extension/popup/store.ts b/extension/popup/store.ts index c213579..297f825 100644 --- a/extension/popup/store.ts +++ b/extension/popup/store.ts @@ -51,6 +51,10 @@ export type Screen = | 'send-submitted' | 'sent' | 'receive' + | 'v0-migration-intro' + | 'v0-migration-setup' + | 'v0-migration-review' + | 'v0-migration-submitted' | 'tx-details' // Approval screens @@ -102,6 +106,25 @@ interface AppStore { lastTransaction: TransactionDetails | null; setLastTransaction: (transaction: TransactionDetails | null) => void; + // UI-only draft state for transfering v0 funds flow + v0MigrationDraft: { + v0BalanceNock: number; + feeNock: number; + destinationWalletIndex: number | null; + seedWords: string[]; + keyfileName?: string; + }; + setV0MigrationDraft: ( + value: Partial<{ + v0BalanceNock: number; + feeNock: number; + destinationWalletIndex: number | null; + seedWords: string[]; + keyfileName?: string; + }> + ) => void; + resetV0MigrationDraft: () => void; + // Pending connect request (for showing approval screen) pendingConnectRequest: ConnectRequest | null; setPendingConnectRequest: (request: ConnectRequest | null) => void; @@ -178,6 +201,13 @@ export const useStore = create((set, get) => ({ onboardingMnemonic: null, lastTransaction: null, + v0MigrationDraft: { + v0BalanceNock: 2500, + feeNock: 59, + destinationWalletIndex: null, + seedWords: Array(24).fill(''), + keyfileName: undefined, + }, pendingConnectRequest: null, pendingSignRequest: null, pendingSignRawTxRequest: null, @@ -239,6 +269,27 @@ export const useStore = create((set, get) => ({ set({ lastTransaction: transaction }); }, + setV0MigrationDraft: value => { + set(state => ({ + v0MigrationDraft: { + ...state.v0MigrationDraft, + ...value, + }, + })); + }, + + resetV0MigrationDraft: () => { + set({ + v0MigrationDraft: { + v0BalanceNock: 2500, + feeNock: 59, + destinationWalletIndex: null, + seedWords: Array(24).fill(''), + keyfileName: undefined, + }, + }); + }, + // Set pending connect request setPendingConnectRequest: (request: ConnectRequest | null) => { set({ pendingConnectRequest: request }); From ea2e60ca889a9f2fa977ffa1de6cc8f2aa0b1065 Mon Sep 17 00:00:00 2001 From: Gohlub <62673775+Gohlub@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:39:32 -0500 Subject: [PATCH 2/6] styling updates --- extension/popup/Router.tsx | 3 + .../popup/screens/V0MigrationFundsScreen.tsx | 239 ++++++++ .../popup/screens/V0MigrationIntroScreen.tsx | 55 +- .../popup/screens/V0MigrationReviewScreen.tsx | 18 +- .../popup/screens/V0MigrationSetupScreen.tsx | 514 +++++++++--------- .../screens/V0MigrationSubmittedScreen.tsx | 16 +- extension/popup/store.ts | 1 + 7 files changed, 551 insertions(+), 295 deletions(-) create mode 100644 extension/popup/screens/V0MigrationFundsScreen.tsx diff --git a/extension/popup/Router.tsx b/extension/popup/Router.tsx index 55f18df..9e0811a 100644 --- a/extension/popup/Router.tsx +++ b/extension/popup/Router.tsx @@ -38,6 +38,7 @@ import { AboutScreen } from './screens/AboutScreen'; import { RecoveryPhraseScreen } from './screens/RecoveryPhraseScreen'; import { V0MigrationIntroScreen } from './screens/V0MigrationIntroScreen'; import { V0MigrationSetupScreen } from './screens/V0MigrationSetupScreen'; +import { V0MigrationFundsScreen } from './screens/V0MigrationFundsScreen'; import { V0MigrationReviewScreen } from './screens/V0MigrationReviewScreen'; import { V0MigrationSubmittedScreen } from './screens/V0MigrationSubmittedScreen'; @@ -93,6 +94,8 @@ export function Router() { return ; case 'v0-migration-setup': return ; + case 'v0-migration-funds': + return ; case 'v0-migration-review': return ; case 'v0-migration-submitted': diff --git a/extension/popup/screens/V0MigrationFundsScreen.tsx b/extension/popup/screens/V0MigrationFundsScreen.tsx new file mode 100644 index 0000000..a9f57a5 --- /dev/null +++ b/extension/popup/screens/V0MigrationFundsScreen.tsx @@ -0,0 +1,239 @@ +import { useEffect, useState } from 'react'; +import { useStore } from '../store'; +import { ChevronLeftIcon } from '../components/icons/ChevronLeftIcon'; +import { AccountIcon } from '../components/AccountIcon'; +import WalletIconYellow from '../assets/wallet-icon-yellow.svg'; +import ArrowDownIcon from '../assets/arrow-down-icon.svg'; +import ChevronDownIconAsset from '../assets/wallet-dropdown-arrow.svg'; +import InfoIconAsset from '../assets/info-icon.svg'; +import { truncateAddress } from '../utils/format'; +import { PlusIcon } from '../components/icons/PlusIcon'; + +export function V0MigrationFundsScreen() { + const { navigate, wallet, v0MigrationDraft, setV0MigrationDraft } = useStore(); + const visibleAccounts = wallet.accounts.filter(account => !account.hidden); + const [showWalletPicker, setShowWalletPicker] = useState(false); + + useEffect(() => { + if (v0MigrationDraft.destinationWalletIndex === null && visibleAccounts.length > 0) { + setV0MigrationDraft({ destinationWalletIndex: visibleAccounts[0].index }); + } + }, [v0MigrationDraft.destinationWalletIndex, visibleAccounts, setV0MigrationDraft]); + + const destinationWallet = + visibleAccounts.find(account => account.index === v0MigrationDraft.destinationWalletIndex) || + visibleAccounts[0] || + null; + const hasInsufficientFunds = v0MigrationDraft.v0BalanceNock <= v0MigrationDraft.feeNock; + + return ( +
+
+ +

+ Transfer v0 funds +

+
+
+ +
+
+ +

+ Pick a wallet to receive your v0 funds. +

+
+ +
+
v0 Wallet Balance
+
+ {v0MigrationDraft.v0BalanceNock.toLocaleString('en-US')} +
+
+ +
+
+ +
+
+ +
+
+ Receiving wallet +
+ +
+ + {hasInsufficientFunds && ( +
+ Insufficient funds to cover transaction fee. +
+ )} + +
+
+ Fee + +
+
+ {v0MigrationDraft.feeNock} NOCK +
+
+
+ +
+
+ + +
+
+ + {showWalletPicker && ( +
+
+
+
+

Select wallet

+ +
+ + {visibleAccounts.map((account, index) => { + const isSelected = account.index === v0MigrationDraft.destinationWalletIndex; + const balance = wallet.accountBalances[account.address] ?? 0; + return ( + + ); + })} + + +
+
+ )} +
+ ); +} diff --git a/extension/popup/screens/V0MigrationIntroScreen.tsx b/extension/popup/screens/V0MigrationIntroScreen.tsx index f25b36d..b0e4366 100644 --- a/extension/popup/screens/V0MigrationIntroScreen.tsx +++ b/extension/popup/screens/V0MigrationIntroScreen.tsx @@ -15,46 +15,45 @@ export function V0MigrationIntroScreen() { } return ( -
-
- -

Transfer v0 funds

-
+

+ Transfer v0 funds +

+
-
-
- -

v0 Funds Migration

-

+

+ + +
+
+ v0 Funds Migration +
+
Transfer your balance from V0 to V1 -

+
-
-

- The network has upgraded. If your wallet was created before October 25, 2025{' '} - (block 39,000), your funds need to be migrated to remain accessible on the current - network. -

-

- This process transfers your full balance from your v0 wallet to v1. It only takes a - moment. -

+
+ The network has upgraded. If your wallet was created before + October 25, 2025 + (block 39,000), your funds need to be migrated to remain accessible on the current network. +
+
+ This process transfers your full balance from your v0 wallet to v1. It only takes a moment.
-
+
diff --git a/extension/popup/screens/V0MigrationReviewScreen.tsx b/extension/popup/screens/V0MigrationReviewScreen.tsx index a40f101..b87c5c3 100644 --- a/extension/popup/screens/V0MigrationReviewScreen.tsx +++ b/extension/popup/screens/V0MigrationReviewScreen.tsx @@ -17,7 +17,7 @@ export function V0MigrationReviewScreen() { style={{ backgroundColor: 'var(--color-bg)', color: 'var(--color-text-primary)' }} >
-

Review Transfer

@@ -27,7 +27,7 @@ export function V0MigrationReviewScreen() {
-
+
{amount.toLocaleString('en-US')} NOCK
@@ -37,8 +37,8 @@ export function V0MigrationReviewScreen() {
-
-
+
+
v0 Wallet
@@ -47,11 +47,11 @@ export function V0MigrationReviewScreen() {
-
+
-
+
- Network fee - {v0MigrationDraft.feeNock} NOCK + Network fee + {v0MigrationDraft.feeNock} NOCK
@@ -77,7 +77,7 @@ export function V0MigrationReviewScreen() {
-

Transfer v0 funds

-
-
- -
-
- -

- Enter your 24-word recovery phrase. -
- Paste into first field to auto-fill all words. -

-
- -
- - - -
- - {v0MigrationDraft.keyfileName && ( -
- Keyfile: {v0MigrationDraft.keyfileName} -
- )} + Transfer v0 funds + +
+
-
- {Array.from({ length: WORD_COUNT }).map((_, i) => ( -
-
- {i + 1} +
+
+
+ {/* Icon and instructions - same as ImportScreen */} +
+
+
- handleWordChange(i, e.target.value)} - placeholder="word" - className="flex-1 bg-transparent outline-none text-[16px]" - /> +

+ Enter your 24-word secret phrase. +
+ Paste into first field to auto-fill all words. +

- ))} -
- -
- - -
- -
- -

Pick a wallet to receive your v0 funds.

-
- -
-
v0 Wallet Balance
-
- {v0MigrationDraft.v0BalanceNock.toLocaleString('en-US')} -
-
-
-
- -
-
+ {/* Or import from keyfile - same as ImportScreen */} + -
-
Receiving wallet
- -
- {hasInsufficientFunds && ( -
- Insufficient funds to cover transaction fee. -
- )} - -
-
- Fee - -
-
- {v0MigrationDraft.feeNock} NOCK +
-
-
-
- - + {/* Bottom buttons */} +
+
+ + +
- {showWalletPicker && ( + {/* Keyfile Import Modal - same as onboarding ImportScreen */} + {showKeyfileImport && (
-
-
-

Select wallet

-
- {visibleAccounts.map((account, index) => { - const isSelected = account.index === v0MigrationDraft.destinationWalletIndex; - const balance = wallet.accountBalances[account.address] ?? 0; - return ( - - ); - })} + {keyfileError && {keyfileError}}
diff --git a/extension/popup/screens/V0MigrationSubmittedScreen.tsx b/extension/popup/screens/V0MigrationSubmittedScreen.tsx index 5ec4c9b..966737d 100644 --- a/extension/popup/screens/V0MigrationSubmittedScreen.tsx +++ b/extension/popup/screens/V0MigrationSubmittedScreen.tsx @@ -24,25 +24,25 @@ export function V0MigrationSubmittedScreen() {
-
+
-

+

Your transaction
was submitted

-

+

Check the transaction activity below

-
You sent
+
You sent
-
{v0MigrationDraft.v0BalanceNock.toLocaleString()} NOCK
+
{v0MigrationDraft.v0BalanceNock.toLocaleString()} NOCK
@@ -51,16 +51,16 @@ export function V0MigrationSubmittedScreen() { className="mt-auto mb-3 w-full rounded-[14px] p-3 flex items-center justify-between" style={{ backgroundColor: 'var(--color-surface-900)' }} > - Activity log + Activity log
-
+
+ + {buildError && {buildError}}
@@ -155,8 +216,8 @@ export function V0MigrationFundsScreen() {
diff --git a/extension/popup/screens/V0MigrationReviewScreen.tsx b/extension/popup/screens/V0MigrationReviewScreen.tsx index b87c5c3..4dea5ce 100644 --- a/extension/popup/screens/V0MigrationReviewScreen.tsx +++ b/extension/popup/screens/V0MigrationReviewScreen.tsx @@ -8,8 +8,9 @@ export function V0MigrationReviewScreen() { const { navigate, wallet, v0MigrationDraft, priceUsd } = useStore(); const destinationWallet = wallet.accounts.find(account => account.index === v0MigrationDraft.destinationWalletIndex) || null; - const amount = v0MigrationDraft.v0BalanceNock; + const amount = v0MigrationDraft.migratedAmountNock ?? v0MigrationDraft.v0BalanceNock; const usdAmount = amount * priceUsd; + const canSend = Boolean(v0MigrationDraft.signRawTxPayload); return (
navigate('v0-migration-submitted')} - className="flex-1 h-12 rounded-[14px] text-[16px] font-medium" + disabled={!canSend} + className="flex-1 h-12 rounded-[14px] text-[16px] font-medium disabled:opacity-50 disabled:cursor-not-allowed" style={{ backgroundColor: 'var(--color-primary)', color: '#000' }} > Send diff --git a/extension/popup/screens/V0MigrationSetupScreen.tsx b/extension/popup/screens/V0MigrationSetupScreen.tsx index 33e0205..b04a72c 100644 --- a/extension/popup/screens/V0MigrationSetupScreen.tsx +++ b/extension/popup/screens/V0MigrationSetupScreen.tsx @@ -5,17 +5,20 @@ import { Alert } from '../components/Alert'; import lockIcon from '../assets/lock-icon.svg'; import { importKeyfile, type Keyfile } from '../../shared/keyfile'; import { UI_CONSTANTS } from '../../shared/constants'; +import { queryV0BalanceFromMnemonic } from '../../shared/v0-migration'; const WORD_COUNT = 24; export function V0MigrationSetupScreen() { - const { navigate, v0MigrationDraft, setV0MigrationDraft } = useStore(); + const { navigate, setV0MigrationDraft } = useStore(); const [showKeyfileImport, setShowKeyfileImport] = useState(false); const [keyfileError, setKeyfileError] = useState(''); + const [discoverError, setDiscoverError] = useState(''); + const [isDiscovering, setIsDiscovering] = useState(false); + const [words, setWords] = useState(Array(WORD_COUNT).fill('')); const fileInputRef = useRef(null); const inputRefs = useRef<(HTMLInputElement | null)[]>([]); - const words = v0MigrationDraft.seedWords; const canContinue = words.length === WORD_COUNT && words.every(w => Boolean(w)); async function handlePasteAll() { @@ -26,7 +29,7 @@ export function V0MigrationSetupScreen() { pasted.forEach((word, index) => { next[index] = word; }); - setV0MigrationDraft({ seedWords: next }); + setWords(next); } catch (error) { console.warn('Paste failed:', error); } @@ -34,9 +37,9 @@ export function V0MigrationSetupScreen() { function handleWordChange(index: number, value: string) { const trimmedValue = value.trim().toLowerCase(); - const newWords = [...v0MigrationDraft.seedWords]; + const newWords = [...words]; newWords[index] = trimmedValue; - setV0MigrationDraft({ seedWords: newWords }); + setWords(newWords); if (value.endsWith(' ')) { const nextIndex = index + 1; if (nextIndex < WORD_COUNT) { @@ -55,7 +58,7 @@ export function V0MigrationSetupScreen() { pastedWords.forEach((word, i) => { next[i] = word; }); - setV0MigrationDraft({ seedWords: next }); + setWords(next); } } } @@ -91,7 +94,7 @@ export function V0MigrationSetupScreen() { importedWords.forEach((word, i) => { next[i] = word; }); - setV0MigrationDraft({ seedWords: next }); + setWords(next); setShowKeyfileImport(false); if (fileInputRef.current) fileInputRef.current.value = ''; } catch (err) { @@ -107,6 +110,44 @@ export function V0MigrationSetupScreen() { if (fileInputRef.current) fileInputRef.current.value = ''; } + async function handleContinue() { + if (!canContinue || isDiscovering) return; + + setDiscoverError(''); + setIsDiscovering(true); + try { + const discovery = await queryV0BalanceFromMnemonic(words.join(' ').trim()); + + if (!discovery.v0NotesProtobuf.length) { + throw new Error('No v0 notes found for this recovery phrase.'); + } + + setV0MigrationDraft({ + sourceAddress: discovery.sourceAddress, + sourcePkh: discovery.sourcePkh, + v0NotesProtobuf: discovery.v0NotesProtobuf, + v0BalanceNock: discovery.totalNock, + migratedAmountNock: undefined, + feeNock: 59, + keyfileName: undefined, + signRawTxPayload: undefined, + txId: undefined, + }); + console.log('[V0 Migration] derived query address', { + sourceAddress: discovery.sourceAddress, + sourcePkh: discovery.sourcePkh, + totalNock: discovery.totalNock, + legacyNotesCount: discovery.v0NotesProtobuf.length, + }); + setWords(Array(WORD_COUNT).fill('')); + navigate('v0-migration-funds'); + } catch (err) { + setDiscoverError(err instanceof Error ? err.message : 'Failed to discover v0 notes'); + } finally { + setIsDiscovering(false); + } + } + return (
{/* Header - same as onboarding ImportScreen */} @@ -227,6 +268,8 @@ export function V0MigrationSetupScreen() { > Paste all + + {discoverError && {discoverError}}
@@ -247,8 +290,8 @@ export function V0MigrationSetupScreen() {
diff --git a/extension/popup/screens/V0MigrationSubmittedScreen.tsx b/extension/popup/screens/V0MigrationSubmittedScreen.tsx index 966737d..883242f 100644 --- a/extension/popup/screens/V0MigrationSubmittedScreen.tsx +++ b/extension/popup/screens/V0MigrationSubmittedScreen.tsx @@ -5,6 +5,7 @@ import { PlusIcon } from '../components/icons/PlusIcon'; export function V0MigrationSubmittedScreen() { const { navigate, v0MigrationDraft, resetV0MigrationDraft } = useStore(); + const sentAmount = v0MigrationDraft.migratedAmountNock ?? v0MigrationDraft.v0BalanceNock; function handleBackToOverview() { resetV0MigrationDraft(); @@ -42,7 +43,7 @@ export function V0MigrationSubmittedScreen() {
You sent
-
{v0MigrationDraft.v0BalanceNock.toLocaleString()} NOCK
+
{sentAmount.toLocaleString()} NOCK
diff --git a/extension/popup/store.ts b/extension/popup/store.ts index bfd61d4..10d2b17 100644 --- a/extension/popup/store.ts +++ b/extension/popup/store.ts @@ -110,18 +110,36 @@ interface AppStore { // UI-only draft state for transfering v0 funds flow v0MigrationDraft: { v0BalanceNock: number; + migratedAmountNock?: number; feeNock: number; destinationWalletIndex: number | null; - seedWords: string[]; keyfileName?: string; + sourceAddress?: string; + sourcePkh?: string; + v0NotesProtobuf?: any[]; + signRawTxPayload?: { + rawTx: any; + notes: any[]; + spendConditions: any[]; + }; + txId?: string; }; setV0MigrationDraft: ( value: Partial<{ v0BalanceNock: number; + migratedAmountNock?: number; feeNock: number; destinationWalletIndex: number | null; - seedWords: string[]; keyfileName?: string; + sourceAddress?: string; + sourcePkh?: string; + v0NotesProtobuf?: any[]; + signRawTxPayload?: { + rawTx: any; + notes: any[]; + spendConditions: any[]; + }; + txId?: string; }> ) => void; resetV0MigrationDraft: () => void; @@ -204,10 +222,15 @@ export const useStore = create((set, get) => ({ lastTransaction: null, v0MigrationDraft: { v0BalanceNock: 2500, + migratedAmountNock: undefined, feeNock: 59, destinationWalletIndex: null, - seedWords: Array(24).fill(''), keyfileName: undefined, + sourceAddress: undefined, + sourcePkh: undefined, + v0NotesProtobuf: undefined, + signRawTxPayload: undefined, + txId: undefined, }, pendingConnectRequest: null, pendingSignRequest: null, @@ -283,10 +306,15 @@ export const useStore = create((set, get) => ({ set({ v0MigrationDraft: { v0BalanceNock: 2500, + migratedAmountNock: undefined, feeNock: 59, destinationWalletIndex: null, - seedWords: Array(24).fill(''), keyfileName: undefined, + sourceAddress: undefined, + sourcePkh: undefined, + v0NotesProtobuf: undefined, + signRawTxPayload: undefined, + txId: undefined, }, }); }, diff --git a/extension/shared/v0-migration.ts b/extension/shared/v0-migration.ts new file mode 100644 index 0000000..7f4aed1 --- /dev/null +++ b/extension/shared/v0-migration.ts @@ -0,0 +1,189 @@ +import { base58 } from '@scure/base'; +import { NOCK_TO_NICKS, RPC_ENDPOINT } from './constants'; +import { ensureWasmInitialized } from './wasm-utils'; +import { wasm } from './sdk-wasm'; +import type { TxEngineSettings } from '@nockbox/iris-sdk/wasm'; + +const DEFAULT_FEE_PER_WORD = '32768'; +const TARGET_NOTE_NOCK = 300; +const MIGRATION_AMOUNT_NOCK = 200; +const TARGET_NOTE_NICKS = BigInt(TARGET_NOTE_NOCK * NOCK_TO_NICKS); +const MIGRATION_AMOUNT_NICKS = BigInt(MIGRATION_AMOUNT_NOCK * NOCK_TO_NICKS); + +function isNoteV0(note: unknown): note is any { + return Boolean(note && typeof note === 'object' && 'inner' in note && 'sig' in note && 'source' in note); +} + +export interface V0DiscoveryResult { + sourceAddress: string; + sourcePkh: string; + v0NotesProtobuf: any[]; + totalNicks: string; + totalNock: number; +} + +export interface BuiltV0MigrationResult { + txId: string; + feeNicks: string; + feeNock: number; + migratedNicks: string; + migratedNock: number; + selectedNoteNicks: string; + selectedNoteNock: number; + signRawTxPayload: { + rawTx: any; + notes: any[]; + spendConditions: any[]; + }; +} + +export async function deriveV0AddressFromMnemonic( + mnemonic: string, + passphrase = '' +): Promise<{ sourceAddress: string; sourcePkh: string }> { + await ensureWasmInitialized(); + const master = wasm.deriveMasterKeyFromMnemonic(mnemonic, passphrase); + const publicKey = Uint8Array.from(master.publicKey); + const sourceAddress = base58.encode(publicKey); + const sourcePkh = wasm.hashPublicKey(publicKey); + return { sourceAddress, sourcePkh }; +} + +export async function queryV0BalanceFromMnemonic( + mnemonic: string, + grpcEndpoint = RPC_ENDPOINT +): Promise { + const { sourceAddress, sourcePkh } = await deriveV0AddressFromMnemonic(mnemonic); + return queryV0BalanceByAddress(sourceAddress, grpcEndpoint, sourcePkh); +} + +async function queryV0BalanceByAddress( + sourceAddress: string, + grpcEndpoint = RPC_ENDPOINT, + providedSourcePkh?: string +): Promise { + await ensureWasmInitialized(); + const grpcClient = new wasm.GrpcClient(grpcEndpoint); + const balance = await grpcClient.getBalanceByAddress(sourceAddress); + + const v0NotesProtobuf: any[] = []; + let totalNicks = 0n; + + for (const entry of balance.notes ?? []) { + if (!entry?.note?.note_version || !('Legacy' in entry.note.note_version) || !entry.note) { + continue; + } + + const parsed = wasm.note_from_protobuf(entry.note); + if (!isNoteV0(parsed)) { + continue; + } + + totalNicks += BigInt(parsed.assets); + v0NotesProtobuf.push(entry.note); + } + + let sourcePkh = providedSourcePkh; + if (!sourcePkh) { + const pkBytes = base58.decode(sourceAddress); + if (pkBytes.length !== 97) { + throw new Error('Invalid legacy address: expected bare pubkey (97 bytes)'); + } + sourcePkh = wasm.hashPublicKey(Uint8Array.from(pkBytes)); + } + + return { + sourceAddress, + sourcePkh, + v0NotesProtobuf, + totalNicks: totalNicks.toString(), + totalNock: Number(totalNicks) / NOCK_TO_NICKS, + }; +} + +export async function buildV0MigrationTransactionFromNotes( + v0NotesProtobuf: any[], + sourceV0Pkh: string, + targetV1Pkh: string, + feePerWord: string = DEFAULT_FEE_PER_WORD +): Promise { + await ensureWasmInitialized(); + + if (!v0NotesProtobuf.length) { + throw new Error('No v0 notes available for migration'); + } + + const sourceSpendCondition = [{ Pkh: { m: 1, hashes: [sourceV0Pkh] } }]; + const settings: TxEngineSettings = { + tx_engine_version: 1 as any, + tx_engine_patch: 0 as any, + min_fee: '256', + cost_per_word: feePerWord, + witness_word_div: 1, + }; + const builder = new wasm.TxBuilder(settings); + + const candidates: Array<{ note: any; assets: bigint }> = []; + for (const notePb of v0NotesProtobuf) { + const parsed = wasm.note_from_protobuf(notePb); + if (!isNoteV0(parsed)) continue; + const assets = BigInt(parsed.assets); + if (assets < MIGRATION_AMOUNT_NICKS) continue; + candidates.push({ note: parsed, assets }); + } + + if (!candidates.length) { + throw new Error('No v0 note is large enough to migrate 200 NOCK.'); + } + + let selected = candidates[0]; + for (const candidate of candidates) { + const currentDiff = selected.assets > TARGET_NOTE_NICKS + ? selected.assets - TARGET_NOTE_NICKS + : TARGET_NOTE_NICKS - selected.assets; + const nextDiff = candidate.assets > TARGET_NOTE_NICKS + ? candidate.assets - TARGET_NOTE_NICKS + : TARGET_NOTE_NICKS - candidate.assets; + if (nextDiff < currentDiff) { + selected = candidate; + } + } + + const feeNicksBigInt = selected.assets - MIGRATION_AMOUNT_NICKS; + const recipientDigest = wasm.hex_to_digest(targetV1Pkh); + const refundDigest = wasm.hex_to_digest(targetV1Pkh); + + builder.simpleSpend( + [selected.note], + [sourceSpendCondition], + recipientDigest, + MIGRATION_AMOUNT_NICKS.toString(), + feeNicksBigInt.toString(), + refundDigest, + false + ); + + const feeNicks = feeNicksBigInt.toString(); + const transaction = builder.build(); + const txNotes = builder.allNotes(); + const rawTx = { + version: 1, + id: transaction.id, + spends: transaction.spends, + }; + + return { + txId: transaction.id, + feeNicks, + feeNock: Number(feeNicksBigInt) / NOCK_TO_NICKS, + migratedNicks: MIGRATION_AMOUNT_NICKS.toString(), + migratedNock: Number(MIGRATION_AMOUNT_NICKS) / NOCK_TO_NICKS, + selectedNoteNicks: selected.assets.toString(), + selectedNoteNock: Number(selected.assets) / NOCK_TO_NICKS, + signRawTxPayload: { + rawTx, + notes: (txNotes.notes ?? []).filter((note: unknown) => isNoteV0(note)), + spendConditions: txNotes.spend_conditions ?? [], + }, + }; +} diff --git a/package-lock.json b/package-lock.json index cffd1cb..bd528b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,27 @@ "extraneous": true, "license": "MIT" }, +<<<<<<< HEAD +======= +<<<<<<< HEAD + "../iris-sdk": { + "name": "@nockbox/iris-sdk", + "version": "0.1.1", + "license": "MIT", + "dependencies": { + "@nockbox/iris-wasm": "file:../iris-rs/crates/iris-wasm/pkg", + "@scure/base": "^2.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "prettier": "^3.4.2", + "typescript": "^5.5.3", + "vite": "^5.0.0" + } + }, +======= +>>>>>>> 7206395 (Debug for migration) +>>>>>>> f56b204 (Debug for migration) "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -573,6 +594,7 @@ } }, "node_modules/@nockbox/iris-sdk": { +<<<<<<< HEAD "version": "0.2.0-alpha.4", "resolved": "git+ssh://git@github.com/nockbox/iris-sdk.git#faaf42be65fd833f798e16712e269dbe4d5bc84a", "license": "MIT", @@ -586,6 +608,28 @@ "resolved": "https://registry.npmjs.org/@nockbox/iris-wasm/-/iris-wasm-0.2.0-alpha.6.tgz", "integrity": "sha512-hyVH7PEvPCq0ti25tQ5AkvrINpnyXKnY6n3wKR9tKsbUiR/TJ4o/uc6gAmptf5vBY7d/BL/3HHcwptYFx+H79A==", "license": "MIT" +======= +<<<<<<< HEAD + "resolved": "../iris-sdk", +======= + "version": "0.1.1", + "resolved": "git+ssh://git@github.com/nockbox/iris-sdk.git#418c9cc2e28b74fabed1aa42696e217150c6e116", + "license": "MIT", + "dependencies": { + "@nockbox/iris-wasm": "file:./vendor/iris-wasm", + "@scure/base": "^2.0.0" + } + }, + "node_modules/@nockbox/iris-sdk/vendor/iris-wasm": { + "name": "@nockbox/iris-wasm", + "version": "0.2.0-alpha.3", + "license": "MIT" + }, + "node_modules/@nockbox/iris-wasm": { + "resolved": "node_modules/@nockbox/iris-sdk/vendor/iris-wasm", +>>>>>>> 7206395 (Debug for migration) + "link": true +>>>>>>> f56b204 (Debug for migration) }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", From 9bbf3eeeffcd27ff7b99888b8bf06d2dcc04a4e0 Mon Sep 17 00:00:00 2001 From: Gohlub <62673775+Gohlub@users.noreply.github.com> Date: Wed, 4 Mar 2026 09:35:45 -0500 Subject: [PATCH 4/6] move logic to the SDK --- .../popup/screens/V0MigrationFundsScreen.tsx | 6 - .../popup/screens/V0MigrationSetupScreen.tsx | 2 - extension/popup/store.ts | 2 - extension/shared/v0-migration.ts | 171 ++++-------------- package-lock.json | 3 +- 5 files changed, 35 insertions(+), 149 deletions(-) diff --git a/extension/popup/screens/V0MigrationFundsScreen.tsx b/extension/popup/screens/V0MigrationFundsScreen.tsx index a16f549..78f970a 100644 --- a/extension/popup/screens/V0MigrationFundsScreen.tsx +++ b/extension/popup/screens/V0MigrationFundsScreen.tsx @@ -37,22 +37,16 @@ export function V0MigrationFundsScreen() { setBuildError('No v0 notes loaded. Go back and import your recovery phrase again.'); return; } - if (!v0MigrationDraft.sourcePkh) { - setBuildError('Missing v0 source key data. Go back and import your recovery phrase again.'); - return; - } setBuildError(''); setIsBuilding(true); try { const built = await buildV0MigrationTransactionFromNotes( v0MigrationDraft.v0NotesProtobuf, - v0MigrationDraft.sourcePkh, destinationWallet.address ); console.log('[V0 Migration] transaction build', { sourceAddress: v0MigrationDraft.sourceAddress, - sourcePkh: v0MigrationDraft.sourcePkh, destinationPkh: destinationWallet.address, discoveredV0BalanceNock: v0MigrationDraft.v0BalanceNock, migratedAmountNock: built.migratedNock, diff --git a/extension/popup/screens/V0MigrationSetupScreen.tsx b/extension/popup/screens/V0MigrationSetupScreen.tsx index b04a72c..e4dae5f 100644 --- a/extension/popup/screens/V0MigrationSetupScreen.tsx +++ b/extension/popup/screens/V0MigrationSetupScreen.tsx @@ -124,7 +124,6 @@ export function V0MigrationSetupScreen() { setV0MigrationDraft({ sourceAddress: discovery.sourceAddress, - sourcePkh: discovery.sourcePkh, v0NotesProtobuf: discovery.v0NotesProtobuf, v0BalanceNock: discovery.totalNock, migratedAmountNock: undefined, @@ -135,7 +134,6 @@ export function V0MigrationSetupScreen() { }); console.log('[V0 Migration] derived query address', { sourceAddress: discovery.sourceAddress, - sourcePkh: discovery.sourcePkh, totalNock: discovery.totalNock, legacyNotesCount: discovery.v0NotesProtobuf.length, }); diff --git a/extension/popup/store.ts b/extension/popup/store.ts index 10d2b17..553951b 100644 --- a/extension/popup/store.ts +++ b/extension/popup/store.ts @@ -115,7 +115,6 @@ interface AppStore { destinationWalletIndex: number | null; keyfileName?: string; sourceAddress?: string; - sourcePkh?: string; v0NotesProtobuf?: any[]; signRawTxPayload?: { rawTx: any; @@ -311,7 +310,6 @@ export const useStore = create((set, get) => ({ destinationWalletIndex: null, keyfileName: undefined, sourceAddress: undefined, - sourcePkh: undefined, v0NotesProtobuf: undefined, signRawTxPayload: undefined, txId: undefined, diff --git a/extension/shared/v0-migration.ts b/extension/shared/v0-migration.ts index 7f4aed1..a5a26fd 100644 --- a/extension/shared/v0-migration.ts +++ b/extension/shared/v0-migration.ts @@ -1,22 +1,17 @@ -import { base58 } from '@scure/base'; +/** + * v0-to-v1 migration - delegates to SDK. + */ + import { NOCK_TO_NICKS, RPC_ENDPOINT } from './constants'; import { ensureWasmInitialized } from './wasm-utils'; -import { wasm } from './sdk-wasm'; -import type { TxEngineSettings } from '@nockbox/iris-sdk/wasm'; - -const DEFAULT_FEE_PER_WORD = '32768'; -const TARGET_NOTE_NOCK = 300; -const MIGRATION_AMOUNT_NOCK = 200; -const TARGET_NOTE_NICKS = BigInt(TARGET_NOTE_NOCK * NOCK_TO_NICKS); -const MIGRATION_AMOUNT_NICKS = BigInt(MIGRATION_AMOUNT_NOCK * NOCK_TO_NICKS); - -function isNoteV0(note: unknown): note is any { - return Boolean(note && typeof note === 'object' && 'inner' in note && 'sig' in note && 'source' in note); -} +import { + buildV0MigrationTransactionFromNotes as sdkBuildFromNotes, + deriveV0AddressFromMnemonic as sdkDeriveV0Address, + queryV0BalanceFromMnemonic as sdkQueryV0Balance, +} from '@nockbox/iris-sdk'; export interface V0DiscoveryResult { sourceAddress: string; - sourcePkh: string; v0NotesProtobuf: any[]; totalNicks: string; totalNock: number; @@ -40,150 +35,50 @@ export interface BuiltV0MigrationResult { export async function deriveV0AddressFromMnemonic( mnemonic: string, passphrase = '' -): Promise<{ sourceAddress: string; sourcePkh: string }> { +): Promise<{ sourceAddress: string }> { await ensureWasmInitialized(); - const master = wasm.deriveMasterKeyFromMnemonic(mnemonic, passphrase); - const publicKey = Uint8Array.from(master.publicKey); - const sourceAddress = base58.encode(publicKey); - const sourcePkh = wasm.hashPublicKey(publicKey); - return { sourceAddress, sourcePkh }; + const derived = sdkDeriveV0Address(mnemonic, passphrase); + return { sourceAddress: derived.sourceAddress }; } export async function queryV0BalanceFromMnemonic( mnemonic: string, grpcEndpoint = RPC_ENDPOINT -): Promise { - const { sourceAddress, sourcePkh } = await deriveV0AddressFromMnemonic(mnemonic); - return queryV0BalanceByAddress(sourceAddress, grpcEndpoint, sourcePkh); -} - -async function queryV0BalanceByAddress( - sourceAddress: string, - grpcEndpoint = RPC_ENDPOINT, - providedSourcePkh?: string ): Promise { await ensureWasmInitialized(); - const grpcClient = new wasm.GrpcClient(grpcEndpoint); - const balance = await grpcClient.getBalanceByAddress(sourceAddress); - - const v0NotesProtobuf: any[] = []; - let totalNicks = 0n; - - for (const entry of balance.notes ?? []) { - if (!entry?.note?.note_version || !('Legacy' in entry.note.note_version) || !entry.note) { - continue; - } - - const parsed = wasm.note_from_protobuf(entry.note); - if (!isNoteV0(parsed)) { - continue; - } - - totalNicks += BigInt(parsed.assets); - v0NotesProtobuf.push(entry.note); - } - - let sourcePkh = providedSourcePkh; - if (!sourcePkh) { - const pkBytes = base58.decode(sourceAddress); - if (pkBytes.length !== 97) { - throw new Error('Invalid legacy address: expected bare pubkey (97 bytes)'); - } - sourcePkh = wasm.hashPublicKey(Uint8Array.from(pkBytes)); - } - + const discovery = await sdkQueryV0Balance(mnemonic, grpcEndpoint); + const v0NotesProtobuf = discovery.balance.notes + ?.filter((e: any) => e?.note?.note_version && 'Legacy' in e.note.note_version) + .map((e: any) => e.note) ?? []; return { - sourceAddress, - sourcePkh, + sourceAddress: discovery.sourceAddress, v0NotesProtobuf, - totalNicks: totalNicks.toString(), - totalNock: Number(totalNicks) / NOCK_TO_NICKS, + totalNicks: discovery.totalNicks, + totalNock: Number(BigInt(discovery.totalNicks)) / NOCK_TO_NICKS, }; } export async function buildV0MigrationTransactionFromNotes( v0NotesProtobuf: any[], - sourceV0Pkh: string, targetV1Pkh: string, - feePerWord: string = DEFAULT_FEE_PER_WORD + feePerWord = '32768' ): Promise { await ensureWasmInitialized(); - - if (!v0NotesProtobuf.length) { - throw new Error('No v0 notes available for migration'); - } - - const sourceSpendCondition = [{ Pkh: { m: 1, hashes: [sourceV0Pkh] } }]; - const settings: TxEngineSettings = { - tx_engine_version: 1 as any, - tx_engine_patch: 0 as any, - min_fee: '256', - cost_per_word: feePerWord, - witness_word_div: 1, - }; - const builder = new wasm.TxBuilder(settings); - - const candidates: Array<{ note: any; assets: bigint }> = []; - for (const notePb of v0NotesProtobuf) { - const parsed = wasm.note_from_protobuf(notePb); - if (!isNoteV0(parsed)) continue; - const assets = BigInt(parsed.assets); - if (assets < MIGRATION_AMOUNT_NICKS) continue; - candidates.push({ note: parsed, assets }); - } - - if (!candidates.length) { - throw new Error('No v0 note is large enough to migrate 200 NOCK.'); - } - - let selected = candidates[0]; - for (const candidate of candidates) { - const currentDiff = selected.assets > TARGET_NOTE_NICKS - ? selected.assets - TARGET_NOTE_NICKS - : TARGET_NOTE_NICKS - selected.assets; - const nextDiff = candidate.assets > TARGET_NOTE_NICKS - ? candidate.assets - TARGET_NOTE_NICKS - : TARGET_NOTE_NICKS - candidate.assets; - if (nextDiff < currentDiff) { - selected = candidate; - } - } - - const feeNicksBigInt = selected.assets - MIGRATION_AMOUNT_NICKS; - const recipientDigest = wasm.hex_to_digest(targetV1Pkh); - const refundDigest = wasm.hex_to_digest(targetV1Pkh); - - builder.simpleSpend( - [selected.note], - [sourceSpendCondition], - recipientDigest, - MIGRATION_AMOUNT_NICKS.toString(), - feeNicksBigInt.toString(), - refundDigest, - false - ); - - const feeNicks = feeNicksBigInt.toString(); - const transaction = builder.build(); - const txNotes = builder.allNotes(); - const rawTx = { - version: 1, - id: transaction.id, - spends: transaction.spends, - }; - + const built = await sdkBuildFromNotes(v0NotesProtobuf, targetV1Pkh, feePerWord, { + debug: true, // [TEMPORARY] Remove when migration is validated + }); return { - txId: transaction.id, - feeNicks, - feeNock: Number(feeNicksBigInt) / NOCK_TO_NICKS, - migratedNicks: MIGRATION_AMOUNT_NICKS.toString(), - migratedNock: Number(MIGRATION_AMOUNT_NICKS) / NOCK_TO_NICKS, - selectedNoteNicks: selected.assets.toString(), - selectedNoteNock: Number(selected.assets) / NOCK_TO_NICKS, + txId: built.txId, + feeNicks: built.fee, + feeNock: built.feeNock, + migratedNicks: built.migratedNicks, + migratedNock: built.migratedNock, + selectedNoteNicks: built.selectedNoteNicks, + selectedNoteNock: built.selectedNoteNock, signRawTxPayload: { - rawTx, - notes: (txNotes.notes ?? []).filter((note: unknown) => isNoteV0(note)), - spendConditions: txNotes.spend_conditions ?? [], + rawTx: built.signRawTxPayload.rawTx, + notes: built.signRawTxPayload.notes, + spendConditions: built.signRawTxPayload.spendConditions, }, }; } diff --git a/package-lock.json b/package-lock.json index bd528b8..4f4208c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -613,7 +613,8 @@ "resolved": "../iris-sdk", ======= "version": "0.1.1", - "resolved": "git+ssh://git@github.com/nockbox/iris-sdk.git#418c9cc2e28b74fabed1aa42696e217150c6e116", + "resolved": "git+ssh://git@github.com/nockbox/iris-sdk.git#8c669f1afe16739d64b6cf95584faad7fd789506", + "integrity": "sha512-UQZzc0qavX8pPK8RkoWPKK9UeIgji/Xx3S4giYjT6xu7R3Ho1agE8TW8z8ji0v6uxAUtFNoWvin/6sZm8WqVXw==", "license": "MIT", "dependencies": { "@nockbox/iris-wasm": "file:./vendor/iris-wasm", From 04c775804903cbe5e927e4d6c1d305046c3e03cf Mon Sep 17 00:00:00 2001 From: Gohlub <62673775+Gohlub@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:55:21 -0500 Subject: [PATCH 5/6] some small changes --- .../popup/screens/V0MigrationFundsScreen.tsx | 4 +- .../popup/screens/V0MigrationReviewScreen.tsx | 72 ++++++++- .../popup/screens/V0MigrationSetupScreen.tsx | 15 +- .../screens/V0MigrationSubmittedScreen.tsx | 12 +- extension/popup/store.ts | 26 +++- extension/shared/v0-migration.ts | 143 ++++++++++++++++-- package-lock.json | 7 +- 7 files changed, 245 insertions(+), 34 deletions(-) diff --git a/extension/popup/screens/V0MigrationFundsScreen.tsx b/extension/popup/screens/V0MigrationFundsScreen.tsx index 78f970a..1d7a657 100644 --- a/extension/popup/screens/V0MigrationFundsScreen.tsx +++ b/extension/popup/screens/V0MigrationFundsScreen.tsx @@ -33,7 +33,7 @@ export function V0MigrationFundsScreen() { async function handleContinue() { if (!destinationWallet || isBuilding) return; - if (!v0MigrationDraft.v0NotesProtobuf?.length) { + if (!v0MigrationDraft.v0Notes?.length) { setBuildError('No v0 notes loaded. Go back and import your recovery phrase again.'); return; } @@ -42,7 +42,7 @@ export function V0MigrationFundsScreen() { setIsBuilding(true); try { const built = await buildV0MigrationTransactionFromNotes( - v0MigrationDraft.v0NotesProtobuf, + v0MigrationDraft.v0Notes, destinationWallet.address ); console.log('[V0 Migration] transaction build', { diff --git a/extension/popup/screens/V0MigrationReviewScreen.tsx b/extension/popup/screens/V0MigrationReviewScreen.tsx index 4dea5ce..85852a2 100644 --- a/extension/popup/screens/V0MigrationReviewScreen.tsx +++ b/extension/popup/screens/V0MigrationReviewScreen.tsx @@ -1,16 +1,61 @@ +import { useState } from 'react'; import { useStore } from '../store'; import { ChevronLeftIcon } from '../components/icons/ChevronLeftIcon'; import { AccountIcon } from '../components/AccountIcon'; import WalletIconYellow from '../assets/wallet-icon-yellow.svg'; import { truncateAddress } from '../utils/format'; +import { signAndBroadcastV0Migration } from '../../shared/v0-migration'; +import { Alert } from '../components/Alert'; export function V0MigrationReviewScreen() { - const { navigate, wallet, v0MigrationDraft, priceUsd } = useStore(); + const { navigate, wallet, v0MigrationDraft, setV0MigrationDraft, priceUsd } = useStore(); + const [sendError, setSendError] = useState(''); + const [isSending, setIsSending] = useState(false); const destinationWallet = wallet.accounts.find(account => account.index === v0MigrationDraft.destinationWalletIndex) || null; const amount = v0MigrationDraft.migratedAmountNock ?? v0MigrationDraft.v0BalanceNock; const usdAmount = amount * priceUsd; - const canSend = Boolean(v0MigrationDraft.signRawTxPayload); + const canSend = + Boolean(v0MigrationDraft.signRawTxPayload) && + Boolean(v0MigrationDraft.v0Mnemonic) && + !isSending; + + async function handleSend(skipBroadcast = false) { + if (!canSend || !v0MigrationDraft.v0Mnemonic || !v0MigrationDraft.signRawTxPayload) return; + + setSendError(''); + setIsSending(true); + try { + const { txId, confirmed, skipped } = await signAndBroadcastV0Migration( + v0MigrationDraft.v0Mnemonic, + v0MigrationDraft.signRawTxPayload, + undefined, + { debug: true, skipBroadcast } + ); + setV0MigrationDraft({ + v0Mnemonic: undefined, + txId, + v0TxConfirmed: skipped ? false : confirmed, + v0TxSkipped: skipped, + }); + navigate('v0-migration-submitted'); + } catch (err) { + const msg = + err instanceof Error + ? err.message + : typeof err === 'string' + ? err + : err && typeof err === 'object' && 'message' in err + ? String((err as { message: unknown }).message) + : err != null + ? String(err) + : 'Failed to sign and broadcast'; + console.error('[V0 Migration] Sign/broadcast error:', err); + setSendError(msg); + } finally { + setIsSending(false); + } + } return (
v0 Wallet
-
- Imported seed +
+ {v0MigrationDraft.sourceAddress ? truncateAddress(v0MigrationDraft.sourceAddress) : 'Imported seed'}
@@ -72,6 +117,8 @@ export function V0MigrationReviewScreen() { Network fee {v0MigrationDraft.feeNock} NOCK
+ + {sendError && {sendError}}
@@ -79,19 +126,30 @@ export function V0MigrationReviewScreen() { +
diff --git a/extension/popup/screens/V0MigrationSetupScreen.tsx b/extension/popup/screens/V0MigrationSetupScreen.tsx index e4dae5f..a724690 100644 --- a/extension/popup/screens/V0MigrationSetupScreen.tsx +++ b/extension/popup/screens/V0MigrationSetupScreen.tsx @@ -118,13 +118,20 @@ export function V0MigrationSetupScreen() { try { const discovery = await queryV0BalanceFromMnemonic(words.join(' ').trim()); - if (!discovery.v0NotesProtobuf.length) { - throw new Error('No v0 notes found for this recovery phrase.'); + if (!discovery.v0Notes.length) { + const rawCount = discovery.rawNotesFromRpc ?? 0; + const msg = + rawCount > 0 + ? `No v0 (Legacy) notes found. RPC returned ${rawCount} note(s) but none match Legacy format. Check DevTools console for details.` + : `No v0 notes found for this recovery phrase. Queried address: ${discovery.sourceAddress?.slice(0, 12)}... (see console for full address)`; + throw new Error(msg); } + const mnemonic = words.join(' ').trim(); setV0MigrationDraft({ sourceAddress: discovery.sourceAddress, - v0NotesProtobuf: discovery.v0NotesProtobuf, + v0Mnemonic: mnemonic, + v0Notes: discovery.v0Notes, v0BalanceNock: discovery.totalNock, migratedAmountNock: undefined, feeNock: 59, @@ -135,7 +142,7 @@ export function V0MigrationSetupScreen() { console.log('[V0 Migration] derived query address', { sourceAddress: discovery.sourceAddress, totalNock: discovery.totalNock, - legacyNotesCount: discovery.v0NotesProtobuf.length, + legacyNotesCount: discovery.v0Notes.length, }); setWords(Array(WORD_COUNT).fill('')); navigate('v0-migration-funds'); diff --git a/extension/popup/screens/V0MigrationSubmittedScreen.tsx b/extension/popup/screens/V0MigrationSubmittedScreen.tsx index 883242f..668fa67 100644 --- a/extension/popup/screens/V0MigrationSubmittedScreen.tsx +++ b/extension/popup/screens/V0MigrationSubmittedScreen.tsx @@ -33,10 +33,18 @@ export function V0MigrationSubmittedScreen() {

Your transaction
- was submitted + {v0MigrationDraft.v0TxSkipped + ? 'was signed (not broadcast)' + : v0MigrationDraft.v0TxConfirmed + ? 'was confirmed' + : 'was submitted'}

- Check the transaction activity below + {v0MigrationDraft.v0TxSkipped + ? 'Debug mode: transaction was signed but not broadcast.' + : v0MigrationDraft.v0TxConfirmed + ? 'Your migration is complete.' + : 'Check the transaction activity below for confirmation.'}

diff --git a/extension/popup/store.ts b/extension/popup/store.ts index 553951b..0d8f8e1 100644 --- a/extension/popup/store.ts +++ b/extension/popup/store.ts @@ -115,13 +115,16 @@ interface AppStore { destinationWalletIndex: number | null; keyfileName?: string; sourceAddress?: string; - v0NotesProtobuf?: any[]; + v0Mnemonic?: string; // Kept in memory only until sign+broadcast + v0Notes?: any[]; signRawTxPayload?: { rawTx: any; notes: any[]; spendConditions: any[]; }; txId?: string; + v0TxConfirmed?: boolean; + v0TxSkipped?: boolean; }; setV0MigrationDraft: ( value: Partial<{ @@ -131,14 +134,16 @@ interface AppStore { destinationWalletIndex: number | null; keyfileName?: string; sourceAddress?: string; - sourcePkh?: string; - v0NotesProtobuf?: any[]; + v0Mnemonic?: string; + v0Notes?: any[]; signRawTxPayload?: { rawTx: any; notes: any[]; spendConditions: any[]; }; txId?: string; + v0TxConfirmed?: boolean; + v0TxSkipped?: boolean; }> ) => void; resetV0MigrationDraft: () => void; @@ -227,9 +232,11 @@ export const useStore = create((set, get) => ({ keyfileName: undefined, sourceAddress: undefined, sourcePkh: undefined, - v0NotesProtobuf: undefined, - signRawTxPayload: undefined, - txId: undefined, + v0Notes: undefined, + signRawTxPayload: undefined, + txId: undefined, + v0TxConfirmed: undefined, + v0TxSkipped: undefined, }, pendingConnectRequest: null, pendingSignRequest: null, @@ -310,10 +317,13 @@ export const useStore = create((set, get) => ({ destinationWalletIndex: null, keyfileName: undefined, sourceAddress: undefined, - v0NotesProtobuf: undefined, + v0Mnemonic: undefined, + v0Notes: undefined, signRawTxPayload: undefined, txId: undefined, - }, + v0TxConfirmed: undefined, + v0TxSkipped: undefined, + }, }); }, diff --git a/extension/shared/v0-migration.ts b/extension/shared/v0-migration.ts index a5a26fd..57d86cf 100644 --- a/extension/shared/v0-migration.ts +++ b/extension/shared/v0-migration.ts @@ -1,20 +1,25 @@ /** - * v0-to-v1 migration - delegates to SDK. + * v0-to-v1 migration - delegates discovery and build to SDK. */ import { NOCK_TO_NICKS, RPC_ENDPOINT } from './constants'; import { ensureWasmInitialized } from './wasm-utils'; import { - buildV0MigrationTransactionFromNotes as sdkBuildFromNotes, + buildV0MigrationTransaction as sdkBuildFromV0Notes, deriveV0AddressFromMnemonic as sdkDeriveV0Address, queryV0BalanceFromMnemonic as sdkQueryV0Balance, } from '@nockbox/iris-sdk'; +import wasm from './sdk-wasm.js'; +import { txEngineSettings } from './tx-engine-settings.js'; +import { createBrowserClient } from './rpc-client-browser'; export interface V0DiscoveryResult { sourceAddress: string; - v0NotesProtobuf: any[]; + v0Notes: any[]; totalNicks: string; totalNock: number; + /** Raw notes count from RPC (for debugging when v0Notes is empty) */ + rawNotesFromRpc?: number; } export interface BuiltV0MigrationResult { @@ -47,26 +52,40 @@ export async function queryV0BalanceFromMnemonic( ): Promise { await ensureWasmInitialized(); const discovery = await sdkQueryV0Balance(mnemonic, grpcEndpoint); - const v0NotesProtobuf = discovery.balance.notes - ?.filter((e: any) => e?.note?.note_version && 'Legacy' in e.note.note_version) - .map((e: any) => e.note) ?? []; + const rawNotesCount = discovery.balance?.notes?.length ?? 0; + const legacyCount = discovery.v0Notes.length; + console.log('[V0 Migration] Discovery result:', { + sourceAddress: discovery.sourceAddress, + rawNotesFromRpc: rawNotesCount, + legacyV0Notes: legacyCount, + totalNicks: discovery.totalNicks, + }); + if (legacyCount === 0 && rawNotesCount > 0) { + const first = discovery.balance?.notes?.[0]; + const nv = first?.note?.note_version; + const nvKeys = nv && typeof nv === 'object' ? Object.keys(nv) : []; + console.warn('[V0 Migration] RPC returned', rawNotesCount, 'notes but none are Legacy (v0). Check note_version structure.'); + console.warn('[V0 Migration] First entry note_version keys:', nvKeys, 'sample:', nv ? JSON.stringify(nv).slice(0, 300) : 'n/a'); + } return { sourceAddress: discovery.sourceAddress, - v0NotesProtobuf, + v0Notes: discovery.v0Notes, totalNicks: discovery.totalNicks, totalNock: Number(BigInt(discovery.totalNicks)) / NOCK_TO_NICKS, + rawNotesFromRpc: rawNotesCount, }; } export async function buildV0MigrationTransactionFromNotes( - v0NotesProtobuf: any[], + v0Notes: any[], targetV1Pkh: string, feePerWord = '32768' ): Promise { await ensureWasmInitialized(); - const built = await sdkBuildFromNotes(v0NotesProtobuf, targetV1Pkh, feePerWord, { + const built = await sdkBuildFromV0Notes(v0Notes, targetV1Pkh, feePerWord, undefined, undefined, { + singleNoteOnly: true, debug: true, // [TEMPORARY] Remove when migration is validated - }); + }) as { txId: string; fee: string; feeNock: number; migratedNicks: string; migratedNock: number; selectedNoteNicks: string; selectedNoteNock: number; signRawTxPayload: { rawTx: any; notes: any[]; spendConditions: any[] } }; return { txId: built.txId, feeNicks: built.fee, @@ -82,3 +101,107 @@ export async function buildV0MigrationTransactionFromNotes( }, }; } + +const CONFIRM_POLL_INTERVAL_MS = 3000; +const CONFIRM_TIMEOUT_MS = 90_000; + +/** [TEMPORARY] Set true to log unsigned tx before signing. Remove when migration is validated. */ +const DEBUG_V0_MIGRATION = true; + +/** + * Sign a v0 migration raw transaction with the given mnemonic (master key) and broadcast. + * Polls until the transaction is confirmed on-chain or timeout. + * + * @param options.debug - Log unsigned transaction to console before signing + * @param options.skipBroadcast - Sign but do not broadcast (for debugging) + */ +export async function signAndBroadcastV0Migration( + mnemonic: string, + signRawTxPayload: { rawTx: any; notes: any[]; spendConditions: any[] }, + grpcEndpoint = RPC_ENDPOINT, + options?: { debug?: boolean; skipBroadcast?: boolean } +): Promise<{ txId: string; confirmed: boolean; skipped?: boolean }> { + await ensureWasmInitialized(); + + const masterKey = wasm.deriveMasterKeyFromMnemonic(mnemonic, ''); + if (!masterKey.privateKey || masterKey.privateKey.byteLength !== 32) { + masterKey.free(); + throw new Error('Cannot derive signing key from mnemonic'); + } + + const debug = options?.debug ?? DEBUG_V0_MIGRATION; + const skipBroadcast = options?.skipBroadcast ?? false; + + try { + const { rawTx, notes, spendConditions } = signRawTxPayload; + + if (debug) { + console.log('[V0 Migration] Unsigned transaction (before signing):', { + rawTx: { id: rawTx?.id, version: rawTx?.version, spendsCount: rawTx?.spends?.length ?? 0 }, + notesCount: notes.length, + spendConditionsCount: spendConditions.length, + fullRawTx: rawTx, + }); + } + + let builder: ReturnType; + try { + builder = wasm.TxBuilder.fromTx( + rawTx, + notes, + spendConditions, + txEngineSettings() + ); + } catch (e) { + console.error('[V0 Migration] TxBuilder.fromTx failed:', e); + throw e; + } + + const signingKeyBytes = new Uint8Array(masterKey.privateKey.slice(0, 32)); + try { + builder.sign(signingKeyBytes); + } catch (e) { + console.error('[V0 Migration] builder.sign failed:', e); + throw e; + } + + try { + builder.validate(); + } catch (e) { + console.error('[V0 Migration] builder.validate failed:', e); + throw e; + } + + const signedTx = builder.build(); + const signedRawTx = wasm.nockchainTxToRaw(signedTx) as wasm.RawTxV1; + const protobuf = wasm.rawTxToProtobuf(signedRawTx); + + if (debug) { + console.log('[V0 Migration] Signed transaction (before broadcast):', { + txId: signedTx.id, + spendsCount: signedRawTx?.spends?.length ?? 0, + }); + } + + if (skipBroadcast) { + console.log('[V0 Migration] Skipping broadcast (debug mode)'); + return { txId: signedTx.id, confirmed: false, skipped: true }; + } + + const rpcClient = createBrowserClient(grpcEndpoint); + const txId = await rpcClient.sendTransaction(protobuf); + + const deadline = Date.now() + CONFIRM_TIMEOUT_MS; + while (Date.now() < deadline) { + const accepted = await rpcClient.isTransactionAccepted(txId); + if (accepted) { + return { txId, confirmed: true }; + } + await new Promise(resolve => setTimeout(resolve, CONFIRM_POLL_INTERVAL_MS)); + } + + return { txId, confirmed: false }; + } finally { + masterKey.free(); + } +} diff --git a/package-lock.json b/package-lock.json index 4f4208c..612aa06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "license": "MIT" }, <<<<<<< HEAD +<<<<<<< HEAD ======= <<<<<<< HEAD "../iris-sdk": { @@ -58,6 +59,8 @@ ======= >>>>>>> 7206395 (Debug for migration) >>>>>>> f56b204 (Debug for migration) +======= +>>>>>>> 1d4df1d (some small changes) "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -594,6 +597,7 @@ } }, "node_modules/@nockbox/iris-sdk": { +<<<<<<< HEAD <<<<<<< HEAD "version": "0.2.0-alpha.4", "resolved": "git+ssh://git@github.com/nockbox/iris-sdk.git#faaf42be65fd833f798e16712e269dbe4d5bc84a", @@ -612,6 +616,8 @@ <<<<<<< HEAD "resolved": "../iris-sdk", ======= +======= +>>>>>>> 1d4df1d (some small changes) "version": "0.1.1", "resolved": "git+ssh://git@github.com/nockbox/iris-sdk.git#8c669f1afe16739d64b6cf95584faad7fd789506", "integrity": "sha512-UQZzc0qavX8pPK8RkoWPKK9UeIgji/Xx3S4giYjT6xu7R3Ho1agE8TW8z8ji0v6uxAUtFNoWvin/6sZm8WqVXw==", @@ -628,7 +634,6 @@ }, "node_modules/@nockbox/iris-wasm": { "resolved": "node_modules/@nockbox/iris-sdk/vendor/iris-wasm", ->>>>>>> 7206395 (Debug for migration) "link": true >>>>>>> f56b204 (Debug for migration) }, From 9070bdac10d4f907a97ea657805fa9418de411b1 Mon Sep 17 00:00:00 2001 From: Gohlub <62673775+Gohlub@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:25:41 -0400 Subject: [PATCH 6/6] clean up logic in preparation for testing, small UX changes --- .../popup/screens/V0MigrationFundsScreen.tsx | 318 +++++++++++++++--- .../popup/screens/V0MigrationIntroScreen.tsx | 107 ++++-- .../popup/screens/V0MigrationReviewScreen.tsx | 3 +- .../popup/screens/V0MigrationSetupScreen.tsx | 27 +- extension/popup/store.ts | 8 +- extension/shared/v0-migration.ts | 158 ++++----- 6 files changed, 427 insertions(+), 194 deletions(-) diff --git a/extension/popup/screens/V0MigrationFundsScreen.tsx b/extension/popup/screens/V0MigrationFundsScreen.tsx index 1d7a657..5d6243a 100644 --- a/extension/popup/screens/V0MigrationFundsScreen.tsx +++ b/extension/popup/screens/V0MigrationFundsScreen.tsx @@ -1,22 +1,32 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef } from 'react'; import { useStore } from '../store'; import { ChevronLeftIcon } from '../components/icons/ChevronLeftIcon'; import { AccountIcon } from '../components/AccountIcon'; -import { Alert } from '../components/Alert'; import WalletIconYellow from '../assets/wallet-icon-yellow.svg'; import ArrowDownIcon from '../assets/arrow-down-icon.svg'; import ChevronDownIconAsset from '../assets/wallet-dropdown-arrow.svg'; import InfoIconAsset from '../assets/info-icon.svg'; +import PencilEditIcon from '../assets/pencil-edit-icon.svg'; +import CheckmarkIcon from '../assets/checkmark-pencil-icon.svg'; import { truncateAddress } from '../utils/format'; import { PlusIcon } from '../components/icons/PlusIcon'; -import { buildV0MigrationTransactionFromNotes } from '../../shared/v0-migration'; +import { buildV0MigrationTx } from '../../shared/v0-migration'; export function V0MigrationFundsScreen() { const { navigate, wallet, v0MigrationDraft, setV0MigrationDraft } = useStore(); const visibleAccounts = wallet.accounts.filter(account => !account.hidden); const [showWalletPicker, setShowWalletPicker] = useState(false); const [buildError, setBuildError] = useState(''); + const [errorType, setErrorType] = useState<'fee_too_low' | 'general' | null>(null); const [isBuilding, setIsBuilding] = useState(false); + const [isEstimatingFee, setIsEstimatingFee] = useState(false); + const [fee, setFee] = useState(''); + const [isEditingFee, setIsEditingFee] = useState(false); + const [editedFee, setEditedFee] = useState(''); + const [showFeeTooltip, setShowFeeTooltip] = useState(false); + const [isFeeManuallyEdited, setIsFeeManuallyEdited] = useState(false); + const [minimumFee, setMinimumFee] = useState(null); + const estimateAbortRef = useRef(null); useEffect(() => { if (v0MigrationDraft.destinationWalletIndex === null && visibleAccounts.length > 0) { @@ -28,48 +38,130 @@ export function V0MigrationFundsScreen() { visibleAccounts.find(account => account.index === v0MigrationDraft.destinationWalletIndex) || visibleAccounts[0] || null; - const hasInsufficientFunds = v0MigrationDraft.v0BalanceNock <= v0MigrationDraft.feeNock; + + // Dynamic fee estimation - debounced (same pattern as SendScreen) + // Uses selected destination wallet address (one of our own), not a dummy + useEffect(() => { + if (!v0MigrationDraft.v0Mnemonic || !destinationWallet?.address) return; + if (isFeeManuallyEdited) return; + + setBuildError(''); + setErrorType(null); + setIsEstimatingFee(true); + + const ac = new AbortController(); + estimateAbortRef.current = ac; + + const timeoutId = setTimeout(async () => { + try { + const result = await buildV0MigrationTx( + v0MigrationDraft.v0Mnemonic!, + destinationWallet!.address, + true + ); + if (ac.signal.aborted) return; + const feeNock = result.feeNock; + setV0MigrationDraft({ feeNock }); + if (feeNock != null) { + setFee(feeNock.toString()); + setEditedFee(feeNock.toString()); + setMinimumFee(feeNock); + } else { + setFee(''); + setEditedFee(''); + setMinimumFee(null); + } + setBuildError(''); + } catch (err) { + if (ac.signal.aborted) return; + setBuildError(err instanceof Error ? err.message : 'Failed to estimate fee'); + setErrorType('general'); + setV0MigrationDraft({ feeNock: undefined }); + setFee(''); + setEditedFee(''); + setMinimumFee(null); + } finally { + if (!ac.signal.aborted) setIsEstimatingFee(false); + estimateAbortRef.current = null; + } + }, 500); + + return () => { + clearTimeout(timeoutId); + ac.abort(); + setIsEstimatingFee(false); + }; + }, [v0MigrationDraft.v0Mnemonic, destinationWallet?.address, destinationWallet?.index, v0MigrationDraft.destinationWalletIndex, isFeeManuallyEdited, setV0MigrationDraft]); + + const hasInsufficientFunds = + v0MigrationDraft.feeNock != null && v0MigrationDraft.v0BalanceNock <= v0MigrationDraft.feeNock; + + function handleEditFee() { + setIsEditingFee(true); + setEditedFee(fee); + } + + function handleSaveFee() { + const feeNum = parseFloat(editedFee); + if (!isNaN(feeNum) && feeNum >= 0) { + if (minimumFee !== null && feeNum < minimumFee) { + setBuildError('Fee too low.'); + setErrorType('fee_too_low'); + } else { + setBuildError(''); + setErrorType(null); + } + setFee(editedFee); + setV0MigrationDraft({ feeNock: feeNum }); + setIsFeeManuallyEdited(true); + } + setIsEditingFee(false); + } + + function handleFeeInputChange(e: React.ChangeEvent) { + const value = e.target.value; + if (value === '' || /^\d*\.?\d*$/.test(value)) { + setEditedFee(value); + } + } + + function handleFeeInputKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter') handleSaveFee(); + if (e.key === 'Escape') { + setIsEditingFee(false); + setEditedFee(fee); + } + } + + function handleFeeInputBlur() { + handleSaveFee(); + } async function handleContinue() { if (!destinationWallet || isBuilding) return; - if (!v0MigrationDraft.v0Notes?.length) { - setBuildError('No v0 notes loaded. Go back and import your recovery phrase again.'); + if (!v0MigrationDraft.v0Mnemonic) { + setBuildError('No recovery phrase loaded. Go back and import your recovery phrase again.'); return; } setBuildError(''); setIsBuilding(true); try { - const built = await buildV0MigrationTransactionFromNotes( - v0MigrationDraft.v0Notes, - destinationWallet.address + const result = await buildV0MigrationTx( + v0MigrationDraft.v0Mnemonic, + destinationWallet.address, + true ); - console.log('[V0 Migration] transaction build', { - sourceAddress: v0MigrationDraft.sourceAddress, - destinationPkh: destinationWallet.address, - discoveredV0BalanceNock: v0MigrationDraft.v0BalanceNock, - migratedAmountNock: built.migratedNock, - feeNock: built.feeNock, - selectedNoteNock: built.selectedNoteNock, - selectedNoteNicks: built.selectedNoteNicks, - txInputs: { - notesCount: built.signRawTxPayload.notes?.length ?? 0, - spendConditionsCount: built.signRawTxPayload.spendConditions?.length ?? 0, - notes: built.signRawTxPayload.notes, - spendConditions: built.signRawTxPayload.spendConditions, - }, - finalTransaction: { - txId: built.txId, - rawTx: built.signRawTxPayload.rawTx, - }, - }); + if (!result.txId || !result.signRawTxPayload) { + throw new Error('Failed to build migration transaction'); + } setV0MigrationDraft({ - migratedAmountNock: built.migratedNock, - feeNock: built.feeNock, - signRawTxPayload: built.signRawTxPayload, - txId: built.txId, + migratedAmountNock: result.migratedNock, + feeNock: result.feeNock, + signRawTxPayload: result.signRawTxPayload, + txId: result.txId, }); navigate('v0-migration-review'); } catch (err) { @@ -178,20 +270,142 @@ export function V0MigrationFundsScreen() {
)} -
-
- Fee - -
-
- {v0MigrationDraft.feeNock} NOCK + {/* Fee */} +
+
+
+ Fee +
setShowFeeTooltip(true)} + onMouseLeave={() => setShowFeeTooltip(false)} + > + Fee information + {showFeeTooltip && ( +
+
+ Network transaction fee. Adjustable if needed. +
+
+
+ )} +
+
+ {isEditingFee ? ( +
+ + + NOCK + + +
+ ) : ( + + )}
+ {buildError && ( +
+ {buildError} + {errorType === 'fee_too_low' && minimumFee !== null && ( + + )} +
+ )}
- - {buildError && {buildError}}
@@ -246,10 +460,22 @@ export function V0MigrationFundsScreen() { const balance = wallet.accountBalances[account.address] ?? 0; return ( -

+

Transfer v0 funds

-
+
-
- - -
-
- v0 Funds Migration + {/* Content - Figma: 16px horizontal, 12px gap icon→title, 8px gap title→subtitle */} +
+
+
+
-
- Transfer your balance from V0 to V1 + +
+

+ v0 Funds Migration +

+

+ Transfer your balance from V0 to V1 +

-
-
- The network has upgraded. If your wallet was created before - October 25, 2025 - (block 39,000), your funds need to be migrated to remain accessible on the current network. -
-
- This process transfers your full balance from your v0 wallet to v1. It only takes a moment. +
+

+ The network has upgraded. If your wallet was created before + October 25, 2025 + + {' '} + (block 39,000), your funds need to be migrated to remain accessible on the current network. + +

+

+ This process transfers your full balance from your v0 wallet to v1. It only takes a moment. +

+
-
+ {/* Footer CTA - matches other migration screens */} +
@@ -61,4 +117,3 @@ export function V0MigrationIntroScreen() {
); } - diff --git a/extension/popup/screens/V0MigrationReviewScreen.tsx b/extension/popup/screens/V0MigrationReviewScreen.tsx index 85852a2..0905fcd 100644 --- a/extension/popup/screens/V0MigrationReviewScreen.tsx +++ b/extension/popup/screens/V0MigrationReviewScreen.tsx @@ -29,7 +29,6 @@ export function V0MigrationReviewScreen() { const { txId, confirmed, skipped } = await signAndBroadcastV0Migration( v0MigrationDraft.v0Mnemonic, v0MigrationDraft.signRawTxPayload, - undefined, { debug: true, skipBroadcast } ); setV0MigrationDraft({ @@ -115,7 +114,7 @@ export function V0MigrationReviewScreen() {
Network fee - {v0MigrationDraft.feeNock} NOCK + {v0MigrationDraft.feeNock != null ? `${v0MigrationDraft.feeNock} NOCK` : ''}
{sendError && {sendError}} diff --git a/extension/popup/screens/V0MigrationSetupScreen.tsx b/extension/popup/screens/V0MigrationSetupScreen.tsx index a724690..f8fc274 100644 --- a/extension/popup/screens/V0MigrationSetupScreen.tsx +++ b/extension/popup/screens/V0MigrationSetupScreen.tsx @@ -5,7 +5,7 @@ import { Alert } from '../components/Alert'; import lockIcon from '../assets/lock-icon.svg'; import { importKeyfile, type Keyfile } from '../../shared/keyfile'; import { UI_CONSTANTS } from '../../shared/constants'; -import { queryV0BalanceFromMnemonic } from '../../shared/v0-migration'; +import { queryV0Balance } from '../../shared/v0-migration'; const WORD_COUNT = 24; @@ -116,34 +116,29 @@ export function V0MigrationSetupScreen() { setDiscoverError(''); setIsDiscovering(true); try { - const discovery = await queryV0BalanceFromMnemonic(words.join(' ').trim()); + const mnemonic = words.join(' ').trim(); + const result = await queryV0Balance(mnemonic); - if (!discovery.v0Notes.length) { - const rawCount = discovery.rawNotesFromRpc ?? 0; + if (!result.v0Notes.length) { + const rawCount = result.rawNotesFromRpc ?? 0; const msg = rawCount > 0 ? `No v0 (Legacy) notes found. RPC returned ${rawCount} note(s) but none match Legacy format. Check DevTools console for details.` - : `No v0 notes found for this recovery phrase. Queried address: ${discovery.sourceAddress?.slice(0, 12)}... (see console for full address)`; + : `No v0 notes found for this recovery phrase. Queried address: ${result.sourceAddress?.slice(0, 12)}... (see console for full address)`; throw new Error(msg); } - const mnemonic = words.join(' ').trim(); setV0MigrationDraft({ - sourceAddress: discovery.sourceAddress, + sourceAddress: result.sourceAddress, v0Mnemonic: mnemonic, - v0Notes: discovery.v0Notes, - v0BalanceNock: discovery.totalNock, + v0Notes: result.v0Notes, + v0BalanceNock: result.totalNock, migratedAmountNock: undefined, - feeNock: 59, + feeNock: undefined, keyfileName: undefined, signRawTxPayload: undefined, txId: undefined, }); - console.log('[V0 Migration] derived query address', { - sourceAddress: discovery.sourceAddress, - totalNock: discovery.totalNock, - legacyNotesCount: discovery.v0Notes.length, - }); setWords(Array(WORD_COUNT).fill('')); navigate('v0-migration-funds'); } catch (err) { @@ -214,7 +209,7 @@ export function V0MigrationSetupScreen() { Or import from keyfile - {/* 24-word input grid - same as ImportScreen */} + {/* 24-word input grid */}
{Array.from({ length: 12 }).map((_, rowIndex) => (
diff --git a/extension/popup/store.ts b/extension/popup/store.ts index 0d8f8e1..1d1b996 100644 --- a/extension/popup/store.ts +++ b/extension/popup/store.ts @@ -111,7 +111,7 @@ interface AppStore { v0MigrationDraft: { v0BalanceNock: number; migratedAmountNock?: number; - feeNock: number; + feeNock?: number; destinationWalletIndex: number | null; keyfileName?: string; sourceAddress?: string; @@ -130,7 +130,7 @@ interface AppStore { value: Partial<{ v0BalanceNock: number; migratedAmountNock?: number; - feeNock: number; + feeNock?: number; destinationWalletIndex: number | null; keyfileName?: string; sourceAddress?: string; @@ -227,7 +227,7 @@ export const useStore = create((set, get) => ({ v0MigrationDraft: { v0BalanceNock: 2500, migratedAmountNock: undefined, - feeNock: 59, + feeNock: undefined, destinationWalletIndex: null, keyfileName: undefined, sourceAddress: undefined, @@ -313,7 +313,7 @@ export const useStore = create((set, get) => ({ v0MigrationDraft: { v0BalanceNock: 2500, migratedAmountNock: undefined, - feeNock: 59, + feeNock: undefined, destinationWalletIndex: null, keyfileName: undefined, sourceAddress: undefined, diff --git a/extension/shared/v0-migration.ts b/extension/shared/v0-migration.ts index 57d86cf..53690c3 100644 --- a/extension/shared/v0-migration.ts +++ b/extension/shared/v0-migration.ts @@ -2,112 +2,61 @@ * v0-to-v1 migration - delegates discovery and build to SDK. */ -import { NOCK_TO_NICKS, RPC_ENDPOINT } from './constants'; import { ensureWasmInitialized } from './wasm-utils'; +import { getEffectiveRpcEndpoint } from './rpc-config'; import { - buildV0MigrationTransaction as sdkBuildFromV0Notes, - deriveV0AddressFromMnemonic as sdkDeriveV0Address, - queryV0BalanceFromMnemonic as sdkQueryV0Balance, + buildV0MigrationTx as sdkBuildV0MigrationTx, + queryV0Balance as sdkQueryV0Balance, + type BuildV0MigrationTxResult, + type V0BalanceResult, } from '@nockbox/iris-sdk'; import wasm from './sdk-wasm.js'; -import { txEngineSettings } from './tx-engine-settings.js'; import { createBrowserClient } from './rpc-client-browser'; -export interface V0DiscoveryResult { - sourceAddress: string; - v0Notes: any[]; - totalNicks: string; - totalNock: number; - /** Raw notes count from RPC (for debugging when v0Notes is empty) */ - rawNotesFromRpc?: number; -} +export type { V0BalanceResult }; -export interface BuiltV0MigrationResult { - txId: string; - feeNicks: string; - feeNock: number; - migratedNicks: string; - migratedNock: number; - selectedNoteNicks: string; - selectedNoteNock: number; - signRawTxPayload: { - rawTx: any; - notes: any[]; - spendConditions: any[]; - }; -} +const CONFIRM_POLL_INTERVAL_MS = 3000; +const CONFIRM_TIMEOUT_MS = 90_000; +/** [TEMPORARY] Set true to log unsigned tx before signing. Remove when migration is validated. */ +const DEBUG_V0_MIGRATION = true; -export async function deriveV0AddressFromMnemonic( - mnemonic: string, - passphrase = '' -): Promise<{ sourceAddress: string }> { +/** + * Discovery only: query v0 (Legacy) balance for a mnemonic. Use this to display balance + * before building a migration tx. Does not build a transaction. + */ +export async function queryV0Balance(mnemonic: string): Promise { await ensureWasmInitialized(); - const derived = sdkDeriveV0Address(mnemonic, passphrase); - return { sourceAddress: derived.sourceAddress }; + const grpcEndpoint = await getEffectiveRpcEndpoint(); + return sdkQueryV0Balance(mnemonic, grpcEndpoint); } -export async function queryV0BalanceFromMnemonic( +/** + * Build v0 migration transaction (queries balance internally, then builds tx when target provided). + * Use for fee estimation and for the actual migration payload on the Funds screen. + */ +export async function buildV0MigrationTx( mnemonic: string, - grpcEndpoint = RPC_ENDPOINT -): Promise { + targetV1Pkh?: string, + debug = false +): Promise { await ensureWasmInitialized(); - const discovery = await sdkQueryV0Balance(mnemonic, grpcEndpoint); - const rawNotesCount = discovery.balance?.notes?.length ?? 0; - const legacyCount = discovery.v0Notes.length; - console.log('[V0 Migration] Discovery result:', { - sourceAddress: discovery.sourceAddress, - rawNotesFromRpc: rawNotesCount, - legacyV0Notes: legacyCount, - totalNicks: discovery.totalNicks, - }); - if (legacyCount === 0 && rawNotesCount > 0) { - const first = discovery.balance?.notes?.[0]; - const nv = first?.note?.note_version; - const nvKeys = nv && typeof nv === 'object' ? Object.keys(nv) : []; - console.warn('[V0 Migration] RPC returned', rawNotesCount, 'notes but none are Legacy (v0). Check note_version structure.'); - console.warn('[V0 Migration] First entry note_version keys:', nvKeys, 'sample:', nv ? JSON.stringify(nv).slice(0, 300) : 'n/a'); + const grpcEndpoint = await getEffectiveRpcEndpoint(); + const result = await sdkBuildV0MigrationTx(mnemonic, grpcEndpoint, targetV1Pkh, { debug }); + + if (debug) { + console.log('[V0 Migration] Result:', { + sourceAddress: result.sourceAddress, + rawNotesFromRpc: result.rawNotesFromRpc, + legacyV0Notes: result.v0Notes.length, + totalNicks: result.totalNicks, + smallestNoteNock: result.smallestNoteNock, + txId: result.txId, + }); } - return { - sourceAddress: discovery.sourceAddress, - v0Notes: discovery.v0Notes, - totalNicks: discovery.totalNicks, - totalNock: Number(BigInt(discovery.totalNicks)) / NOCK_TO_NICKS, - rawNotesFromRpc: rawNotesCount, - }; -} -export async function buildV0MigrationTransactionFromNotes( - v0Notes: any[], - targetV1Pkh: string, - feePerWord = '32768' -): Promise { - await ensureWasmInitialized(); - const built = await sdkBuildFromV0Notes(v0Notes, targetV1Pkh, feePerWord, undefined, undefined, { - singleNoteOnly: true, - debug: true, // [TEMPORARY] Remove when migration is validated - }) as { txId: string; fee: string; feeNock: number; migratedNicks: string; migratedNock: number; selectedNoteNicks: string; selectedNoteNock: number; signRawTxPayload: { rawTx: any; notes: any[]; spendConditions: any[] } }; - return { - txId: built.txId, - feeNicks: built.fee, - feeNock: built.feeNock, - migratedNicks: built.migratedNicks, - migratedNock: built.migratedNock, - selectedNoteNicks: built.selectedNoteNicks, - selectedNoteNock: built.selectedNoteNock, - signRawTxPayload: { - rawTx: built.signRawTxPayload.rawTx, - notes: built.signRawTxPayload.notes, - spendConditions: built.signRawTxPayload.spendConditions, - }, - }; + return result; } -const CONFIRM_POLL_INTERVAL_MS = 3000; -const CONFIRM_TIMEOUT_MS = 90_000; - -/** [TEMPORARY] Set true to log unsigned tx before signing. Remove when migration is validated. */ -const DEBUG_V0_MIGRATION = true; - /** * Sign a v0 migration raw transaction with the given mnemonic (master key) and broadcast. * Polls until the transaction is confirmed on-chain or timeout. @@ -117,11 +66,11 @@ const DEBUG_V0_MIGRATION = true; */ export async function signAndBroadcastV0Migration( mnemonic: string, - signRawTxPayload: { rawTx: any; notes: any[]; spendConditions: any[] }, - grpcEndpoint = RPC_ENDPOINT, + signRawTxPayload: { rawTx: any; notes: any[]; spendConditions?: (any | null)[]; refundLock?: any }, options?: { debug?: boolean; skipBroadcast?: boolean } ): Promise<{ txId: string; confirmed: boolean; skipped?: boolean }> { await ensureWasmInitialized(); + const grpcEndpoint = await getEffectiveRpcEndpoint(); const masterKey = wasm.deriveMasterKeyFromMnemonic(mnemonic, ''); if (!masterKey.privateKey || masterKey.privateKey.byteLength !== 32) { @@ -133,36 +82,45 @@ export async function signAndBroadcastV0Migration( const skipBroadcast = options?.skipBroadcast ?? false; try { - const { rawTx, notes, spendConditions } = signRawTxPayload; + const { rawTx, notes } = signRawTxPayload; if (debug) { - console.log('[V0 Migration] Unsigned transaction (before signing):', { + const debugPayload = { rawTx: { id: rawTx?.id, version: rawTx?.version, spendsCount: rawTx?.spends?.length ?? 0 }, notesCount: notes.length, - spendConditionsCount: spendConditions.length, + spendConditionsCount: signRawTxPayload.spendConditions?.length ?? 0, fullRawTx: rawTx, - }); + }; + console.log('[V0 Migration] Unsigned transaction (before signing):', debugPayload); + return { txId: rawTx?.id ?? '', confirmed: false, skipped: true, ...debugPayload }; } + // alpha.6: fromTx(tx, notes, refund_lock, settings) - use refundLock from payload (v0 notes pass null for spendConditions) + const sc = signRawTxPayload.spendConditions; + const refundLock = + signRawTxPayload.refundLock ?? + (sc && sc.length > 0 && sc[0] ? wasm.locky(sc[0]) : null); let builder: ReturnType; try { builder = wasm.TxBuilder.fromTx( rawTx, notes, - spendConditions, - txEngineSettings() + refundLock, + wasm.txEngineSettingsV1BythosDefault() ); } catch (e) { console.error('[V0 Migration] TxBuilder.fromTx failed:', e); throw e; } - const signingKeyBytes = new Uint8Array(masterKey.privateKey.slice(0, 32)); + const privateKey = wasm.PrivateKey.fromBytes(masterKey.privateKey); try { - builder.sign(signingKeyBytes); + await builder.sign(privateKey); } catch (e) { console.error('[V0 Migration] builder.sign failed:', e); throw e; + } finally { + privateKey.free(); } try { @@ -173,7 +131,7 @@ export async function signAndBroadcastV0Migration( } const signedTx = builder.build(); - const signedRawTx = wasm.nockchainTxToRaw(signedTx) as wasm.RawTxV1; + const signedRawTx = wasm.nockchainTxToRawTx(signedTx) as wasm.RawTxV1; const protobuf = wasm.rawTxToProtobuf(signedRawTx); if (debug) {