From 78eb1af4f7bb0740ad4d365b3cccf943b4ed3d3f Mon Sep 17 00:00:00 2001 From: Gohlub <62673775+Gohlub@users.noreply.github.com> Date: Wed, 13 May 2026 07:31:16 -0400 Subject: [PATCH 1/8] fix: solves #45 (display fee for bridge) --- extension/popup/screens/SwapScreen.tsx | 61 ++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 9 deletions(-) diff --git a/extension/popup/screens/SwapScreen.tsx b/extension/popup/screens/SwapScreen.tsx index 5bd5033..6b813f0 100644 --- a/extension/popup/screens/SwapScreen.tsx +++ b/extension/popup/screens/SwapScreen.tsx @@ -7,7 +7,7 @@ import NockTextCircleContainer from '../assets/NockTextCircleContainer.svg'; import NockText from '../assets/NockText.svg'; import JustNText from '../assets/JustNText.svg'; import UpDownVec from '../assets/upDownvec.svg'; -import { MIN_BRIDGE_AMOUNT_NOCK, isEvmAddress } from '@nockbox/iris-sdk'; +import { BRIDGE_PROTOCOL_FEE_RATE, MIN_BRIDGE_AMOUNT_NOCK, isEvmAddress } from '@nockbox/iris-sdk'; import { formatWithCommas, parseAmount } from '../utils/format'; export function SwapScreen() { @@ -23,6 +23,16 @@ export function SwapScreen() { const spendableNock = wallet.spendableBalance; const amountNum = parseAmount(amount); + const bridgeProtocolFeeNock = + amountNum > 0 && !Number.isNaN(amountNum) ? amountNum * BRIDGE_PROTOCOL_FEE_RATE : 0; + const receiveAmountNock = + amountNum > 0 && !Number.isNaN(amountNum) ? Math.max(amountNum - bridgeProtocolFeeNock, 0) : 0; + const bridgeProtocolFeeAmountDisplay = bridgeProtocolFeeNock.toLocaleString('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: 4, + }); + const bridgeProtocolFeePercentLabel = `${(BRIDGE_PROTOCOL_FEE_RATE * 100).toFixed(1)}%`; + // Single consolidated message: never show two at once. Uses same wallet.spendableBalance as Home. // Don't show spendable-below-min while balance is loading (same pattern as HomeScreen skeleton). const consolidatedAmountError = useMemo(() => { @@ -68,12 +78,13 @@ export function SwapScreen() { } const hasDecimalPart = /\.\d/.test(amount.replace(/,/g, '')); - const displayAmount = amount - ? amountNum.toLocaleString('en-US', { - minimumFractionDigits: hasDecimalPart ? 2 : 0, - maximumFractionDigits: hasDecimalPart ? 2 : 0, - }) - : '0.00'; + const receiveDisplayAmount = + amountNum > 0 && !Number.isNaN(amountNum) + ? receiveAmountNock.toLocaleString('en-US', { + minimumFractionDigits: hasDecimalPart ? 2 : 0, + maximumFractionDigits: hasDecimalPart ? 2 : 0, + }) + : '0.00'; const usdValue = amountNum > 0 && priceUsd > 0 @@ -83,6 +94,23 @@ export function SwapScreen() { }) : null; + const receiveUsdValue = + receiveAmountNock > 0 && priceUsd > 0 + ? (receiveAmountNock * priceUsd).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + : null; + + const showReceiveEstimate = amountNum > 0 && !Number.isNaN(amountNum); + const receiveAmountDisplayText = showReceiveEstimate ? `~${receiveDisplayAmount}` : receiveDisplayAmount; + const receiveUsdDisplayText = + receiveUsdValue !== null + ? `~$${receiveUsdValue} USD` + : showReceiveEstimate + ? '— USD' + : '0 USD'; + const amountLineHeightPx = amountFontSizePx + 4; // Scale font only when amount text would overflow the available area. @@ -269,13 +297,13 @@ export function SwapScreen() { color: amount ? 'var(--color-text-primary)' : 'var(--color-text-muted)', }} > - {displayAmount} + {receiveAmountDisplayText}
- {usdValue !== null ? `$${usdValue} USD` : '0 USD'} + {receiveUsdDisplayText}
@@ -360,6 +388,21 @@ export function SwapScreen() { /> +
+
+
+ + Bridge fee {bridgeProtocolFeePercentLabel} + + + {bridgeProtocolFeeAmountDisplay} NOCK + +
+
+ {(error || consolidatedAmountError) && (
Date: Wed, 13 May 2026 07:35:28 -0400 Subject: [PATCH 2/8] fix: solves #41 (keyfile import removed from v0) --- .../popup/screens/V0MigrationSetupScreen.tsx | 117 ------------------ 1 file changed, 117 deletions(-) diff --git a/extension/popup/screens/V0MigrationSetupScreen.tsx b/extension/popup/screens/V0MigrationSetupScreen.tsx index 95e6b26..d067199 100644 --- a/extension/popup/screens/V0MigrationSetupScreen.tsx +++ b/extension/popup/screens/V0MigrationSetupScreen.tsx @@ -3,20 +3,15 @@ import { setV0MigrationMnemonic, 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, resetV0MigrationDraft } = 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)); @@ -76,40 +71,6 @@ export function V0MigrationSetupScreen() { } } - 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; @@ -196,20 +157,6 @@ export function V0MigrationSetupScreen() {

- {/* Or import from keyfile - same as ImportScreen */} - - {/* 24-word input grid */}
{Array.from({ length: 12 }).map((_, rowIndex) => ( @@ -308,70 +255,6 @@ export function V0MigrationSetupScreen() {
- - {/* Keyfile Import Modal - same as onboarding ImportScreen */} - {showKeyfileImport && ( -
-
-

- Import from keyfile -

-

- Select your keyfile to import your wallet. -

- -
- - - -
- - {keyfileError && {keyfileError}} - - -
-
- )} ); } From eb613ea9c08e11b65fb29f477ee4e60d9ce31ad8 Mon Sep 17 00:00:00 2001 From: Gohlub <62673775+Gohlub@users.noreply.github.com> Date: Wed, 13 May 2026 07:53:09 -0400 Subject: [PATCH 3/8] fix: solves #39 (dark icon) --- extension/popup/assets/SwapIconAsset-dark.svg | 10 ++++++ extension/popup/assets/swap_icon.svg | 3 -- extension/popup/components/icons/SwapIcon.tsx | 18 ++++++++++ extension/popup/screens/HomeScreen.tsx | 16 +++++---- extension/popup/styles.css | 33 +++++++++++++++++++ 5 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 extension/popup/assets/SwapIconAsset-dark.svg delete mode 100644 extension/popup/assets/swap_icon.svg create mode 100644 extension/popup/components/icons/SwapIcon.tsx diff --git a/extension/popup/assets/SwapIconAsset-dark.svg b/extension/popup/assets/SwapIconAsset-dark.svg new file mode 100644 index 0000000..392dbbd --- /dev/null +++ b/extension/popup/assets/SwapIconAsset-dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/extension/popup/assets/swap_icon.svg b/extension/popup/assets/swap_icon.svg deleted file mode 100644 index d019c47..0000000 --- a/extension/popup/assets/swap_icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/extension/popup/components/icons/SwapIcon.tsx b/extension/popup/components/icons/SwapIcon.tsx new file mode 100644 index 0000000..3f43110 --- /dev/null +++ b/extension/popup/components/icons/SwapIcon.tsx @@ -0,0 +1,18 @@ +/** + * SwapIcon — matches home action row; uses currentColor for light/dark themes. + */ + +interface IconProps { + className?: string; +} + +export function SwapIcon({ className = 'w-5 h-5' }: IconProps) { + return ( + + + + ); +} diff --git a/extension/popup/screens/HomeScreen.tsx b/extension/popup/screens/HomeScreen.tsx index c3a66c2..da7515f 100644 --- a/extension/popup/screens/HomeScreen.tsx +++ b/extension/popup/screens/HomeScreen.tsx @@ -1,6 +1,5 @@ import { useState, useLayoutEffect, useEffect, useRef, useMemo } from 'react'; import { useStore } from '../store'; -import { useTheme } from '../contexts/ThemeContext'; import { truncateAddress } from '../utils/format'; import { send } from '../utils/messaging'; import { ERROR_CODES, INTERNAL_METHODS, NOCK_TO_NICKS, STORAGE_KEYS } from '../../shared/constants'; @@ -33,7 +32,8 @@ import { resolveCounterpartyAccount } from '../../shared/account-lock-roots'; import { isMigrationWalletTx } from '../../shared/v0-migration'; import { isBridgeWalletTx } from '../../shared/bridge-config'; import { useLockRootAccountMap } from '../hooks/useLockRootAccountMap'; -import SwapIconAsset from '../assets/swap_icon.svg'; +import { SwapIcon } from '../components/icons/SwapIcon'; +import SwapIconAssetDark from '../assets/SwapIconAsset-dark.svg'; import BaseIconAsset from '../assets/base_icon.svg'; import { SwapSubmittedToast } from '../components/SwapSubmittedToast'; @@ -60,7 +60,6 @@ export function HomeScreen() { refreshWalletAccounts, setSettingsAccountAddress, } = useStore(); - const { theme } = useTheme(); const lockRootToAccount = useLockRootAccountMap(wallet.accounts); const [balanceHidden, setBalanceHidden] = useState(false); @@ -874,9 +873,14 @@ export function HomeScreen() { }} onClick={() => navigate('swap')} > -
- Swap -
+ + + + Swap - {isSending && ( -
-
-
-
Signing and submitting transaction
-
- Signing and submitting your transaction. This could take a while. -
-
-
- )}
); } diff --git a/extension/popup/screens/SendScreen.tsx b/extension/popup/screens/SendScreen.tsx index ae68b91..04736c1 100644 --- a/extension/popup/screens/SendScreen.tsx +++ b/extension/popup/screens/SendScreen.tsx @@ -4,7 +4,13 @@ import { useTheme } from '../contexts/ThemeContext'; import { truncateAddress } from '../utils/format'; import { send } from '../utils/messaging'; import { INTERNAL_METHODS, NOCK_TO_NICKS } from '../../shared/constants'; -import { formatNock, isDustAmount, MIN_SENDABLE_NOCK } from '../../shared/currency'; +import { + formatNock, + isDustAmount, + MIN_SENDABLE_NOCK, + nickToNock, + truncateNockToDecimals, +} from '../../shared/currency'; import type { SubAccount } from '../../shared/types'; import { AccountIcon } from '../components/AccountIcon'; import { ChevronLeftIcon } from '../components/icons/ChevronLeftIcon'; @@ -13,11 +19,39 @@ import { base58 } from '@scure/base'; import PencilEditIcon from '../assets/pencil-edit-icon.svg'; import CheckmarkIcon from '../assets/checkmark-pencil-icon.svg'; import InfoIcon from '../assets/info-icon.svg'; +import { guard } from '@nockbox/iris-sdk/wasm'; +import { ensureWasmInitialized } from '../../shared/wasm-utils'; function formatInt(n: number) { return n.toLocaleString('en-US', { maximumFractionDigits: 0 }); } +/** + * PKH string safe for WASM fee / max-send simulation only. Uses the recipient when it is a + * canonical V1 digest; otherwise the current account PKH (fee does not depend on recipient). + */ +function recipientPkhForFeeEstimate(recipientTrimmed: string, selfPkh: string | undefined): string { + const self = (selfPkh || '').trim(); + const r = (recipientTrimmed || '').trim(); + if (!self) return r; + if (!r) return self; + try { + const bytes = base58.decode(r); + if (bytes.length !== 40) return self; + try { + if (!guard.isDigest(r)) return self; + } catch { + return self; + } + return r; + } catch { + return self; + } +} + +/** Fee field: truncate to 2 decimals, no commas (matches plain fee input). */ +const FEE_FORMAT_OPTIONS = { mode: 'truncate' as const, useGrouping: false }; + export function SendScreen() { const { theme } = useTheme(); const { navigate, wallet, syncWallet, setLastTransaction, fetchBalance } = useStore(); @@ -35,10 +69,16 @@ export function SendScreen() { const [errorType, setErrorType] = useState<'fee_too_low' | 'general' | null>(null); const [isFeeManuallyEdited, setIsFeeManuallyEdited] = useState(false); const [isCalculatingFee, setIsCalculatingFee] = useState(false); - const [minimumFee, setMinimumFee] = useState(null); // Minimum fee from WASM calculation + /** Whole-nick fee from the last WASM estimate (authoritative minimum / send fee when not overridden). */ + const [minimumFeeNicks, setMinimumFeeNicks] = useState(null); const [isSendingMax, setIsSendingMax] = useState(false); // Track if user is sending entire balance const [isLoadingBalance, setIsLoadingBalance] = useState(false); // Track balance refresh after account switch + /** Nicks to pass at broadcast; kept in sync with auto-estimate, max-send, or manual save. */ + const feeSendNicksRef = useRef(null); + /** Supersede in-flight debounced fee estimates when amount/recipient changes. */ + const feeEstimateSeqRef = useRef(0); + // Get real accounts from vault (filter out hidden accounts) const accounts = (wallet.accounts || []).filter(acc => !acc.hidden); const currentAccount = @@ -46,10 +86,11 @@ export function SendScreen() { // Use spendable balance (only UTXOs that are not in_flight - can be spent NOW) const currentBalance = wallet.spendableBalance; - // Refresh balance when screen mounts to ensure spendable balance is accurate + // Refresh when this screen is shown or the selected account changes (avoids stale spendable + // after switching wallets on Home then opening Send, or rapid switches on this screen). useEffect(() => { - fetchBalance(); - }, [fetchBalance]); + void fetchBalance(); + }, [fetchBalance, wallet.currentAccount?.address]); // Account switching handler async function handleSwitchAccount(accountAddress: string) { @@ -60,7 +101,9 @@ export function SendScreen() { setAmount(''); setFee(''); setEditedFee(''); - setMinimumFee(null); + setMinimumFeeNicks(null); + feeSendNicksRef.current = null; + feeEstimateSeqRef.current += 1; setIsFeeManuallyEdited(false); setIsSendingMax(false); setError(''); @@ -75,13 +118,18 @@ export function SendScreen() { }>(INTERNAL_METHODS.SWITCH_ACCOUNT, [accountAddress]); if (result?.ok && result.account) { + const latest = useStore.getState().wallet; + const addr = result.account.address; + const cachedBal = latest.accountBalances?.[addr] ?? 0; + const cachedSpendable = latest.accountSpendableBalances?.[addr] ?? cachedBal; const updatedWallet = { - ...wallet, + ...latest, currentAccount: result.account, - address: result.account.address, - activeSeedSourceId: result.activeSeedSourceId ?? wallet.activeSeedSourceId, - balance: wallet.accountBalances?.[result.account.address] ?? 0, - spendableBalance: wallet.accountSpendableBalances?.[result.account.address] ?? 0, + address: addr, + activeSeedSourceId: result.activeSeedSourceId ?? latest.activeSeedSourceId, + balance: cachedBal, + availableBalance: cachedBal, + spendableBalance: cachedSpendable, }; syncWallet(updatedWallet); // Refresh balance to ensure spendable balance is accurate for new account @@ -98,25 +146,9 @@ export function SendScreen() { setError(''); try { - // Use recipient address if valid, otherwise use a dummy address for estimation - let addressToUse = receiverAddress.trim(); - - if (addressToUse) { - try { - const bytes = base58.decode(addressToUse); - if (bytes.length !== 40) { - addressToUse = ''; // Invalid, will use dummy - } - } catch { - addressToUse = ''; // Invalid base58, will use dummy - } - } - - // If no valid address, use dummy (fee doesn't depend on recipient) - if (!addressToUse) { - const dummyBytes = new Uint8Array(40).fill(0); - addressToUse = base58.encode(dummyBytes); - } + await ensureWasmInitialized(); + const selfPkh = currentAccount?.address || wallet.address || undefined; + const addressToUse = recipientPkhForFeeEstimate(receiverAddress, selfPkh); const result = await send<{ maxAmount?: number; @@ -142,9 +174,11 @@ export function SendScreen() { const feeNock = result.fee / NOCK_TO_NICKS; setAmount(formatNock(maxAmountNock, 5)); - setFee(feeNock.toString()); - setEditedFee(feeNock.toString()); - setMinimumFee(feeNock); + const feeStr = formatNock(feeNock, 2, FEE_FORMAT_OPTIONS); + setFee(feeStr); + setEditedFee(feeStr); + setMinimumFeeNicks(result.fee); + feeSendNicksRef.current = result.fee; setIsFeeManuallyEdited(true); // Lock fee for max send setError(''); setErrorType(null); @@ -165,10 +199,13 @@ export function SendScreen() { } function handleSaveFee() { - const feeNum = parseFloat(editedFee); + const feeNum = parseFloat(editedFee.replace(/,/g, '')); if (!isNaN(feeNum) && feeNum >= 0) { - // Validate against minimum fee if we have one - if (minimumFee !== null && feeNum < minimumFee) { + const feeSavedNum = truncateNockToDecimals(feeNum, 2); + const feeSaved = formatNock(feeNum, 2, FEE_FORMAT_OPTIONS); + const feeNicksSaved = Math.ceil(feeSavedNum * NOCK_TO_NICKS - 1e-9); + feeSendNicksRef.current = feeNicksSaved; + if (minimumFeeNicks !== null && feeNicksSaved < minimumFeeNicks) { setError('Fee too low.'); setErrorType('fee_too_low'); // Still allow saving the fee, but show warning @@ -176,7 +213,8 @@ export function SendScreen() { setError(''); setErrorType(null); } - setFee(editedFee); + setFee(feeSaved); + setEditedFee(feeSaved); setIsFeeManuallyEdited(true); // Mark as manually edited - stops auto-updates } setIsEditingFee(false); @@ -242,14 +280,20 @@ export function SendScreen() { return; } - const feeNum = parseFloat(fee); + const feeNum = parseFloat(fee.replace(/,/g, '')); if (!fee || isNaN(feeNum) || feeNum < 0) { setError('Please enter a valid fee'); return; } + const feeNicksForSend = + feeSendNicksRef.current !== null + ? feeSendNicksRef.current + : Math.ceil(feeNum * NOCK_TO_NICKS - 1e-9); + const feeNockForSend = nickToNock(feeNicksForSend); + // Check if user has sufficient spendable balance for amount + fee - const totalNeeded = amountNum + feeNum; + const totalNeeded = amountNum + feeNockForSend; if (totalNeeded > currentBalance) { setError(`Insufficient spendable balance`); return; @@ -259,7 +303,8 @@ export function SendScreen() { setLastTransaction({ txid: '', // Will be generated when actually sent amount: amountNum, - fee: feeNum, + fee: feeNockForSend, + feeNicks: feeNicksForSend, to: receiverAddress.trim(), from: currentAccount?.address, sendMax: isSendingMax, // Flag for sweep transaction (all UTXOs to recipient) @@ -353,47 +398,34 @@ export function SendScreen() { const amountNum = parseFloat(amount.replace(/,/g, '')); if (isNaN(amountNum) || amountNum <= 0) return; - // Use recipient address if provided and valid, otherwise use a dummy address - // (fee doesn't depend on recipient address, only on amount/UTXOs needed) - let addressToUse = receiverAddress.trim(); - - // Validate address if provided, otherwise use dummy - if (addressToUse) { - try { - const bytes = base58.decode(addressToUse); - if (bytes.length !== 40) { - addressToUse = ''; // Invalid, will use dummy - } - } catch { - addressToUse = ''; // Invalid base58, will use dummy - } - } - - // If no valid address provided, use a dummy address for estimation - // The actual recipient doesn't affect fee - only amount/UTXOs matter - if (!addressToUse) { - // Dummy V1 PKH address (8 byte version + 32 byte PKH digest) - const dummyBytes = new Uint8Array(40).fill(0); - addressToUse = base58.encode(dummyBytes); - } - // Show loading state immediately setIsCalculatingFee(true); // Debounce: wait 500ms before estimating to avoid excessive WASM operations + const seq = ++feeEstimateSeqRef.current; const timeoutId = setTimeout(async () => { try { + await ensureWasmInitialized(); + if (seq !== feeEstimateSeqRef.current) return; + + const selfPkh = currentAccount?.address || wallet.address || undefined; + const addressToUse = recipientPkhForFeeEstimate(receiverAddress, selfPkh); + const amountNicks = Math.floor(amountNum * NOCK_TO_NICKS); const result = await send<{ fee?: number; error?: string }>( INTERNAL_METHODS.ESTIMATE_TRANSACTION_FEE, [addressToUse, amountNicks] ); + if (seq !== feeEstimateSeqRef.current) return; + if (result?.fee) { const feeNock = result.fee / NOCK_TO_NICKS; - setFee(feeNock.toString()); - setEditedFee(feeNock.toString()); - setMinimumFee(feeNock); // Store as minimum required fee + const feeStr = formatNock(feeNock, 2, FEE_FORMAT_OPTIONS); + setFee(feeStr); + setEditedFee(feeStr); + setMinimumFeeNicks(result.fee); + feeSendNicksRef.current = result.fee; setError(''); setErrorType(null); } else if (result?.error) { @@ -415,7 +447,7 @@ export function SendScreen() { clearTimeout(timeoutId); setIsCalculatingFee(false); }; - }, [receiverAddress, amount, isFeeManuallyEdited]); + }, [receiverAddress, amount, isFeeManuallyEdited, currentAccount?.address, wallet.address]); // ----------------------------------------------------------------------------- @@ -778,13 +810,18 @@ export function SendScreen() { }} > {error} - {errorType === 'fee_too_low' && minimumFee !== null && ( + {errorType === 'fee_too_low' && minimumFeeNicks !== null && ( - {isSending && ( -
-
-
-
Signing and submitting transaction
-
- Signing and submitting your transaction. Don't close the popup. This could take a - while. -
-
-
- )}
); } diff --git a/extension/popup/store.ts b/extension/popup/store.ts index e8c9752..2b72eb3 100644 --- a/extension/popup/store.ts +++ b/extension/popup/store.ts @@ -24,6 +24,9 @@ let v0MigrationMnemonic: string | undefined; let onboardingPassword: string | undefined; let walletTransactionsFetchInFlight: { address: string; promise: Promise } | null = null; +/** Suppresses stale `isBalanceFetching: false` when multiple `fetchBalance` calls overlap (rapid account switch). Resets when the popup unloads. */ +let balanceFetchGeneration = 0; + export function setV0MigrationMnemonic(mnemonic: string | undefined) { v0MigrationMnemonic = mnemonic; } @@ -622,12 +625,19 @@ export const useStore = create((set, get) => ({ // Fetch balance from UTXO store for all accounts // Also syncs UTXOs from chain (runs in popup context where WASM works) fetchBalance: async () => { + const generation = ++balanceFetchGeneration; + const clearFetchingIfLatest = () => { + if (generation === balanceFetchGeneration) { + set({ isBalanceFetching: false }); + } + }; + try { // Don't attempt to sync UTXOs while the vault is locked. SYNC_UTXOS // requires the encryption key to persist results; calling it while locked // yields cascading "Cannot save account data" / "Vault is locked" errors. if (get().wallet.locked) { - set({ isBalanceFetching: false }); + clearFetchingIfLatest(); return; } @@ -637,7 +647,7 @@ export const useStore = create((set, get) => ({ const currentAccount = get().wallet.currentAccount; if (!currentAccount || accounts.length === 0) { - set({ isBalanceFetching: false }); + clearFetchingIfLatest(); return; } @@ -744,11 +754,11 @@ export const useStore = create((set, get) => ({ accountSpendableBalances, accountBalanceDetails, }, - isBalanceFetching: false, }); + clearFetchingIfLatest(); } catch (error) { console.error('[Store] Failed to fetch balance:', error); - set({ isBalanceFetching: false }); + clearFetchingIfLatest(); } }, diff --git a/extension/shared/currency.ts b/extension/shared/currency.ts index 7ee1c06..8052ac0 100644 --- a/extension/shared/currency.ts +++ b/extension/shared/currency.ts @@ -93,38 +93,110 @@ export function isDustAmount(nockAmount: number): boolean { return roundedNick === 0; } +/** + * Options for {@link formatNock}. Defaults preserve historical rounding + thousands separators. + */ +export type FormatNockOptions = { + /** `round` (default): `toFixed`-style rounding. `truncate`: floor toward zero at `maxDecimals`. */ + mode?: 'round' | 'truncate'; + /** When false, omit thousands separators (e.g. plain numeric inputs). Default true. */ + useGrouping?: boolean; +}; + +/** + * Truncate nonnegative NOCK toward zero to at most `decimalPlaces` fractional digits. + */ +export function truncateNockToDecimals(nockAmount: number, decimalPlaces: number): number { + if (!Number.isFinite(nockAmount) || nockAmount < 0) { + return 0; + } + const scale = 10 ** decimalPlaces; + return Math.floor(nockAmount * scale + 1e-9) / scale; +} + /** * Format NOCK for display with smart decimal precision and thousands separators * * Shows minimum decimals needed (up to maxDecimals) with commas for readability: * - 2.5 → "2.5" (not "2.50") - * - 1000.126 → "1,000.13" (default max 2) + * - 1000.126 → "1,000.13" (default max 2, round) * - 100.00 → "100" * * @param nockAmount - Amount in NOCK * @param maxDecimals - Maximum decimal places (default: 2) - * @returns Formatted NOCK string with minimal decimals and thousands separators + * @param options - Optional rounding mode and grouping (defaults: round, grouped) + * @returns Formatted NOCK string with minimal decimals and optional thousands separators * * @example * formatNock(2.5) // "2.5" * formatNock(1000.126) // "1,000.13" * formatNock(100.00) // "100" * formatNock(1000.12345, 5) // "1,000.12345" — pass higher max where precision matters + * formatNock(1.239, 2, { mode: 'truncate', useGrouping: false }) // "1.23" */ -export function formatNock(nockAmount: number, maxDecimals: number = 2): string { - // Round to max decimals first - const rounded = Number(nockAmount.toFixed(maxDecimals)); +export function formatNock( + nockAmount: number, + maxDecimals: number = 2, + options?: FormatNockOptions +): string { + const mode = options?.mode ?? 'round'; + const useGrouping = options?.useGrouping ?? true; + + if (!Number.isFinite(nockAmount)) { + return String(nockAmount); + } - // Split into integer and decimal parts - const [integerPart, decimalPart] = rounded.toString().split('.'); + const scale = 10 ** maxDecimals; + const adjusted = + mode === 'truncate' + ? truncateNockToDecimals(Math.max(nockAmount, 0), maxDecimals) + : Number(nockAmount.toFixed(maxDecimals)); - // Add thousands separators to integer part - const formattedInteger = parseInt(integerPart).toLocaleString('en-US'); + const [integerPart, decimalPart] = adjusted.toString().split('.'); + const formattedInteger = useGrouping + ? parseInt(integerPart, 10).toLocaleString('en-US') + : integerPart; - // Recombine with decimal part (if exists) return decimalPart ? `${formattedInteger}.${decimalPart}` : formattedInteger; } +const TX_ENGINE_INSUFFICIENT_FEE_RE = + /Insufficient fee for transaction \(needed: ([\d.]+), got: ([\d.]+)\)/; + +/** + * Parses tx-engine `Nicks` display form `whole.remainder` where `remainder` is + * `total_nicks % 65536` (not a decimal fraction of a NOCK). + */ +function parseCompoundNicksDisplayToTotalNicks(token: string): number | null { + const t = token.trim(); + const m = /^(\d+)\.(\d+)$/.exec(t); + if (!m) return null; + const whole = BigInt(m[1]); + const rem = BigInt(m[2]); + if (rem < 0n || rem >= 65536n) return null; + const total = whole * 65536n + rem; + if (total > BigInt(Number.MAX_SAFE_INTEGER)) return null; + return Number(total); +} + +/** + * Rewrites tx-engine insufficient-fee messages to decimal NOCK (not compound `Nicks` text). + */ +export function rewriteInsufficientFeeErrorToDecimalNock(message: string): string { + return message.replace( + TX_ENGINE_INSUFFICIENT_FEE_RE, + (full, needRaw: string, gotRaw: string) => { + const needN = parseCompoundNicksDisplayToTotalNicks(needRaw); + const gotN = parseCompoundNicksDisplayToTotalNicks(gotRaw); + if (needN === null || gotN === null) return full; + const opts = { mode: 'round' as const, useGrouping: false }; + const needStr = formatNock(nickToNock(needN), 8, opts); + const gotStr = formatNock(nickToNock(gotN), 8, opts); + return `Insufficient fee for transaction (needed: ${needStr} NOCK, got: ${gotStr} NOCK)`; + } + ); +} + /** * Format NICK with thousands separators * diff --git a/extension/shared/nockblocks-client.ts b/extension/shared/nockblocks-client.ts index 0cb9dde..9df49b3 100644 --- a/extension/shared/nockblocks-client.ts +++ b/extension/shared/nockblocks-client.ts @@ -80,11 +80,18 @@ function getApiUrl(): string { } function normalizeTransaction(transaction: NockblocksTransaction): NockblocksTransaction { + const spends = transaction.spends || transaction.transaction?.spends || []; + const outputs = transaction.outputs || transaction.transaction?.outputs || []; return { ...transaction, txId: transaction.txId || transaction.id, - spends: transaction.spends || transaction.transaction?.spends || [], - outputs: transaction.outputs || transaction.transaction?.outputs || [], + spends, + outputs, + // Some RPC surfaces spends/outputs only on `transaction`; keep nested body in sync so + // callers that read `tx.transaction` (e.g. history sync) still see seeds + noteData. + transaction: transaction.transaction + ? { ...transaction.transaction, spends, outputs } + : { spends, outputs }, }; } diff --git a/extension/shared/types.ts b/extension/shared/types.ts index 980725d..683ffee 100644 --- a/extension/shared/types.ts +++ b/extension/shared/types.ts @@ -95,6 +95,8 @@ export interface TransactionDetails { amount: number; /** Transaction fee in NOCK */ fee: number; + /** Fee in whole nicks as used for broadcast (matches WASM feeUsed when set). */ + feeNicks?: number; /** Recipient address */ to?: string; /** Sender address */ diff --git a/extension/shared/vault.ts b/extension/shared/vault.ts index 89de3c1..c3e7cf0 100644 --- a/extension/shared/vault.ts +++ b/extension/shared/vault.ts @@ -62,11 +62,13 @@ import { createNockblocksClient, isNockblocksConfigured, type NockblocksOutput, + type NockblocksSeed, type NockblocksSpend, type NockblocksTransaction, } from './nockblocks-client.js'; import { buildBridgeTransaction, validateBridgeTransaction } from '@nockbox/iris-sdk'; import { BRIDGE_CONFIG } from './bridge-config'; +import { rewriteInsufficientFeeErrorToDecimalNock } from './currency'; async function txEngineSettings(blockHeight: number): Promise { return await getTxEngineSettingsForHeight(blockHeight); @@ -248,6 +250,44 @@ interface VaultState { enc: EncryptedVault | null; } +function feeEstimateUserFacingError(error: unknown, kind: 'fee' | 'max'): string { + const raw = error instanceof Error ? error.message : String(error); + if (/digest|canonical|base58|iris-wasm|Digest guard/i.test(raw)) { + return kind === 'max' + ? 'Could not estimate the maximum send for that recipient. Use a valid Nockchain address.' + : 'Could not estimate the network fee for that recipient. Use a valid Nockchain address.'; + } + const readable = rewriteInsufficientFeeErrorToDecimalNock(raw); + return (kind === 'max' ? 'Max send estimation failed: ' : 'Fee estimation failed: ') + readable; +} + +/** + * Collect `id` / `txHash` / `trackingTxId` for matching a Nockblocks row to an existing + * {@link WalletTransaction} (e.g. bridge uses UUID `id` + on-chain `txHash` only until we align). + */ +function walletTxIdentityStrings(tx: Partial): string[] { + const out = new Set(); + for (const key of ['id', 'txHash', 'trackingTxId'] as const) { + const v = tx[key]; + if (typeof v === 'string') { + const t = v.trim(); + if (t.length > 0) out.add(t); + } + } + return [...out]; +} + +function walletTxSharesAnyIdentifier( + a: Partial, + b: Partial +): boolean { + const sa = new Set(walletTxIdentityStrings(a)); + for (const x of walletTxIdentityStrings(b)) { + if (sa.has(x)) return true; + } + return false; +} + export class Vault { private state: VaultState = { locked: true, @@ -1490,18 +1530,10 @@ export class Vault { tx: Partial ): number { const transactions = this.walletTxStore[accountAddress] || []; - const trackingTxId = tx.trackingTxId || tx.txHash; - - return transactions.findIndex(existing => { - if (tx.id && existing.id === tx.id) return true; - if ( - trackingTxId && - (existing.trackingTxId === trackingTxId || existing.txHash === trackingTxId) - ) { - return true; - } - return false; - }); + if (walletTxIdentityStrings(tx).length === 0) { + return -1; + } + return transactions.findIndex(existing => walletTxSharesAnyIdentifier(tx, existing)); } getAccountSyncState(accountAddress: string): AccountSyncState { @@ -1765,6 +1797,34 @@ export class Vault { return seedLockRoots.size === 1 ? [...seedLockRoots][0] : undefined; } + private nockblocksNoteDataHasBridgePayload(noteData: NockblocksSeed['noteData']): boolean { + if (!noteData || typeof noteData !== 'object') { + return false; + } + return Object.prototype.hasOwnProperty.call(noteData, BRIDGE_CONFIG.noteDataKey); + } + + private nockblocksTxHasBridgeInNoteData( + spends: NockblocksSpend[], + outputs: NockblocksOutput[] + ): boolean { + for (const spend of spends) { + for (const seed of spend.seeds || []) { + if (this.nockblocksNoteDataHasBridgePayload(seed.noteData)) { + return true; + } + } + } + for (const output of outputs) { + for (const seed of output.seeds || []) { + if (this.nockblocksNoteDataHasBridgePayload(seed.noteData)) { + return true; + } + } + } + return false; + } + private getTransactionTrackingId(tx: WalletTransaction): string | undefined { return tx.trackingTxId || tx.txHash; } @@ -1835,13 +1895,20 @@ export class Vault { ? accountAddress : this.getUniqueLockRootFromOutputs(externalOutputs); - // v0→v1 migration spends carry `version: "v0_to_v1"` and a `signaturesV0[]` - const migrationSpend = externalSpends.find( - (spend: NockblocksSpend) => spend.version === 'v0_to_v1' - ); - const migrationV0Pubkey = migrationSpend?.signaturesV0?.find( - sig => typeof sig?.pubkey === 'string' && sig.pubkey.length > 0 - )?.pubkey; + // v0→v1 migration: spends normally include `version: "v0_to_v1"` + `signaturesV0[].pubkey`. + // Indexers sometimes omit `version` while still returning signatures — still treat as v0. + const migrationV0Pubkey = externalSpends + .flatMap((spend: NockblocksSpend) => spend.signaturesV0 || []) + .find(sig => typeof sig?.pubkey === 'string' && sig.pubkey.trim().length > 0) + ?.pubkey?.trim(); + + const migrationSpend = externalSpends.find((spend: NockblocksSpend) => { + const v = spend.version; + return v === 'v0_to_v1' || (typeof v === 'string' && v.toLowerCase() === 'v0_to_v1'); + }); + + const hasV0MigrationSpendEvidence = + Boolean(migrationSpend) || (direction === 'incoming' && Boolean(migrationV0Pubkey)); const sender = direction === 'incoming' @@ -1850,13 +1917,17 @@ export class Vault { const migrationFromV0 = direction === 'incoming' && - (Boolean(migrationSpend) || + (hasV0MigrationSpendEvidence || (typeof sender === 'string' && sender.length >= 60 && typeof recipient === 'string' && recipient.length > 0 && recipient.length < 60)); + const isBridgeFromNockblocksData = + (direction === 'outgoing' || direction === 'self') && + this.nockblocksTxHasBridgeInNoteData(spends, outputs); + return { id: txId, txHash: txId, @@ -1872,6 +1943,7 @@ export class Vault { recipient, sender, ...(migrationFromV0 ? { migrationFromV0: true as const } : {}), + ...(isBridgeFromNockblocksData ? { kind: 'bridge' as const } : {}), blockId: tx.blockId, confirmedAtBlock: tx.blockHeight, confirmedAtTimestamp: tx.timestamp, @@ -3109,9 +3181,7 @@ export class Vault { } } catch (error) { console.error('[Vault] Fee estimation failed:', error); - return { - error: 'Fee estimation failed: ' + (error instanceof Error ? error.message : String(error)), - }; + return { error: feeEstimateUserFacingError(error, 'fee') }; } } @@ -3229,10 +3299,7 @@ export class Vault { } } catch (error) { console.error('[Vault] Max send estimation failed:', error); - return { - error: - 'Max send estimation failed: ' + (error instanceof Error ? error.message : String(error)), - }; + return { error: feeEstimateUserFacingError(error, 'max') }; } } @@ -3337,8 +3404,9 @@ export class Vault { } } catch (error) { console.error('[Vault] Error sending transaction:', error); + const rawMsg = error instanceof Error ? error.message : String(error); return { - error: `Failed to send transaction: ${error instanceof Error ? error.message : String(error)}`, + error: `Failed to send transaction: ${rewriteInsufficientFeeErrorToDecimalNock(rawMsg)}`, }; } } @@ -3539,8 +3607,9 @@ export class Vault { } } + const rawMsg = error instanceof Error ? error.message : String(error); return { - error: `Transaction failed: ${error instanceof Error ? error.message : String(error)}`, + error: `Transaction failed: ${rewriteInsufficientFeeErrorToDecimalNock(rawMsg)}`, }; } }); @@ -3690,10 +3759,12 @@ export class Vault { walletTx.fee = Number(buildCtx.bridgeResult.fee); walletTx.txHash = signedTxId; + walletTx.trackingTxId = signedTxId; walletTx.status = 'broadcasted_unconfirmed'; await this.updateWalletTransaction(currentAccount.address, walletTxId, { fee: walletTx.fee, txHash: signedTxId, + trackingTxId: signedTxId, status: 'broadcasted_unconfirmed', }); @@ -3748,8 +3819,9 @@ export class Vault { return { fee: Number(buildCtx.bridgeResult.fee) }; } catch (error) { console.error('[Vault] Bridge fee estimation failed:', error); + const rawMsg = error instanceof Error ? error.message : String(error); return { - error: 'Fee estimation failed: ' + (error instanceof Error ? error.message : String(error)), + error: 'Fee estimation failed: ' + rewriteInsufficientFeeErrorToDecimalNock(rawMsg), }; } } @@ -3817,8 +3889,9 @@ export class Vault { ); } catch (error) { console.error('[Vault] Bridge transaction failed:', error); + const rawMsg = error instanceof Error ? error.message : String(error); return { - error: `Bridge failed: ${error instanceof Error ? error.message : String(error)}`, + error: `Bridge failed: ${rewriteInsufficientFeeErrorToDecimalNock(rawMsg)}`, }; } }); diff --git a/package-lock.json b/package-lock.json index d219e5d..7b2824f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -231,7 +231,7 @@ }, "node_modules/@nockbox/iris-sdk": { "version": "0.2.0-alpha.4", - "resolved": "git+ssh://git@github.com/nockbox/iris-sdk.git#5971b2a89755aaa04cddc8e333be3db30290d305", + "resolved": "git+ssh://git@github.com/nockbox/iris-sdk.git#adfce93d0d738e3d07b8cd313e69443d41345820", "license": "MIT", "dependencies": { "@nockbox/iris-wasm": "^0.2.0-alpha.10", From 5575036dd4c8e11ca3d2716ae18f58b25004025f Mon Sep 17 00:00:00 2001 From: Gohlub <62673775+Gohlub@users.noreply.github.com> Date: Wed, 13 May 2026 12:48:01 -0400 Subject: [PATCH 6/8] chore: format for CI --- extension/popup/screens/SwapScreen.tsx | 10 ++++------ extension/shared/currency.ts | 21 +++++++++------------ extension/shared/vault.ts | 10 +++++----- 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/extension/popup/screens/SwapScreen.tsx b/extension/popup/screens/SwapScreen.tsx index 6b813f0..6e36d5d 100644 --- a/extension/popup/screens/SwapScreen.tsx +++ b/extension/popup/screens/SwapScreen.tsx @@ -103,13 +103,11 @@ export function SwapScreen() { : null; const showReceiveEstimate = amountNum > 0 && !Number.isNaN(amountNum); - const receiveAmountDisplayText = showReceiveEstimate ? `~${receiveDisplayAmount}` : receiveDisplayAmount; + const receiveAmountDisplayText = showReceiveEstimate + ? `~${receiveDisplayAmount}` + : receiveDisplayAmount; const receiveUsdDisplayText = - receiveUsdValue !== null - ? `~$${receiveUsdValue} USD` - : showReceiveEstimate - ? '— USD' - : '0 USD'; + receiveUsdValue !== null ? `~$${receiveUsdValue} USD` : showReceiveEstimate ? '— USD' : '0 USD'; const amountLineHeightPx = amountFontSizePx + 4; diff --git a/extension/shared/currency.ts b/extension/shared/currency.ts index 8052ac0..da4f8d0 100644 --- a/extension/shared/currency.ts +++ b/extension/shared/currency.ts @@ -183,18 +183,15 @@ function parseCompoundNicksDisplayToTotalNicks(token: string): number | null { * Rewrites tx-engine insufficient-fee messages to decimal NOCK (not compound `Nicks` text). */ export function rewriteInsufficientFeeErrorToDecimalNock(message: string): string { - return message.replace( - TX_ENGINE_INSUFFICIENT_FEE_RE, - (full, needRaw: string, gotRaw: string) => { - const needN = parseCompoundNicksDisplayToTotalNicks(needRaw); - const gotN = parseCompoundNicksDisplayToTotalNicks(gotRaw); - if (needN === null || gotN === null) return full; - const opts = { mode: 'round' as const, useGrouping: false }; - const needStr = formatNock(nickToNock(needN), 8, opts); - const gotStr = formatNock(nickToNock(gotN), 8, opts); - return `Insufficient fee for transaction (needed: ${needStr} NOCK, got: ${gotStr} NOCK)`; - } - ); + return message.replace(TX_ENGINE_INSUFFICIENT_FEE_RE, (full, needRaw: string, gotRaw: string) => { + const needN = parseCompoundNicksDisplayToTotalNicks(needRaw); + const gotN = parseCompoundNicksDisplayToTotalNicks(gotRaw); + if (needN === null || gotN === null) return full; + const opts = { mode: 'round' as const, useGrouping: false }; + const needStr = formatNock(nickToNock(needN), 8, opts); + const gotStr = formatNock(nickToNock(gotN), 8, opts); + return `Insufficient fee for transaction (needed: ${needStr} NOCK, got: ${gotStr} NOCK)`; + }); } /** diff --git a/extension/shared/vault.ts b/extension/shared/vault.ts index c3e7cf0..41b019e 100644 --- a/extension/shared/vault.ts +++ b/extension/shared/vault.ts @@ -2043,7 +2043,10 @@ export class Vault { this.capWalletTransactions(accountAddress); } - private enqueueNockblocksHistoryRefresh(accountAddress: string, fn: () => Promise): Promise { + private enqueueNockblocksHistoryRefresh( + accountAddress: string, + fn: () => Promise + ): Promise { const prev = this.nockblocksHistoryRefreshChains.get(accountAddress) ?? Promise.resolve(); const next = prev .catch(() => { @@ -2129,10 +2132,7 @@ export class Vault { client ); - if ( - ingestResult.abortedEmptyFirstPage && - opts?.retryAddressIndexOnEmptyPage - ) { + if (ingestResult.abortedEmptyFirstPage && opts?.retryAddressIndexOnEmptyPage) { await new Promise(resolve => setTimeout(resolve, 1500)); ingestResult = await this.ingestNockblocksTransactionsByAddress( accountAddress, From aaf18aa012ce5237816a674079c1b242f08841ba Mon Sep 17 00:00:00 2001 From: Gohlub <62673775+Gohlub@users.noreply.github.com> Date: Wed, 13 May 2026 12:50:33 -0400 Subject: [PATCH 7/8] chore: bump version for the release candidate --- extension/manifest.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/extension/manifest.json b/extension/manifest.json index 3db330f..6ed7861 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Iris Wallet", "homepage_url": "https://iriswallet.io", - "version": "1.2.2", + "version": "1.2.3", "description": "Iris Wallet - Browser Wallet for Nockchain", "icons": { "16": "icons/icon16.png", diff --git a/package.json b/package.json index 9a80655..ce1dafd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "iris", - "version": "1.2.2", + "version": "1.2.3", "description": "Iris - Chrome Wallet Extension for Nockchain", "type": "module", "scripts": { From 67ef11962425d2db2365f1b73e21397668b1eae4 Mon Sep 17 00:00:00 2001 From: Gohlub <62673775+Gohlub@users.noreply.github.com> Date: Wed, 13 May 2026 12:52:17 -0400 Subject: [PATCH 8/8] Update extension/popup/styles.css Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- extension/popup/styles.css | 1 + 1 file changed, 1 insertion(+) diff --git a/extension/popup/styles.css b/extension/popup/styles.css index 1cd90a9..c8919f8 100644 --- a/extension/popup/styles.css +++ b/extension/popup/styles.css @@ -142,6 +142,7 @@ html.light { * Default (no .light) = dark UI — show `.theme-icon-dark`. Light UI — show `.theme-icon-light`. */ .theme-icon-pair { + display: inline-block; position: relative; flex-shrink: 0; }