-
- You receive (Base)
-
-
- {amount
- ? Number(amount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
- : '0.00'}
-
-
- Base

+ {/* Swap direction - circle between cards (overlaps) */}
+
+
+

+
-
-
-
-
- Receiver address
+ {/* You receive (Base) - accent card */}
+
+
+
+ {displayAmount}
+
+
+ {usdValue !== null ? `$${usdValue} USD` : '0 USD'}
+

+
-
- Base

+
+
+ {/* Same as NOCK logo but small circle has white bg + Base icon */}
+
-
setDestinationAddress(e.target.value.trim())}
- placeholder="0x..."
- className="w-full bg-transparent border-0 outline-none text-[14px] font-medium"
- />
-
- {(error || amountError) && (
+ {/* Receiver address */}
- {error || amountError}
+
+
+ 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 */}
+
@@ -172,4 +400,3 @@ export function SwapScreen() {
);
}
-
diff --git a/extension/popup/store.ts b/extension/popup/store.ts
index 2c9c98c..c33d73e 100644
--- a/extension/popup/store.ts
+++ b/extension/popup/store.ts
@@ -142,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;
@@ -201,6 +205,7 @@ export const useStore = create
((set, get) => ({
pendingTransactionRequest: null,
walletTransactions: [],
selectedTransaction: null,
+ swapSubmittedToastVisible: false,
isBalanceFetching: false,
isInitialized: false,
priceUsd: 0,
@@ -290,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 {
@@ -363,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;
From 89be6d973b7f6f963e0e8775c4482966f7f20264 Mon Sep 17 00:00:00 2001
From: Gohlub <62673775+Gohlub@users.noreply.github.com>
Date: Fri, 13 Mar 2026 16:09:52 -0400
Subject: [PATCH 3/4] integrate SDK and bridging logic
---
extension/background/index.ts | 47 +++++-
extension/popup/screens/SwapReviewScreen.tsx | 41 ++++-
extension/shared/bridge-config.ts | 23 +++
extension/shared/constants.ts | 14 ++
extension/shared/vault.ts | 157 ++++++++++++++++++-
5 files changed, 274 insertions(+), 8 deletions(-)
create mode 100644 extension/shared/bridge-config.ts
diff --git a/extension/background/index.ts b/extension/background/index.ts
index 64823f7..343bab3 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,51 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
}
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/screens/SwapReviewScreen.tsx b/extension/popup/screens/SwapReviewScreen.tsx
index ff9bcb9..7517c44 100644
--- a/extension/popup/screens/SwapReviewScreen.tsx
+++ b/extension/popup/screens/SwapReviewScreen.tsx
@@ -1,12 +1,14 @@
import { useState } from 'react';
import { useStore } from '../store';
+import { send } from '../utils/messaging';
import { ChevronLeftIcon } from '../components/icons/ChevronLeftIcon';
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 DownArrow from '../assets/downArrow.svg';
-import { BRIDGE_PROTOCOL_FEE_DISPLAY } from '../../shared/constants';
+import { BRIDGE_PROTOCOL_FEE_DISPLAY, INTERNAL_METHODS } from '../../shared/constants';
+import { nockToNick } from '../../shared/currency';
function truncate(addr: string): string {
if (!addr) return '';
@@ -45,11 +47,38 @@ export function SwapReviewScreen() {
async function handleSwap() {
setSubmitting(true);
- setError('Bridge execution is temporarily disabled while API migration is in progress.');
- setSubmitting(false);
- setPendingBridgeSwap(null);
- setSwapSubmittedToastVisible(true);
- navigate('home');
+ setError('');
+
+ try {
+ const amountNicks = nockToNick(prepared.amountNock);
+ const result = await send<{
+ txid?: string;
+ broadcasted?: boolean;
+ walletTx?: unknown;
+ error?: string;
+ }>(INTERNAL_METHODS.SEND_BRIDGE_TRANSACTION, [
+ prepared.destinationAddress,
+ amountNicks,
+ priceUsd > 0 ? priceUsd : undefined,
+ ]);
+
+ if (result?.error) {
+ setError(result.error);
+ setSubmitting(false);
+ return;
+ }
+
+ setPendingBridgeSwap(null);
+ setSwapSubmittedToastVisible(true);
+ useStore.getState().fetchBalance();
+ useStore.getState().fetchWalletTransactions();
+ navigate('home');
+ } catch (err) {
+ console.error('[SwapReview] Bridge failed:', err);
+ setError(err instanceof Error ? err.message : 'Bridge transaction failed');
+ } finally {
+ setSubmitting(false);
+ }
}
return (
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 fa6ef57..8e82fbe 100644
--- a/extension/shared/constants.ts
+++ b/extension/shared/constants.ts
@@ -114,6 +114,9 @@ export const INTERNAL_METHODS = {
/** Send transaction using UTXO store (build, lock, broadcast atomically) */
SEND_TRANSACTION_V2: 'wallet:sendTransactionV2',
+ /** 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',
@@ -314,6 +317,16 @@ 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
@@ -336,6 +349,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..897ad6a 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,155 @@ export class Vault {
});
}
+ /**
+ * 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
*
From c3566c66768913b69859f50de1ace2f738f45adb Mon Sep 17 00:00:00 2001
From: Gohlub <62673775+Gohlub@users.noreply.github.com>
Date: Fri, 13 Mar 2026 16:11:22 -0400
Subject: [PATCH 4/4] add bridge estimation fee
---
extension/background/index.ts | 40 +++++++++++
extension/popup/screens/SwapReviewScreen.tsx | 43 +++++++++--
extension/shared/constants.ts | 3 +
extension/shared/vault.ts | 75 ++++++++++++++++++++
4 files changed, 155 insertions(+), 6 deletions(-)
diff --git a/extension/background/index.ts b/extension/background/index.ts
index 343bab3..48ff3ba 100644
--- a/extension/background/index.ts
+++ b/extension/background/index.ts
@@ -1497,6 +1497,46 @@ 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()) {
diff --git a/extension/popup/screens/SwapReviewScreen.tsx b/extension/popup/screens/SwapReviewScreen.tsx
index 7517c44..3312e61 100644
--- a/extension/popup/screens/SwapReviewScreen.tsx
+++ b/extension/popup/screens/SwapReviewScreen.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useState, useEffect } from 'react';
import { useStore } from '../store';
import { send } from '../utils/messaging';
import { ChevronLeftIcon } from '../components/icons/ChevronLeftIcon';
@@ -8,7 +8,7 @@ import NockText from '../assets/NockText.svg';
import JustNText from '../assets/JustNText.svg';
import DownArrow from '../assets/downArrow.svg';
import { BRIDGE_PROTOCOL_FEE_DISPLAY, INTERNAL_METHODS } from '../../shared/constants';
-import { nockToNick } from '../../shared/currency';
+import { nockToNick, nickToNock } from '../../shared/currency';
function truncate(addr: string): string {
if (!addr) return '';
@@ -19,6 +19,7 @@ export function SwapReviewScreen() {
const { navigate, pendingBridgeSwap, setPendingBridgeSwap, setSwapSubmittedToastVisible, priceUsd } = useStore();
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
+ const [networkFeeNicks, setNetworkFeeNicks] = useState(null);
if (!pendingBridgeSwap) {
navigate('swap');
@@ -26,6 +27,22 @@ export function SwapReviewScreen() {
}
const prepared = pendingBridgeSwap;
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ const result = await send<{ fee?: number; error?: string }>(
+ INTERNAL_METHODS.ESTIMATE_BRIDGE_FEE,
+ [prepared.destinationAddress, nockToNick(prepared.amountNock)]
+ );
+ if (!cancelled && result?.fee != null) {
+ setNetworkFeeNicks(result.fee);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [prepared.destinationAddress, prepared.amountNock]);
+
const amountNock = prepared.amountNock.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
@@ -39,8 +56,16 @@ export function SwapReviewScreen() {
})
: null;
- const bridgeFeeNock = prepared.amountNock * 0.005;
- const bridgeFeeDisplay = bridgeFeeNock.toLocaleString('en-US', {
+ const networkFeeDisplay =
+ networkFeeNicks != null
+ ? nickToNock(networkFeeNicks).toLocaleString('en-US', {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })
+ : '—';
+
+ const bridgeProtocolFeeNock = prepared.amountNock * 0.005;
+ const bridgeProtocolFeeDisplay = bridgeProtocolFeeNock.toLocaleString('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
@@ -212,15 +237,21 @@ export function SwapReviewScreen() {
- {/* Divider + Bridge fee */}
+ {/* Divider + Fees */}
+
+ Network fee
+
+ {networkFeeDisplay} NOCK
+
+
Bridge fee {BRIDGE_PROTOCOL_FEE_DISPLAY}
- {bridgeFeeDisplay} NOCK
+ {bridgeProtocolFeeDisplay} NOCK
diff --git a/extension/shared/constants.ts b/extension/shared/constants.ts
index 8e82fbe..05ca1a7 100644
--- a/extension/shared/constants.ts
+++ b/extension/shared/constants.ts
@@ -114,6 +114,9 @@ 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',
diff --git a/extension/shared/vault.ts b/extension/shared/vault.ts
index 897ad6a..1e3c925 100644
--- a/extension/shared/vault.ts
+++ b/extension/shared/vault.ts
@@ -2294,6 +2294,81 @@ 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.