diff --git a/extension/popup/Router.tsx b/extension/popup/Router.tsx index 7069c2c..9e0811a 100644 --- a/extension/popup/Router.tsx +++ b/extension/popup/Router.tsx @@ -36,6 +36,11 @@ 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 { V0MigrationFundsScreen } from './screens/V0MigrationFundsScreen'; +import { V0MigrationReviewScreen } from './screens/V0MigrationReviewScreen'; +import { V0MigrationSubmittedScreen } from './screens/V0MigrationSubmittedScreen'; export function Router() { const { currentScreen } = useStore(); @@ -85,6 +90,16 @@ export function Router() { return ; case 'recovery-phrase': return ; + case 'v0-migration-intro': + return ; + case 'v0-migration-setup': + return ; + case 'v0-migration-funds': + 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 +

+
+
+ +
+
+ +

+ 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 */} +
+
+
+ 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 && ( + + )} +
+ )} +
+
+ +
+
+ + +
+
+ + {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 new file mode 100644 index 0000000..f6d3209 --- /dev/null +++ b/extension/popup/screens/V0MigrationIntroScreen.tsx @@ -0,0 +1,119 @@ +import { useStore } from '../store'; +import { ChevronLeftIcon } from '../components/icons/ChevronLeftIcon'; +import TransferV0Icon from '../assets/transferv0_icon.svg'; + +export function V0MigrationIntroScreen() { + const { navigate, resetV0MigrationDraft } = useStore(); + + function handleBack() { + navigate('settings'); + } + + function handleStart() { + resetV0MigrationDraft(); + navigate('v0-migration-setup'); + } + + return ( +
+ {/* Header - matches other migration screens */} +
+ +

+ Transfer v0 funds +

+
+
+ + {/* Content - Figma: 16px horizontal, 12px gap icon→title, 8px gap title→subtitle */} +
+
+
+ +
+ +
+

+ 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. +

+
+
+
+ + {/* Footer CTA - matches other migration screens */} +
+ +
+
+ ); +} diff --git a/extension/popup/screens/V0MigrationReviewScreen.tsx b/extension/popup/screens/V0MigrationReviewScreen.tsx new file mode 100644 index 0000000..0905fcd --- /dev/null +++ b/extension/popup/screens/V0MigrationReviewScreen.tsx @@ -0,0 +1,158 @@ +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, 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) && + 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, + { 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 ( +
+
+ +

Review Transfer

+
+
+ +
+
+ +
+ {amount.toLocaleString('en-US')} NOCK +
+
+ ${usdAmount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +
+
+ +
+
+
+
+ vØ +
+
v0 Wallet
+
+ {v0MigrationDraft.sourceAddress ? truncateAddress(v0MigrationDraft.sourceAddress) : 'Imported seed'} +
+
+ +
+ › +
+ +
+
+ +
+
{destinationWallet?.name || 'Wallet'}
+
+ {truncateAddress(destinationWallet?.address)} +
+
+
+
+ +
+ Network fee + {v0MigrationDraft.feeNock != null ? `${v0MigrationDraft.feeNock} NOCK` : ''} +
+ + {sendError && {sendError}} +
+ +
+
+ + + +
+
+
+ ); +} + diff --git a/extension/popup/screens/V0MigrationSetupScreen.tsx b/extension/popup/screens/V0MigrationSetupScreen.tsx new file mode 100644 index 0000000..f8fc274 --- /dev/null +++ b/extension/popup/screens/V0MigrationSetupScreen.tsx @@ -0,0 +1,374 @@ +import { useRef, useState } from 'react'; +import { useStore } from '../store'; +import { ChevronLeftIcon } from '../components/icons/ChevronLeftIcon'; +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 { queryV0Balance } from '../../shared/v0-migration'; + +const WORD_COUNT = 24; + +export function V0MigrationSetupScreen() { + 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 canContinue = words.length === WORD_COUNT && words.every(w => Boolean(w)); + + 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; + }); + setWords(next); + } catch (error) { + console.warn('Paste failed:', error); + } + } + + function handleWordChange(index: number, value: string) { + const trimmedValue = value.trim().toLowerCase(); + const newWords = [...words]; + newWords[index] = trimmedValue; + setWords(newWords); + if (value.endsWith(' ')) { + const nextIndex = index + 1; + if (nextIndex < WORD_COUNT) { + inputRefs.current[nextIndex]?.focus(); + } + } + } + + function handlePaste(index: number, e: React.ClipboardEvent) { + if (index === 0) { + const pasteData = e.clipboardData.getData('text'); + const pastedWords = pasteData.trim().toLowerCase().split(/\s+/); + if (pastedWords.length === WORD_COUNT) { + e.preventDefault(); + const next = Array(WORD_COUNT).fill(''); + pastedWords.forEach((word, i) => { + next[i] = word; + }); + setWords(next); + } + } + } + + function handleKeyDown(index: number, e: React.KeyboardEvent) { + if (e.key === 'Backspace' && !words[index] && index > 0) { + inputRefs.current[index - 1]?.focus(); + } + if (e.key === 'Enter') { + e.preventDefault(); + const nextIndex = index + 1; + if (nextIndex < WORD_COUNT) { + inputRefs.current[nextIndex]?.focus(); + } + } + } + + function handleFileSelect(event: React.ChangeEvent) { + const file = event.target.files?.[0]; + if (!file) return; + setKeyfileError(''); + const reader = new FileReader(); + reader.onload = e => { + try { + const keyfile = JSON.parse(e.target?.result as string) as Keyfile; + const mnemonic = importKeyfile(keyfile); + const importedWords = mnemonic.trim().split(/\s+/); + if (importedWords.length !== UI_CONSTANTS.MNEMONIC_WORD_COUNT) { + setKeyfileError('Invalid keyfile: expected 24 words'); + return; + } + const next = Array(WORD_COUNT).fill(''); + importedWords.forEach((word, i) => { + next[i] = word; + }); + setWords(next); + setShowKeyfileImport(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } catch (err) { + setKeyfileError(err instanceof Error ? err.message : 'Invalid keyfile format'); + } + }; + reader.readAsText(file); + } + + function handleCancelKeyfileImport() { + setShowKeyfileImport(false); + setKeyfileError(''); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + + async function handleContinue() { + if (!canContinue || isDiscovering) return; + + setDiscoverError(''); + setIsDiscovering(true); + try { + const mnemonic = words.join(' ').trim(); + const result = await queryV0Balance(mnemonic); + + 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: ${result.sourceAddress?.slice(0, 12)}... (see console for full address)`; + throw new Error(msg); + } + + setV0MigrationDraft({ + sourceAddress: result.sourceAddress, + v0Mnemonic: mnemonic, + v0Notes: result.v0Notes, + v0BalanceNock: result.totalNock, + migratedAmountNock: undefined, + feeNock: undefined, + keyfileName: undefined, + signRawTxPayload: undefined, + txId: undefined, + }); + 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 */} +
+ +

+ Transfer v0 funds +

+
+
+ +
+
+
+ {/* Icon and instructions - same as ImportScreen */} +
+
+ +
+

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

+
+ + {/* Or import from keyfile - same as ImportScreen */} + + + {/* 24-word input grid */} +
+ {Array.from({ length: 12 }).map((_, rowIndex) => ( +
+ {[0, 1].map(col => { + const index = rowIndex * 2 + col; + return ( +
+ + {index + 1} + + { + inputRefs.current[index] = el; + }} + type="text" + value={words[index] || ''} + onChange={e => handleWordChange(index, e.target.value)} + onKeyDown={e => handleKeyDown(index, e)} + onPaste={e => handlePaste(index, e)} + placeholder="word" + autoComplete="off" + spellCheck="false" + className="flex-1 min-w-0 bg-transparent font-sans font-medium text-[var(--color-text-primary)] placeholder:text-[var(--color-text-secondary)] outline-none" + style={{ + fontSize: 'var(--font-size-base)', + lineHeight: 'var(--line-height-snug)', + letterSpacing: '0.01em', + }} + /> +
+ ); + })} +
+ ))} +
+ + + + {discoverError && {discoverError}} +
+
+ + {/* Bottom buttons */} +
+
+ + +
+
+
+ + {/* Keyfile Import Modal - same as onboarding ImportScreen */} + {showKeyfileImport && ( +
+
+

+ Import from keyfile +

+

+ Select your keyfile to import your wallet. +

+ +
+ + + +
+ + {keyfileError && {keyfileError}} + + +
+
+ )} +
+ ); +} + diff --git a/extension/popup/screens/V0MigrationSubmittedScreen.tsx b/extension/popup/screens/V0MigrationSubmittedScreen.tsx new file mode 100644 index 0000000..668fa67 --- /dev/null +++ b/extension/popup/screens/V0MigrationSubmittedScreen.tsx @@ -0,0 +1,81 @@ +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(); + const sentAmount = v0MigrationDraft.migratedAmountNock ?? v0MigrationDraft.v0BalanceNock; + + function handleBackToOverview() { + resetV0MigrationDraft(); + navigate('home'); + } + + return ( +
+
+ +

Submitted

+
+
+ +
+
+
+ +
+

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

+

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

+
+ +
+
You sent
+
+
{sentAmount.toLocaleString()} NOCK
+
+
+ + +
+ +
+ +
+
+ ); +} + diff --git a/extension/popup/store.ts b/extension/popup/store.ts index c213579..1d1b996 100644 --- a/extension/popup/store.ts +++ b/extension/popup/store.ts @@ -51,6 +51,11 @@ export type Screen = | 'send-submitted' | 'sent' | 'receive' + | 'v0-migration-intro' + | 'v0-migration-setup' + | 'v0-migration-funds' + | 'v0-migration-review' + | 'v0-migration-submitted' | 'tx-details' // Approval screens @@ -102,6 +107,47 @@ interface AppStore { lastTransaction: TransactionDetails | null; setLastTransaction: (transaction: TransactionDetails | null) => void; + // UI-only draft state for transfering v0 funds flow + v0MigrationDraft: { + v0BalanceNock: number; + migratedAmountNock?: number; + feeNock?: number; + destinationWalletIndex: number | null; + keyfileName?: string; + sourceAddress?: string; + 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<{ + v0BalanceNock: number; + migratedAmountNock?: number; + feeNock?: number; + destinationWalletIndex: number | null; + keyfileName?: string; + sourceAddress?: string; + v0Mnemonic?: string; + v0Notes?: any[]; + signRawTxPayload?: { + rawTx: any; + notes: any[]; + spendConditions: any[]; + }; + txId?: string; + v0TxConfirmed?: boolean; + v0TxSkipped?: boolean; + }> + ) => void; + resetV0MigrationDraft: () => void; + // Pending connect request (for showing approval screen) pendingConnectRequest: ConnectRequest | null; setPendingConnectRequest: (request: ConnectRequest | null) => void; @@ -178,6 +224,20 @@ export const useStore = create((set, get) => ({ onboardingMnemonic: null, lastTransaction: null, + v0MigrationDraft: { + v0BalanceNock: 2500, + migratedAmountNock: undefined, + feeNock: undefined, + destinationWalletIndex: null, + keyfileName: undefined, + sourceAddress: undefined, + sourcePkh: undefined, + v0Notes: undefined, + signRawTxPayload: undefined, + txId: undefined, + v0TxConfirmed: undefined, + v0TxSkipped: undefined, + }, pendingConnectRequest: null, pendingSignRequest: null, pendingSignRawTxRequest: null, @@ -239,6 +299,34 @@ export const useStore = create((set, get) => ({ set({ lastTransaction: transaction }); }, + setV0MigrationDraft: value => { + set(state => ({ + v0MigrationDraft: { + ...state.v0MigrationDraft, + ...value, + }, + })); + }, + + resetV0MigrationDraft: () => { + set({ + v0MigrationDraft: { + v0BalanceNock: 2500, + migratedAmountNock: undefined, + feeNock: undefined, + destinationWalletIndex: null, + keyfileName: undefined, + sourceAddress: undefined, + v0Mnemonic: undefined, + v0Notes: undefined, + signRawTxPayload: undefined, + txId: undefined, + v0TxConfirmed: undefined, + v0TxSkipped: undefined, + }, + }); + }, + // Set pending connect request setPendingConnectRequest: (request: ConnectRequest | null) => { set({ pendingConnectRequest: request }); diff --git a/extension/shared/v0-migration.ts b/extension/shared/v0-migration.ts new file mode 100644 index 0000000..53690c3 --- /dev/null +++ b/extension/shared/v0-migration.ts @@ -0,0 +1,165 @@ +/** + * v0-to-v1 migration - delegates discovery and build to SDK. + */ + +import { ensureWasmInitialized } from './wasm-utils'; +import { getEffectiveRpcEndpoint } from './rpc-config'; +import { + buildV0MigrationTx as sdkBuildV0MigrationTx, + queryV0Balance as sdkQueryV0Balance, + type BuildV0MigrationTxResult, + type V0BalanceResult, +} from '@nockbox/iris-sdk'; +import wasm from './sdk-wasm.js'; +import { createBrowserClient } from './rpc-client-browser'; + +export type { V0BalanceResult }; + +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; + +/** + * 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 grpcEndpoint = await getEffectiveRpcEndpoint(); + return sdkQueryV0Balance(mnemonic, grpcEndpoint); +} + +/** + * 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, + targetV1Pkh?: string, + debug = false +): Promise { + await ensureWasmInitialized(); + 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 result; +} + +/** + * 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 | 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) { + 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 } = signRawTxPayload; + + if (debug) { + const debugPayload = { + rawTx: { id: rawTx?.id, version: rawTx?.version, spendsCount: rawTx?.spends?.length ?? 0 }, + notesCount: notes.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, + refundLock, + wasm.txEngineSettingsV1BythosDefault() + ); + } catch (e) { + console.error('[V0 Migration] TxBuilder.fromTx failed:', e); + throw e; + } + + const privateKey = wasm.PrivateKey.fromBytes(masterKey.privateKey); + try { + await builder.sign(privateKey); + } catch (e) { + console.error('[V0 Migration] builder.sign failed:', e); + throw e; + } finally { + privateKey.free(); + } + + try { + builder.validate(); + } catch (e) { + console.error('[V0 Migration] builder.validate failed:', e); + throw e; + } + + const signedTx = builder.build(); + const signedRawTx = wasm.nockchainTxToRawTx(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 cffd1cb..612aa06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,30 @@ "extraneous": true, "license": "MIT" }, +<<<<<<< HEAD +<<<<<<< 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) +======= +>>>>>>> 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", @@ -573,6 +597,8 @@ } }, "node_modules/@nockbox/iris-sdk": { +<<<<<<< HEAD +<<<<<<< HEAD "version": "0.2.0-alpha.4", "resolved": "git+ssh://git@github.com/nockbox/iris-sdk.git#faaf42be65fd833f798e16712e269dbe4d5bc84a", "license": "MIT", @@ -586,6 +612,30 @@ "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", +======= +======= +>>>>>>> 1d4df1d (some small changes) + "version": "0.1.1", + "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", + "@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", + "link": true +>>>>>>> f56b204 (Debug for migration) }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5",