diff --git a/extension/background/index.ts b/extension/background/index.ts index 64823f7..48ff3ba 100644 --- a/extension/background/index.ts +++ b/extension/background/index.ts @@ -15,7 +15,7 @@ import { assertNativeNote, assertNativeSpendCondition, } from '../shared/sign-raw-tx-compat'; -import { isLegacySignRawTxRequest } from '@nockbox/iris-sdk'; +import { isLegacySignRawTxRequest, isEvmAddress } from '@nockbox/iris-sdk'; import type { Note, SpendCondition } from '@nockbox/iris-sdk/wasm'; import type { Nicks } from '../shared/currency'; import { @@ -1497,6 +1497,91 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { } return; + case INTERNAL_METHODS.ESTIMATE_BRIDGE_FEE: + // params: [destinationAddress, amountNicks] + if (vault.isLocked()) { + sendResponse({ error: ERROR_CODES.LOCKED }); + return; + } + + const [estimateBridgeDest, estimateBridgeAmountNicks] = payload.params || []; + if (!estimateBridgeDest || !isEvmAddress(estimateBridgeDest)) { + sendResponse({ error: 'Invalid destination address. Expected EVM address (0x...).' }); + return; + } + let estimateBridgeAmountParsed: Nicks; + try { + estimateBridgeAmountParsed = parseNicksParam(estimateBridgeAmountNicks, 'amount'); + } catch (err) { + sendResponse({ error: err instanceof Error ? err.message : 'Invalid amount' }); + return; + } + + try { + const estimateResult = await vault.estimateBridgeFee( + estimateBridgeDest, + estimateBridgeAmountParsed + ); + + if ('error' in estimateResult) { + sendResponse({ error: estimateResult.error }); + return; + } + + sendResponse({ fee: estimateResult.fee }); + } catch (error) { + console.error('[Background] Bridge fee estimation failed:', error); + sendResponse({ + error: error instanceof Error ? error.message : 'Bridge fee estimation failed', + }); + } + return; + + case INTERNAL_METHODS.SEND_BRIDGE_TRANSACTION: + // params: [destinationAddress, amountNicks, priceUsdAtTime?] - EVM address (Base), amount in nicks + if (vault.isLocked()) { + sendResponse({ error: ERROR_CODES.LOCKED }); + return; + } + + const [bridgeDest, bridgeAmountNicks, bridgePriceUsd] = payload.params || []; + if (!bridgeDest || !isEvmAddress(bridgeDest)) { + sendResponse({ error: 'Invalid destination address. Expected EVM address (0x...).' }); + return; + } + let bridgeAmountNicksParsed: Nicks; + try { + bridgeAmountNicksParsed = parseNicksParam(bridgeAmountNicks, 'amount'); + } catch (err) { + sendResponse({ error: err instanceof Error ? err.message : 'Invalid amount' }); + return; + } + + try { + const bridgeResult = await vault.sendBridgeTransaction( + bridgeDest, + bridgeAmountNicksParsed, + typeof bridgePriceUsd === 'number' ? bridgePriceUsd : undefined + ); + + if ('error' in bridgeResult) { + sendResponse({ error: bridgeResult.error }); + return; + } + + sendResponse({ + txid: bridgeResult.txId, + broadcasted: bridgeResult.broadcasted, + walletTx: bridgeResult.walletTx, + }); + } catch (error) { + console.error('[Background] Bridge transaction failed:', error); + sendResponse({ + error: error instanceof Error ? error.message : 'Bridge transaction failed', + }); + } + return; + case INTERNAL_METHODS.SEND_TRANSACTION: // params: [to, amount, fee] - amount and fee in nicks // Called from popup Send screen - builds, signs, and broadcasts transaction diff --git a/extension/popup/Router.tsx b/extension/popup/Router.tsx index 7069c2c..bb5def9 100644 --- a/extension/popup/Router.tsx +++ b/extension/popup/Router.tsx @@ -18,6 +18,8 @@ import { HomeScreen } from './screens/HomeScreen'; import { SendScreen } from './screens/SendScreen'; import { SendReviewScreen } from './screens/SendReviewScreen'; import { SendSubmittedScreen } from './screens/SendSubmittedScreen'; +import { SwapScreen } from './screens/SwapScreen'; +import { SwapReviewScreen } from './screens/SwapReviewScreen'; import { SentScreen } from './screens/transactions/SentScreen'; import { TransactionDetailsScreen } from './screens/TransactionDetailsScreen'; import { ReceiveScreen } from './screens/ReceiveScreen'; @@ -97,6 +99,10 @@ export function Router() { return ; case 'receive': return ; + case 'swap': + return ; + case 'swap-review': + return ; case 'tx-details': return ; diff --git a/extension/popup/assets/JustNText.svg b/extension/popup/assets/JustNText.svg new file mode 100644 index 0000000..c896c16 --- /dev/null +++ b/extension/popup/assets/JustNText.svg @@ -0,0 +1,3 @@ + + + diff --git a/extension/popup/assets/NockSmallCircle.svg b/extension/popup/assets/NockSmallCircle.svg new file mode 100644 index 0000000..97a8fe9 --- /dev/null +++ b/extension/popup/assets/NockSmallCircle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/popup/assets/NockText.svg b/extension/popup/assets/NockText.svg new file mode 100644 index 0000000..5ce6bf1 --- /dev/null +++ b/extension/popup/assets/NockText.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/extension/popup/assets/NockTextCircleContainer.svg b/extension/popup/assets/NockTextCircleContainer.svg new file mode 100644 index 0000000..e5b5dc1 --- /dev/null +++ b/extension/popup/assets/NockTextCircleContainer.svg @@ -0,0 +1,3 @@ + + + diff --git a/extension/popup/assets/base_icon.svg b/extension/popup/assets/base_icon.svg new file mode 100644 index 0000000..9adbb1f --- /dev/null +++ b/extension/popup/assets/base_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/extension/popup/assets/downArrow.svg b/extension/popup/assets/downArrow.svg new file mode 100644 index 0000000..0d43a8f --- /dev/null +++ b/extension/popup/assets/downArrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/extension/popup/assets/swap_icon.svg b/extension/popup/assets/swap_icon.svg new file mode 100644 index 0000000..d019c47 --- /dev/null +++ b/extension/popup/assets/swap_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/extension/popup/assets/upDownvec.svg b/extension/popup/assets/upDownvec.svg new file mode 100644 index 0000000..91d0817 --- /dev/null +++ b/extension/popup/assets/upDownvec.svg @@ -0,0 +1,3 @@ + + + diff --git a/extension/popup/components/SwapSubmittedToast.tsx b/extension/popup/components/SwapSubmittedToast.tsx new file mode 100644 index 0000000..5a16f29 --- /dev/null +++ b/extension/popup/components/SwapSubmittedToast.tsx @@ -0,0 +1,49 @@ +/** + * SwapSubmittedToast - Pill-shaped toast that drops down briefly and disappears. + * Figma: white bg, grey border, shadow, check icon + "Swap submitted" + */ + +import { useEffect } from 'react'; +import { useStore } from '../store'; +import { CheckIcon } from './icons/CheckIcon'; + +const TOAST_DURATION_MS = 3000; + +export function SwapSubmittedToast() { + const { swapSubmittedToastVisible, setSwapSubmittedToastVisible } = useStore(); + + useEffect(() => { + if (!swapSubmittedToastVisible) return; + const t = setTimeout(() => setSwapSubmittedToastVisible(false), TOAST_DURATION_MS); + return () => clearTimeout(t); + }, [swapSubmittedToastVisible, setSwapSubmittedToastVisible]); + + if (!swapSubmittedToastVisible) return null; + + return ( +
+
+
+ +
+ + Swap submitted + +
+
+ ); +} diff --git a/extension/popup/screens/HomeScreen.tsx b/extension/popup/screens/HomeScreen.tsx index 87ad70a..faf75e6 100644 --- a/extension/popup/screens/HomeScreen.tsx +++ b/extension/popup/screens/HomeScreen.tsx @@ -28,6 +28,9 @@ import SettingsGearIcon from '../assets/settings-gear-icon.svg'; import PencilEditIcon from '../assets/pencil-edit-icon.svg'; import RefreshIcon from '../assets/refresh-icon.svg'; import ReceiptIcon from '../assets/receipt-icon.svg'; +import SwapIconAsset from '../assets/swap_icon.svg'; +import BaseIconAsset from '../assets/base_icon.svg'; +import { SwapSubmittedToast } from '../components/SwapSubmittedToast'; import './HomeScreen.tailwind.css'; @@ -327,6 +330,7 @@ export function HomeScreen() { className="w-[357px] h-[600px] overflow-hidden relative" style={{ backgroundColor: 'var(--color-home-fill)', color: 'var(--color-text-primary)' }} > + {/* Scroll container */}
{/* Actions */} -
+
+ +

+ Swap review +

+
+ + +
+
+ You're swapping +
+ + {/* From Nockchain card */} +
+
+
+ {amountNock} NOCK +
+
+ {usdValue !== null ? `≈${usdValue} USD` : '—'} +
+
+
+ + +
+ +
+
+
+ + {/* Down arrow */} +
+ +
+ + {/* To Base card */} +
+
+
+ {amountNock} NOCK +
+
+ {usdValue !== null ? `≈${usdValue} USD` : '—'} +
+
+
+ + +
+ Base +
+
+
+ + {/* Receiving address card */} +
+
+ Receiving address +
+
+ + {truncate(prepared.destinationAddress)} + +
+ +
+
+
+ + {/* Divider + Fees */} +
+
+
+ Network fee + + {networkFeeDisplay} NOCK + +
+
+ + Bridge fee {BRIDGE_PROTOCOL_FEE_DISPLAY} + + + {bridgeProtocolFeeDisplay} NOCK + +
+
+ + {error && ( +
+ {error} +
+ )} +
+ +
+ + +
+
+ ); +} diff --git a/extension/popup/screens/SwapScreen.tsx b/extension/popup/screens/SwapScreen.tsx new file mode 100644 index 0000000..6985bc0 --- /dev/null +++ b/extension/popup/screens/SwapScreen.tsx @@ -0,0 +1,402 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useStore } from '../store'; +import { ChevronLeftIcon } from '../components/icons/ChevronLeftIcon'; +import DownArrow from '../assets/downArrow.svg'; +import BaseIconAsset from '../assets/base_icon.svg'; +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 { BRIDGE_PROTOCOL_FEE_DISPLAY, MIN_BRIDGE_AMOUNT_NOCK } from '../../shared/constants'; + +function isEvmAddress(value: string): boolean { + const s = value.trim(); + if (!s) return false; + const normalized = s.startsWith('0x') ? s : `0x${s}`; + return /^0x[0-9a-fA-F]{40}$/.test(normalized); +} + +export function SwapScreen() { + const { navigate, wallet, setPendingBridgeSwap, priceUsd, isBalanceFetching } = useStore(); + const [amount, setAmount] = useState(''); + const [destinationAddress, setDestinationAddress] = useState(''); + const [isPreparing, setIsPreparing] = useState(false); + const [error, setError] = useState(''); + const [amountFontSizePx, setAmountFontSizePx] = useState(36); + const amountContainerRef = useRef(null); + const measureInputRef = useRef(null); + + const spendableNock = wallet.spendableBalance; + const amountNum = parseFloat(amount); + + // 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(() => { + if (!amount) return ''; + if (Number.isNaN(amountNum) || amountNum <= 0) return 'Enter a valid amount'; + if (spendableNock < MIN_BRIDGE_AMOUNT_NOCK && !(isBalanceFetching && spendableNock === 0)) { + return `Spendable balance (${spendableNock.toLocaleString('en-US', { maximumFractionDigits: 2 })} NOCK) is less than minimum bridge amount (${MIN_BRIDGE_AMOUNT_NOCK.toLocaleString()} NOCK).`; + } + if (amountNum < MIN_BRIDGE_AMOUNT_NOCK) { + return `Minimum swap amount is ${MIN_BRIDGE_AMOUNT_NOCK.toLocaleString()} NOCK`; + } + if (amountNum > spendableNock) return 'Insufficient spendable balance'; + return ''; + }, [amount, amountNum, spendableNock, isBalanceFetching]); + + async function handleReview() { + setError(''); + // TODO: TEMP - bypass guards for styling review screen; remove before release + // if (!isEvmAddress(destinationAddress)) { + // setError('Enter a valid Base (EVM) address'); + // return; + // } + // if (consolidatedAmountError) { + // setError(consolidatedAmountError); + // return; + // } + // if (amountNum > spendableNock) return; + + setIsPreparing(true); + try { + setPendingBridgeSwap({ + amountNock: amountNum || 100000, + bridgeFeeLabel: BRIDGE_PROTOCOL_FEE_DISPLAY, + destinationAddress: destinationAddress || '0x0000000000000000000000000000000000000001', + }); + navigate('swap-review'); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setIsPreparing(false); + } + } + + const hasDecimalPart = /\.\d/.test(amount); + const displayAmount = amount + ? Number(amount).toLocaleString('en-US', { + minimumFractionDigits: hasDecimalPart ? 2 : 0, + maximumFractionDigits: hasDecimalPart ? 2 : 0, + }) + : '0.00'; + + const usdValue = + amountNum > 0 && priceUsd > 0 + ? (amountNum * priceUsd).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + : null; + + const amountLineHeightPx = amountFontSizePx + 4; + + // Scale font only when amount text would overflow the available area. + // Measure raw input (what user types) so reactivity works for any number of decimal digits. + useEffect(() => { + const container = amountContainerRef.current; + const measureInput = measureInputRef.current; + if (!container || !measureInput) return; + let ro: ResizeObserver | null = null; + const run = () => { + const containerWidth = container.offsetWidth; + const inputWidth = measureInput.offsetWidth; + if (inputWidth <= 0) { + setAmountFontSizePx(36); + return; + } + if (inputWidth > containerWidth) { + const scaled = (36 * containerWidth) / inputWidth; + setAmountFontSizePx(Math.max(14, Math.min(36, scaled))); + } else { + setAmountFontSizePx(36); + } + }; + // Defer to next frame so DOM has laid out the new amount before measuring + const raf = requestAnimationFrame(() => { + run(); + ro = new ResizeObserver(() => run()); + ro.observe(container); + }); + return () => { + cancelAnimationFrame(raf); + ro?.disconnect(); + }; + }, [amount]); + + return ( +
+ {/* Header - matches Figma */} +
+ +

+ Swap +

+
+
+ + {/* Content - Figma: gap-8, wallet cards + swap direction circle */} +
+
+ {/* You pay (Nockchain) - white/bg card with border */} +
+
+ {/* Hidden span to measure raw input width at 36px for overflow-based font scaling */} + + {amount || '0'} + + setAmount(e.target.value.replace(/[^\d.]/g, ''))} + placeholder="0.00" + className="w-full bg-transparent border-0 outline-none font-[Lora] font-semibold placeholder:text-[var(--color-text-muted)]" + style={{ + fontSize: `${amountFontSizePx}px`, + lineHeight: `${amountLineHeightPx}px`, + letterSpacing: '-0.72px', + color: amount ? 'var(--color-text-primary)' : 'var(--color-text-muted)', + }} + /> +
+ {usdValue !== null ? `$${usdValue} USD` : '0 USD'} + +
+
+
+
+
+ NOCK +
+
+ Nockchain +
+
+ {/* NOCK logo */} +
+ + + {/* Small circle: container + N separate so you can tweak size/position of each */} +
+ +
+
+
+
+ + {/* Swap direction - circle between cards (overlaps) */} +
+
+ +
+
+ + {/* You receive (Base) - accent card */} +
+
+
+ {displayAmount} +
+
+ {usdValue !== null ? `$${usdValue} USD` : '0 USD'} + +
+
+
+
+
+ NOCK +
+
+ Base +
+
+ {/* Same as NOCK logo but small circle has white bg + Base icon */} +
+ + +
+ Base +
+
+
+
+ + {/* Receiver address */} +
+
+
+ Receiver address +
+
+ + Base + +
+ +
+
+
+ setDestinationAddress(e.target.value.trim())} + placeholder="0x..." + className="w-full bg-transparent border-0 outline-none text-[14px] font-medium leading-[18px]" + style={{ letterSpacing: '0.14px' }} + /> +
+ + {(error || consolidatedAmountError) && ( +
+ {error || consolidatedAmountError} +
+ )} +
+
+ + {/* Footer - Cancel + Review */} +
+ + +
+
+ ); +} diff --git a/extension/popup/store.ts b/extension/popup/store.ts index c213579..c33d73e 100644 --- a/extension/popup/store.ts +++ b/extension/popup/store.ts @@ -51,6 +51,8 @@ export type Screen = | 'send-submitted' | 'sent' | 'receive' + | 'swap' + | 'swap-review' | 'tx-details' // Approval screens @@ -102,6 +104,20 @@ interface AppStore { lastTransaction: TransactionDetails | null; setLastTransaction: (transaction: TransactionDetails | null) => void; + // Prepared bridge swap transaction between swap and review screens + pendingBridgeSwap: { + amountNock: number; + bridgeFeeLabel: string; + destinationAddress: string; + } | null; + setPendingBridgeSwap: ( + value: { + amountNock: number; + bridgeFeeLabel: string; + destinationAddress: string; + } | null + ) => void; + // Pending connect request (for showing approval screen) pendingConnectRequest: ConnectRequest | null; setPendingConnectRequest: (request: ConnectRequest | null) => void; @@ -126,6 +142,10 @@ interface AppStore { selectedTransaction: WalletTransaction | null; setSelectedTransaction: (transaction: WalletTransaction | null) => void; + // Swap submitted toast (drops down briefly, then disappears) + swapSubmittedToastVisible: boolean; + setSwapSubmittedToastVisible: (visible: boolean) => void; + // Balance fetching state isBalanceFetching: boolean; @@ -178,12 +198,14 @@ export const useStore = create((set, get) => ({ onboardingMnemonic: null, lastTransaction: null, + pendingBridgeSwap: null, pendingConnectRequest: null, pendingSignRequest: null, pendingSignRawTxRequest: null, pendingTransactionRequest: null, walletTransactions: [], selectedTransaction: null, + swapSubmittedToastVisible: false, isBalanceFetching: false, isInitialized: false, priceUsd: 0, @@ -239,6 +261,10 @@ export const useStore = create((set, get) => ({ set({ lastTransaction: transaction }); }, + setPendingBridgeSwap: value => { + set({ pendingBridgeSwap: value }); + }, + // Set pending connect request setPendingConnectRequest: (request: ConnectRequest | null) => { set({ pendingConnectRequest: request }); @@ -269,6 +295,10 @@ export const useStore = create((set, get) => ({ set({ selectedTransaction: transaction }); }, + setSwapSubmittedToastVisible: (visible: boolean) => { + set({ swapSubmittedToastVisible: visible }); + }, + // Initialize app on load initialize: async () => { try { @@ -342,16 +372,20 @@ export const useStore = create((set, get) => ({ } } + // When we will fetch balance, set loading so UI never shows 0 without a loading state + const willFetchBalance = !walletState.locked && !!walletState.address; + set({ wallet: walletState, currentScreen: initialScreen, isInitialized: true, + isBalanceFetching: willFetchBalance, }); await get().refreshRpcDisplayConfig(); - // Fetch balance if wallet is unlocked - if (!walletState.locked && walletState.address) { + // Fetch balance if wallet is unlocked (don't await - let it update when ready) + if (willFetchBalance) { get().fetchBalance(); get().fetchWalletTransactions(); } diff --git a/extension/popup/styles.css b/extension/popup/styles.css index 5323eb1..8ad16d8 100644 --- a/extension/popup/styles.css +++ b/extension/popup/styles.css @@ -153,6 +153,22 @@ button { cursor: pointer; } +/* Swap submitted toast - drops down from top */ +@keyframes toast-slide-down { + from { + opacity: 0; + transform: translateY(-100%); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-toast-slide-down { + animation: toast-slide-down 0.3s ease-out forwards; +} + /* Hide scrollbars globally */ * { -ms-overflow-style: none; diff --git a/extension/shared/bridge-config.ts b/extension/shared/bridge-config.ts new file mode 100644 index 0000000..9f27932 --- /dev/null +++ b/extension/shared/bridge-config.ts @@ -0,0 +1,23 @@ +/** + * Bridge configuration for Nockchain → Base (Zorp bridge). + * Used by iris-sdk buildBridgeTransaction and validateBridgeTransaction. + */ + +import type { BridgeConfig } from '@nockbox/iris-sdk'; +import { + ZORP_BRIDGE_THRESHOLD, + ZORP_BRIDGE_ADDRESSES, + MIN_BRIDGE_AMOUNT_NOCK, + DEFAULT_FEE_PER_WORD, + NOCK_TO_NICKS, +} from './constants'; + +export const BRIDGE_CONFIG: BridgeConfig = { + threshold: ZORP_BRIDGE_THRESHOLD, + addresses: ZORP_BRIDGE_ADDRESSES, + noteDataKey: 'bridge', + chainTag: '65736162', // %base in little-endian hex + versionTag: '0', + feePerWord: String(DEFAULT_FEE_PER_WORD), + minAmountNicks: String(MIN_BRIDGE_AMOUNT_NOCK * NOCK_TO_NICKS), +}; diff --git a/extension/shared/constants.ts b/extension/shared/constants.ts index 0762031..05ca1a7 100644 --- a/extension/shared/constants.ts +++ b/extension/shared/constants.ts @@ -4,8 +4,8 @@ * for wallet provider API and internal extension communication */ -// Import provider methods from SDK -import { PROVIDER_METHODS } from '@nockbox/iris-sdk'; +// Import provider methods from local SDK source +import { PROVIDER_METHODS } from '../../../iris-sdk/src/index'; /** * Internal Extension Methods - Called by popup UI and other extension components @@ -114,6 +114,12 @@ export const INTERNAL_METHODS = { /** Send transaction using UTXO store (build, lock, broadcast atomically) */ SEND_TRANSACTION_V2: 'wallet:sendTransactionV2', + /** Estimate bridge transaction fee for a given destination and amount */ + ESTIMATE_BRIDGE_FEE: 'wallet:estimateBridgeFee', + + /** Build, sign, and broadcast a bridge transaction (Nockchain → Base) */ + SEND_BRIDGE_TRANSACTION: 'wallet:sendBridgeTransaction', + /** Approve pending sign raw transaction request */ APPROVE_SIGN_RAW_TX: 'wallet:approveSignRawTx', @@ -305,6 +311,25 @@ export const NOCK_TO_NICKS = 65_536; */ export const DEFAULT_TRANSACTION_FEE = 3_407_872; +/** Fee per word (8-byte unit) for transaction size calculation in nicks */ +export const DEFAULT_FEE_PER_WORD = 1 << 15; // 32,768 nicks = 0.5 NOCK per word + +/** Minimum bridge amount in NOCK (UI guardrail) */ +export const MIN_BRIDGE_AMOUNT_NOCK = 100_000; + +/** Bridge protocol fee display string (for review UI) */ +export const BRIDGE_PROTOCOL_FEE_DISPLAY = '0.5%'; + +/** Zorp Bridge 3-of-5 Multisig Configuration (Nockchain → Base) */ +export const ZORP_BRIDGE_THRESHOLD = 3; +export const ZORP_BRIDGE_ADDRESSES: string[] = [ + 'AD6Mw1QUnPUrnVpyj2gW2jT6Jd6WsuZQmPn79XpZoFEocuvV12iDkvh', // Zorp #1 + '6KrZT5hHLY1fva9AUDeGtZu5Jznm4RDLYfjcGjuU49nWoNym5ZeX5X5', // Zorp #2 + 'CDLzgKWAKFXYABkuQaMwbttDSTDMh3Wy2Eoq2XiArsyxn7vScNHupBb', // Pero + '7E47xYNVEyt7jGmLsiChUHnyw88AfBvzJfXfEQkPmMo2ZWsdcPudwmV', // Nockbox + '3xSyK6RQUaYzE8YDUamkpKRHALxaYo8E7eppawwE4sP35c3PASc6koq', // SWPS +]; + /** * User Activity Methods - Methods that count as user activity for auto-lock timer * Only these methods reset the lastActivity timestamp. Passive/polling methods @@ -327,6 +352,7 @@ export const USER_ACTIVITY_METHODS = new Set([ INTERNAL_METHODS.SET_AUTO_LOCK, INTERNAL_METHODS.GET_MNEMONIC, // Viewing secret phrase is user activity INTERNAL_METHODS.SEND_TRANSACTION_V2, + INTERNAL_METHODS.SEND_BRIDGE_TRANSACTION, INTERNAL_METHODS.ESTIMATE_TRANSACTION_FEE, INTERNAL_METHODS.ESTIMATE_MAX_SEND, INTERNAL_METHODS.REPORT_ACTIVITY, diff --git a/extension/shared/vault.ts b/extension/shared/vault.ts index 8c7b87c..1e3c925 100644 --- a/extension/shared/vault.ts +++ b/extension/shared/vault.ts @@ -18,7 +18,11 @@ import { NOCK_TO_NICKS, } from './constants'; import { Account } from './types'; -import { buildMultiNotePayment, type Note } from './transaction-builder'; +import { + buildMultiNotePayment, + discoverSpendConditionForNote, + type Note, +} from './transaction-builder'; import wasm from './sdk-wasm.js'; import { queryV1Balance } from './balance-query'; import { createBrowserClient } from './rpc-client-browser'; @@ -48,6 +52,8 @@ import { assertNativeSpendCondition, } from './sign-raw-tx-compat'; import { getTxEngineSettingsForHeight } from './rpc-config'; +import { buildBridgeTransaction, validateBridgeTransaction } from '@nockbox/iris-sdk'; +import { BRIDGE_CONFIG } from './bridge-config'; async function txEngineSettings(blockHeight: number): Promise { return getTxEngineSettingsForHeight(blockHeight); @@ -2288,6 +2294,230 @@ export class Vault { }); } + /** + * Estimate the chain fee for a bridge transaction (builds tx, returns fee). + * Does not lock notes or broadcast. + */ + async estimateBridgeFee( + destinationAddress: string, + amountNicks: Nicks + ): Promise<{ fee: number } | { error: string }> { + if (this.state.locked || !this.mnemonic) { + return { error: ERROR_CODES.LOCKED }; + } + + const currentAccount = this.getCurrentAccount(); + if (!currentAccount) { + return { error: ERROR_CODES.NO_ACCOUNT }; + } + + try { + await initWasmModules(); + + const availableStoredNotes = this.getAvailableNotes(currentAccount.address); + if (availableStoredNotes.length === 0) { + return { error: 'No available UTXOs.' }; + } + + const estimatedFeeNum = 2 * NOCK_TO_NICKS; + const targetAmount = Number(amountNicks) + estimatedFeeNum; + const selectedStoredNotes = selectNotesForAmount(availableStoredNotes, targetAmount); + if (!selectedStoredNotes) { + return { error: 'Insufficient available funds' }; + } + + const sortedStoredNotes = [...selectedStoredNotes].sort((a, b) => b.assets - a.assets); + const senderPKH = currentAccount.address; + + const wasmNotes = sortedStoredNotes.map(n => { + if (!n.protoNote) { + throw new Error('Note missing protoNote - cannot estimate bridge fee'); + } + return wasm.noteFromProtobuf(n.protoNote); + }); + + const spendConditions = await Promise.all( + sortedStoredNotes.map(n => + discoverSpendConditionForNote(senderPKH, { + nameFirst: n.nameFirst, + originPage: n.originPage, + }) + ) + ); + + const blockHeight = this.getAccountBlockHeight(currentAccount.address); + const txEngineSettings = await getTxEngineSettingsForHeight(blockHeight); + + const bridgeResult = await buildBridgeTransaction( + { + inputNotes: wasmNotes, + spendConditions, + amountInNicks: String(amountNicks), + destinationAddress, + refundPkh: senderPKH, + txEngineSettings, + }, + BRIDGE_CONFIG + ); + + return { fee: Number(bridgeResult.fee) }; + } catch (error) { + console.error('[Vault] Bridge fee estimation failed:', error); + return { + error: 'Fee estimation failed: ' + (error instanceof Error ? error.message : String(error)), + }; + } + } + + /** + * Build, sign, and broadcast a bridge transaction (Nockchain → Base) + * Uses UTXO store for spendable balance consistency. + * + * @param destinationAddress - EVM address on Base to receive NOCK + * @param amountNicks - Amount to bridge in nicks + * @param priceUsdAtTime - Optional USD price for display + */ + async sendBridgeTransaction( + destinationAddress: string, + amountNicks: Nicks, + priceUsdAtTime?: number + ): Promise< + { txId: string; walletTx: WalletTransaction; broadcasted: boolean } | { error: string } + > { + if (this.state.locked || !this.mnemonic) { + return { error: ERROR_CODES.LOCKED }; + } + + const currentAccount = this.getCurrentAccount(); + if (!currentAccount) { + return { error: ERROR_CODES.NO_ACCOUNT }; + } + + return withAccountLock(currentAccount.address, async () => { + const walletTxId = crypto.randomUUID(); + let selectedNoteIds: string[] = []; + + try { + await initWasmModules(); + + const availableStoredNotes = this.getAvailableNotes(currentAccount.address); + + if (availableStoredNotes.length === 0) { + return { error: 'No available UTXOs.' }; + } + + const estimatedFeeNum = 2 * NOCK_TO_NICKS; + const targetAmount = Number(amountNicks) + estimatedFeeNum; + const selectedStoredNotes = selectNotesForAmount(availableStoredNotes, targetAmount); + + if (!selectedStoredNotes) { + return { error: 'Insufficient available funds' }; + } + + selectedNoteIds = selectedStoredNotes.map(n => n.noteId); + const selectedTotal = selectedStoredNotes.reduce((sum, n) => sum + n.assets, 0); + const expectedChange = selectedTotal - Number(amountNicks) - estimatedFeeNum; + + await this.markNotesInFlight(currentAccount.address, selectedNoteIds, walletTxId); + + const walletTx: WalletTransaction = { + id: walletTxId, + accountAddress: currentAccount.address, + direction: 'outgoing', + createdAt: Date.now(), + updatedAt: Date.now(), + priceUsdAtTime, + status: 'created', + inputNoteIds: selectedNoteIds, + recipient: destinationAddress, + amount: Number(amountNicks), + fee: estimatedFeeNum, + expectedChange: expectedChange > 0 ? expectedChange : 0, + }; + await this.addWalletTransaction(walletTx); + + const sortedStoredNotes = [...selectedStoredNotes].sort((a, b) => b.assets - a.assets); + const senderPKH = currentAccount.address; + + const wasmNotes = sortedStoredNotes.map(n => { + if (!n.protoNote) { + throw new Error('Note missing protoNote - cannot build bridge transaction'); + } + return wasm.noteFromProtobuf(n.protoNote); + }); + + const spendConditions = await Promise.all( + sortedStoredNotes.map(n => + discoverSpendConditionForNote(senderPKH, { + nameFirst: n.nameFirst, + originPage: n.originPage, + }) + ) + ); + + const blockHeight = this.getAccountBlockHeight(currentAccount.address); + const txEngineSettings = await getTxEngineSettingsForHeight(blockHeight); + + const bridgeResult = await buildBridgeTransaction( + { + inputNotes: wasmNotes, + spendConditions, + amountInNicks: String(amountNicks), + destinationAddress, + refundPkh: senderPKH, + txEngineSettings, + }, + BRIDGE_CONFIG + ); + + const rawTx = wasm.nockchainTxToRawTx(bridgeResult.transaction); + const protobufTx = await this.signRawTx({ + rawTx, + notes: wasmNotes, + spendConditions, + }); + + const validation = await validateBridgeTransaction(protobufTx, BRIDGE_CONFIG); + if (!validation.valid) { + throw new Error(validation.error ?? 'Bridge transaction validation failed'); + } + + const rpcClient = createBrowserClient(await getEffectiveRpcEndpoint()); + await rpcClient.sendTransaction(protobufTx); + + walletTx.fee = Number(bridgeResult.fee); + walletTx.txHash = bridgeResult.txId; + walletTx.status = 'broadcasted_unconfirmed'; + await this.updateWalletTransaction(currentAccount.address, walletTxId, { + fee: walletTx.fee, + txHash: bridgeResult.txId, + status: 'broadcasted_unconfirmed', + }); + + return { + txId: bridgeResult.txId, + walletTx, + broadcasted: true, + }; + } catch (error) { + console.error('[Vault] Bridge transaction failed:', error); + if (selectedNoteIds.length > 0) { + try { + await this.releaseInFlightNotes(currentAccount.address, selectedNoteIds); + await this.updateWalletTransaction(currentAccount.address, walletTxId, { + status: 'failed', + }); + } catch (releaseError) { + console.error('[Vault] Error releasing notes:', releaseError); + } + } + return { + error: `Bridge failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } + }); + } + /** * Sign a raw transaction using iris-wasm *