diff --git a/extension/background/index.ts b/extension/background/index.ts index 636e3fb5..7dc33257 100644 --- a/extension/background/index.ts +++ b/extension/background/index.ts @@ -245,6 +245,13 @@ function cancelPendingRequest(requestId: string, code?: number, message?: string } } +function completePendingRequest(requestId: string): void { + pendingRequests.delete(requestId); + if (currentRequestId == requestId) { + currentRequestId = null; + } +} + /** * Check if a request timestamp has expired * @param timestamp - Request creation timestamp @@ -703,7 +710,7 @@ async function syncAccountUTXOsWithDedupe( success: false, error: ERROR_CODES.LOCKED, }; - return { ok: true, results }; + return { ok: false, results }; } try { @@ -718,7 +725,7 @@ async function syncAccountUTXOsWithDedupe( }; } - return { ok: true, results }; + return { ok: results[accountAddress]?.success === true, results }; })().finally(() => { utxoSyncInFlight.delete(accountAddress); }); @@ -1281,7 +1288,9 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { sendResponse(storeBalance); } catch (err) { console.error('[Background] Error getting balance from store:', err); - sendResponse({ error: 'Failed to get balance from store' }); + sendResponse({ + error: err instanceof Error ? err.message : 'Failed to get balance from store', + }); } return; @@ -1321,7 +1330,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { sendResponse(syncOutcome); } catch (err) { console.error('[Background] SYNC_UTXOS error:', err); - sendResponse({ error: 'Failed to sync UTXOs' }); + sendResponse({ error: err instanceof Error ? err.message : 'Failed to sync UTXOs' }); } return; @@ -1538,6 +1547,24 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { } try { + const currentAccount = vault.getCurrentAccount(); + if (!currentAccount) { + throw new Error(ERROR_CODES.NO_ACCOUNT); + } + + const syncResult = await syncAccountUTXOsWithDedupe( + currentAccount.address, + currentAccount.name + ); + const accountSync = syncResult.results[currentAccount.address]; + if (!accountSync?.success) { + throw new Error( + `Could not refresh spendable balance before signing: ${ + accountSync?.error || 'unknown balance sync error' + }` + ); + } + const v2Result = await vault.sendTransactionV2( txRequest.to, txRequest.amount, @@ -1556,7 +1583,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { amount: txRequest.amount, fee: txRequest.fee, }); - cancelPendingRequest(approveTxId); + completePendingRequest(approveTxId); processNextRequest(); sendResponse({ success: true }); } catch (error) { @@ -1578,6 +1605,60 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { } return; + case INTERNAL_METHODS.COMPLETE_TRANSACTION: + const [completeTxId, preparedWalletTxId, completeProviderSignedTx] = payload.params || []; + const completeTxPending = pendingRequests.get(completeTxId); + if (completeTxPending && isTransactionRequest(completeTxPending.request)) { + const txRequest = completeTxPending.request; + + if (isRequestExpired(txRequest.timestamp)) { + cancelPendingRequest(completeTxId, 4003, 'Request expired'); + sendResponse({ error: 'Request expired' }); + return; + } + + try { + if (typeof preparedWalletTxId !== 'string') { + throw new Error('Prepared transaction id is required'); + } + + const v2Result = await vault.completePreparedSendTransactionV2( + preparedWalletTxId, + completeProviderSignedTx + ); + + if ('error' in v2Result) { + throw new Error(v2Result.error); + } + + completeTxPending.sendResponse({ + txid: v2Result.txId, + amount: txRequest.amount, + fee: txRequest.fee, + }); + completePendingRequest(completeTxId); + processNextRequest(); + sendResponse({ success: true, txid: v2Result.txId }); + } catch (error) { + console.error('External transaction completion failed:', error); + completeTxPending.sendResponse({ + error: { + code: 4900, + message: + error instanceof Error ? error.message : 'Transaction completion failed', + }, + }); + cancelPendingRequest(completeTxId); + processNextRequest(); + sendResponse({ + error: error instanceof Error ? error.message : 'Transaction completion failed', + }); + } + } else { + sendResponse({ error: ERROR_CODES.NOT_FOUND }); + } + return; + case INTERNAL_METHODS.REJECT_TRANSACTION: const rejectTxId = payload.params?.[0]; const rejectTxPending = pendingRequests.get(rejectTxId); @@ -1606,7 +1687,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { try { const signMessageResponse = await vault.signMessage([signRequest.message]); approveSignPending.sendResponse(signMessageResponse); - cancelPendingRequest(approveSignId); + completePendingRequest(approveSignId); processNextRequest(); sendResponse({ success: true }); } catch (err) { @@ -1632,6 +1713,25 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { } return; + case INTERNAL_METHODS.COMPLETE_SIGN_MESSAGE: + const [completeSignId, completeSignResponse] = payload.params || []; + const completeSignPending = pendingRequests.get(completeSignId); + if (completeSignPending && isSignRequest(completeSignPending.request)) { + const signRequest = completeSignPending.request; + if (isRequestExpired(signRequest.timestamp)) { + cancelPendingRequest(completeSignId, 4003, 'Request expired'); + sendResponse({ error: 'Request expired' }); + return; + } + completeSignPending.sendResponse(completeSignResponse); + completePendingRequest(completeSignId); + processNextRequest(); + sendResponse({ success: true }); + } else { + sendResponse({ error: ERROR_CODES.NOT_FOUND }); + } + return; + case INTERNAL_METHODS.APPROVE_SIGN_RAW_TX: const approveSignRawTxId = payload.params?.[0]; const approveSignRawTxPending = pendingRequests.get(approveSignRawTxId); @@ -1652,7 +1752,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { rawTx: signRawTxRequest.rawTx, }); approveSignRawTxPending.sendResponse({ tx: signedTx }); - cancelPendingRequest(approveSignRawTxId); + completePendingRequest(approveSignRawTxId); processNextRequest(); sendResponse({ success: true }); } catch (err) { @@ -1668,6 +1768,39 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { } return; + case INTERNAL_METHODS.COMPLETE_SIGN_RAW_TX: + const [completeSignRawTxId, completeSignedTx] = payload.params || []; + const completeSignRawTxPending = pendingRequests.get(completeSignRawTxId); + + if ( + completeSignRawTxPending && + isSignRawTxRequest(completeSignRawTxPending.request) + ) { + const signRawTxRequest = completeSignRawTxPending.request; + if (isRequestExpired(signRawTxRequest.timestamp)) { + cancelPendingRequest(completeSignRawTxId, 4003, 'Request expired'); + sendResponse({ error: 'Request expired' }); + return; + } + + try { + // Validate shape by converting back to a raw tx. The signed tx itself + // is still returned to the provider in Iris' native NockchainTx form. + wasm.nockchainTxToRawTx(completeSignedTx); + completeSignRawTxPending.sendResponse({ tx: completeSignedTx }); + completePendingRequest(completeSignRawTxId); + processNextRequest(); + sendResponse({ success: true }); + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : 'Invalid signed transaction'; + sendResponse({ error: errorMessage }); + } + } else { + sendResponse({ error: ERROR_CODES.NOT_FOUND }); + } + return; + case INTERNAL_METHODS.REJECT_SIGN_RAW_TX: const rejectSignRawTxId = payload.params?.[0]; const rejectSignRawTxPending = pendingRequests.get(rejectSignRawTxId); @@ -1710,7 +1843,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { // Add origin only after the response can be built. await approveOrigin(connectRequest.origin); approveConnectPending.sendResponse(connectResponse); - cancelPendingRequest(approveConnectId); + completePendingRequest(approveConnectId); processNextRequest(); sendResponse({ success: true }); @@ -1804,13 +1937,13 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { return; case INTERNAL_METHODS.ESTIMATE_TRANSACTION_FEE: - // params: [to, amount] - amount in nicks + // params: [to, amount, externalIncludeLockData?] - amount in nicks if (vault.isLocked()) { sendResponse({ error: ERROR_CODES.LOCKED }); return; } - const [estimateTo, estimateAmount] = payload.params || []; + const [estimateTo, estimateAmount, estimateExternalIncludeLockData] = payload.params || []; if (!isNockAddress(estimateTo)) { sendResponse({ error: ERROR_CODES.BAD_ADDRESS }); return; @@ -1824,7 +1957,11 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { } try { - const result = await vault.estimateTransactionFee(estimateTo, estimateAmountNicks); + const result = await vault.estimateTransactionFee( + estimateTo, + estimateAmountNicks, + estimateExternalIncludeLockData !== false + ); if ('error' in result) { sendResponse({ error: result.error }); @@ -1840,20 +1977,23 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { return; case INTERNAL_METHODS.ESTIMATE_MAX_SEND: - // params: [to] - estimates max sendable amount for "send max" feature + // params: [to, externalIncludeLockData?] - estimates max sendable amount for "send max" feature if (vault.isLocked()) { sendResponse({ error: ERROR_CODES.LOCKED }); return; } - const [maxSendTo] = payload.params || []; + const [maxSendTo, maxSendExternalIncludeLockData] = payload.params || []; if (!isNockAddress(maxSendTo)) { sendResponse({ error: ERROR_CODES.BAD_ADDRESS }); return; } try { - const maxResult = await vault.estimateMaxSendAmount(maxSendTo); + const maxResult = await vault.estimateMaxSendAmount( + maxSendTo, + maxSendExternalIncludeLockData !== false + ); if ('error' in maxResult) { sendResponse({ error: maxResult.error }); @@ -1930,6 +2070,125 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { } return; + case INTERNAL_METHODS.PREPARE_SEND_TRANSACTION_V2: + // params: [to, amount, fee?, sendMax?, priceUsdAtTime?, origin?, externalIncludeLockData?] + if (vault.isLocked()) { + sendResponse({ error: ERROR_CODES.LOCKED }); + return; + } + + const [ + prepareToV2, + prepareAmountV2, + prepareFeeV2, + prepareSendMaxV2, + preparePriceUsdAtTimeV2, + prepareOriginV2, + prepareExternalIncludeLockDataV2, + ] = payload.params || []; + if (!isNockAddress(prepareToV2)) { + sendResponse({ error: ERROR_CODES.BAD_ADDRESS }); + return; + } + let prepareAmountNicks: Nicks; + let prepareFeeNicks: Nicks | undefined; + try { + prepareAmountNicks = parseNicksParam(prepareAmountV2, 'amount'); + prepareFeeNicks = + prepareFeeV2 === undefined || prepareFeeV2 === null + ? undefined + : parseNicksParam(prepareFeeV2, 'fee', { required: false, allowZero: true }); + } catch (err) { + sendResponse({ error: err instanceof Error ? err.message : 'Invalid params' }); + return; + } + + try { + const currentAccount = vault.getCurrentAccount(); + if (!currentAccount) { + throw new Error(ERROR_CODES.NO_ACCOUNT); + } + + const syncResult = await syncAccountUTXOsWithDedupe( + currentAccount.address, + currentAccount.name + ); + const accountSync = syncResult.results[currentAccount.address]; + if (!accountSync?.success) { + throw new Error( + `Could not refresh spendable balance before signing: ${ + accountSync?.error || 'unknown balance sync error' + }` + ); + } + + const prepareResult = await vault.prepareSendTransactionV2( + prepareToV2, + prepareAmountNicks, + prepareFeeNicks, + prepareSendMaxV2, + preparePriceUsdAtTimeV2, + prepareOriginV2 === 'provider_send' ? 'provider_send' : 'popup_send', + prepareExternalIncludeLockDataV2 !== false + ); + + sendResponse(prepareResult); + } catch (error) { + console.error('[Background] PrepareSendTransactionV2 failed:', error); + sendResponse({ + error: error instanceof Error ? error.message : 'Transaction preparation failed', + }); + } + return; + + case INTERNAL_METHODS.COMPLETE_SEND_TRANSACTION_V2: + const [completeWalletTxIdV2, completeSignedTxV2] = payload.params || []; + if (typeof completeWalletTxIdV2 !== 'string') { + sendResponse({ error: 'Prepared transaction id is required' }); + return; + } + + try { + const completeResult = await vault.completePreparedSendTransactionV2( + completeWalletTxIdV2, + completeSignedTxV2 + ); + + if ('error' in completeResult) { + sendResponse({ error: completeResult.error }); + return; + } + + sendResponse({ + txid: completeResult.txId, + broadcasted: completeResult.broadcasted, + walletTx: completeResult.walletTx, + }); + } catch (error) { + console.error('[Background] CompleteSendTransactionV2 failed:', error); + sendResponse({ + error: error instanceof Error ? error.message : 'Transaction broadcast failed', + }); + } + return; + + case INTERNAL_METHODS.CANCEL_PREPARED_SEND_TRANSACTION_V2: + const cancelWalletTxIdV2 = payload.params?.[0]; + if (typeof cancelWalletTxIdV2 !== 'string') { + sendResponse({ error: 'Prepared transaction id is required' }); + return; + } + + try { + sendResponse(await vault.cancelPreparedSendTransactionV2(cancelWalletTxIdV2)); + } catch (error) { + console.error('[Background] CancelPreparedSendTransactionV2 failed:', error); + sendResponse({ + error: error instanceof Error ? error.message : 'Transaction cancellation failed', + }); + } + return; + case INTERNAL_METHODS.ESTIMATE_BRIDGE_FEE: // params: [destinationAddress, amountNicks] if (vault.isLocked()) { diff --git a/extension/content/index.ts b/extension/content/index.ts index 0124ec6c..a1b414e2 100644 --- a/extension/content/index.ts +++ b/extension/content/index.ts @@ -24,8 +24,21 @@ window.addEventListener('message', async (evt: MessageEvent) => { return; } - // Forward to service worker and relay response back to page - const reply = await chrome.runtime.sendMessage(data); + // Forward to service worker and relay response back to page. + // If the service worker was reloaded/unavailable, still answer the page so + // callers get the bridge error instead of timing out with no details. + let reply: unknown; + try { + reply = await chrome.runtime.sendMessage(data); + } catch (error) { + reply = { + error: { + code: -32603, + message: + error instanceof Error ? error.message : `Iris extension bridge failed: ${String(error)}`, + }, + }; + } const responseMessage = { target: MESSAGE_TARGETS.WALLET_BRIDGE, diff --git a/extension/inpage/index.ts b/extension/inpage/index.ts index 0316a9a4..24ef8410 100644 --- a/extension/inpage/index.ts +++ b/extension/inpage/index.ts @@ -11,7 +11,41 @@ import { version } from '../../package.json'; // Inline constant to avoid imports const MESSAGE_TARGET = 'IRIS'; +function readableErrorMessage(error: unknown): string { + if (!error) return 'Wallet request failed without a response'; + if (typeof error === 'string') { + const text = error.trim(); + return text && text !== '[object Object]' ? text : 'Wallet request failed'; + } + if (typeof error !== 'object') return String(error); + + const value = error as Record; + for (const key of ['message', 'reason', 'details']) { + const detail = value[key]; + if (typeof detail === 'string' && detail.trim() && detail !== '[object Object]') { + return detail; + } + } + for (const key of ['error', 'data', 'cause']) { + const detail = readableErrorMessage(value[key]); + if (detail && detail !== 'Wallet request failed without a response') { + return detail; + } + } + + try { + const serialized = JSON.stringify(error); + if (serialized && serialized !== '{}') return serialized; + } catch { + // ignore serialization failures + } + return 'Wallet request failed'; +} + class NockProvider implements InjectedNockchain { + version = version; + provider = 'iris'; + /** * Make a request to the wallet * @param args - Request arguments with method and params @@ -44,7 +78,14 @@ class NockProvider implements InjectedNockchain { } if (data.reply?.error) { - reject(new Error(data.reply.error)); + const error = data.reply.error; + const message = readableErrorMessage(error); + const wrapped = new Error(message); + (wrapped as Error & { originalError?: unknown }).originalError = error; + if (error && typeof error === 'object') { + Object.assign(wrapped, error); + } + reject(wrapped); } else { resolve(data.reply); } @@ -65,12 +106,19 @@ class NockProvider implements InjectedNockchain { window.addEventListener('message', handler); }); } + + debug() { + return { + provider: 'iris', + version, + injected: true, + location: window.location.origin, + }; + } } // Inject provider into window const provider = new NockProvider(); -(provider as InjectedNockchain).provider = 'iris'; -(provider as InjectedNockchain).version = version; (window as any).nockchain = provider; // Announce provider availability diff --git a/extension/manifest.json b/extension/manifest.json index 8feb01f3..26aef274 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.4", + "version": "1.3.0", "description": "Iris Wallet - Browser Wallet for Nockchain", "icons": { "16": "icons/icon16.png", diff --git a/extension/popup/Router.tsx b/extension/popup/Router.tsx index 05e4ded0..b7820542 100644 --- a/extension/popup/Router.tsx +++ b/extension/popup/Router.tsx @@ -15,6 +15,7 @@ import { ImportScreen } from './screens/onboarding/ImportScreen'; import { ImportSuccessScreen } from './screens/onboarding/ImportSuccessScreen'; import { ResumeBackupScreen } from './screens/onboarding/ResumeBackupScreen'; import { AddWalletStartScreen } from './screens/AddWalletStartScreen'; +import { NocksterConnectScreen } from './screens/NocksterConnectScreen'; import { HomeScreen } from './screens/HomeScreen'; import { SendScreen } from './screens/SendScreen'; import { SendReviewScreen } from './screens/SendReviewScreen'; @@ -72,6 +73,8 @@ export function Router() { return ; case 'wallet-add-import': return ; + case 'wallet-add-nockster': + return ; case 'wallet-add-backup': return ; case 'wallet-add-verify': diff --git a/extension/popup/screens/AddWalletStartScreen.tsx b/extension/popup/screens/AddWalletStartScreen.tsx index cbb0f77c..57966571 100644 --- a/extension/popup/screens/AddWalletStartScreen.tsx +++ b/extension/popup/screens/AddWalletStartScreen.tsx @@ -9,6 +9,21 @@ import vectorBottomLeft from '../assets/vector-bottom-left.svg'; export function AddWalletStartScreen() { const { navigate } = useStore(); + async function handleConnectNockster() { + if (window.location.hash.slice(1) === 'nockster-pair') { + navigate('wallet-add-nockster'); + return; + } + + const url = chrome.runtime.getURL('popup/index.html#nockster-pair'); + try { + await chrome.tabs.create({ url, active: true }); + } catch { + window.open(url, '_blank'); + } + window.close(); + } + return (
+ +
+ {balanceError ? ( +
+ Balance refresh failed: {balanceError} +
+ ) : null} {/* Actions */} diff --git a/extension/popup/screens/NocksterConnectScreen.tsx b/extension/popup/screens/NocksterConnectScreen.tsx new file mode 100644 index 00000000..735e8798 --- /dev/null +++ b/extension/popup/screens/NocksterConnectScreen.tsx @@ -0,0 +1,311 @@ +import { useState } from 'react'; +import { useStore } from '../store'; +import { AnimatedLogo } from '../components/AnimatedLogo'; +import { ChevronLeftIcon } from '../components/icons/ChevronLeftIcon'; +import { PasswordInput } from '../components/PasswordInput'; +import { + NocksterDeviceLockedError, + listNocksterAccounts, + type PairedNocksterAccount, + type NocksterTransportMode, +} from '../utils/nockster'; +import { truncateAddress } from '../utils/format'; +import vectorLeft from '../assets/vector-left.svg'; +import vectorRight from '../assets/vector-right.svg'; + +export function NocksterConnectScreen() { + const { navigate, createExternalSeedSource } = useStore(); + const [connectingTransport, setConnectingTransport] = useState(null); + const [lastTransport, setLastTransport] = useState('hid'); + const [needsPin, setNeedsPin] = useState(false); + const [pin, setPin] = useState(''); + const [availableAccounts, setAvailableAccounts] = useState([]); + const [selectedKeys, setSelectedKeys] = useState>(new Set()); + const [error, setError] = useState(''); + + function accountKey(account: PairedNocksterAccount): string { + return `${account.slot}:${account.path.join('/')}:${account.publicKeyHex}`; + } + + function accountLabel(account: PairedNocksterAccount): string { + return `Slot ${account.slot}`; + } + + async function handleLoadAccounts(transport: NocksterTransportMode) { + setLastTransport(transport); + setConnectingTransport(transport); + setError(''); + try { + const unlockPin = needsPin ? pin.trim() : undefined; + const accounts = await listNocksterAccounts(transport, unlockPin || undefined); + setAvailableAccounts(accounts); + setSelectedKeys(new Set(accounts.map(accountKey))); + setNeedsPin(false); + setPin(''); + } catch (err) { + handleConnectionError(err); + } finally { + setConnectingTransport(null); + } + } + + function handleConnectionError(err: unknown) { + if (err instanceof NocksterDeviceLockedError) { + setNeedsPin(true); + setError(err.message); + return; + } + + const message = err instanceof Error ? err.message : 'Failed to connect Nockster'; + if (message === 'Wrong PIN') { + setNeedsPin(true); + setPin(''); + } + setError(message); + } + + function toggleAccount(account: PairedNocksterAccount) { + const key = accountKey(account); + setSelectedKeys(prev => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + } + + async function handleImportSelected() { + const selected = availableAccounts.filter(account => selectedKeys.has(accountKey(account))); + if (!selected.length) return; + + setConnectingTransport(lastTransport); + setError(''); + try { + const errors: string[] = []; + for (const account of selected) { + const label = accountLabel(account); + const result = await createExternalSeedSource({ + address: account.address, + name: `Nockster ${label}`, + provider: 'nockster', + sourceRef: JSON.stringify({ + slot: account.slot, + path: account.path, + publicKeyHex: account.publicKeyHex, + publicKeyBase58: account.publicKeyBase58, + transport: account.transport, + firmware: account.firmware, + }), + accountRef: account.publicKeyBase58, + publicKeyHex: account.publicKeyHex, + slot: account.slot, + path: account.path, + }); + + if (result?.error) { + errors.push(`${label}: ${result.error}`); + } + } + + if (errors.length === selected.length) { + throw new Error(errors.join('; ')); + } + + navigate('home'); + if (errors.length) { + console.warn('[Nockster] Some imports failed:', errors); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to import Nockster accounts'); + } finally { + setConnectingTransport(null); + } + } + + const isConnecting = connectingTransport !== null; + + return ( +
+ + + +
+ +

+ Connect Nockster +

+
+
+ +
+
+
+ +
+
+

+ Pair hardware wallet +

+

+ Select your Nockster connection, then choose wallets to import. +

+
+ + {error && ( +
+ {error} +
+ )} + + {needsPin && ( +
+ + { + if (e.key === 'Enter' && pin.trim() && !isConnecting) { + void handleLoadAccounts(lastTransport); + } + }} + /> +
+ )} + + {availableAccounts.length > 0 && ( +
+ {availableAccounts.map(account => { + const key = accountKey(account); + const checked = selectedKeys.has(key); + return ( + + ); + })} +
+ )} +
+ +
+ {availableAccounts.length > 0 ? ( + + ) : ( + <> + + + + )} + +
+
+
+ ); +} diff --git a/extension/popup/screens/SendReviewScreen.tsx b/extension/popup/screens/SendReviewScreen.tsx index cfd4d9c1..1be8b53b 100644 --- a/extension/popup/screens/SendReviewScreen.tsx +++ b/extension/popup/screens/SendReviewScreen.tsx @@ -9,6 +9,7 @@ import { ChevronLeftIcon } from '../components/icons/ChevronLeftIcon'; import { ChevronRightIcon } from '../components/icons/ChevronRightIcon'; import IrisLogo40 from '../assets/iris-logo-40.svg'; import IrisLogoBlue from '../assets/iris-logo-blue.svg'; +import { getCurrentNocksterAccount, signRawTxWithNockster } from '../utils/nockster'; export function SendReviewScreen() { const { navigate, wallet, lastTransaction, priceUsd } = useStore(); @@ -58,20 +59,65 @@ export function SendReviewScreen() { ? Math.round(lastTransaction.feeNicks) : nockToNick(lastTransaction.fee); - // Send transaction using V2 (builds, locks notes, broadcasts atomically) - // If sendMax is true, this is a sweep transaction (all UTXOs to recipient) - const result = await send<{ + const nockster = getCurrentNocksterAccount(wallet); + let preparedWalletTxId: string | null = null; + let completionStarted = false; + let result: { txid?: string; broadcasted?: boolean; walletTx?: any; error?: string; - }>(INTERNAL_METHODS.SEND_TRANSACTION_V2, [ - lastTransaction.to, - amountInNicks, - feeInNicks, - lastTransaction.sendMax, // Pass sendMax flag for sweep transactions - priceUsd, // Store USD price at time of transaction for historical display - ]); + }; + + try { + if (nockster) { + const prepared = await send<{ + walletTx?: { id?: string }; + rawTx?: unknown; + error?: string; + }>(INTERNAL_METHODS.PREPARE_SEND_TRANSACTION_V2, [ + lastTransaction.to, + amountInNicks, + feeInNicks, + lastTransaction.sendMax, + priceUsd, + undefined, + !lastTransaction.nocksterFeeMinimized, + ]); + + if (prepared?.error) { + throw new Error(prepared.error); + } + if (!prepared?.walletTx?.id || !prepared.rawTx) { + throw new Error('Failed to prepare transaction for Nockster signing'); + } + + preparedWalletTxId = prepared.walletTx.id; + const signedTx = await signRawTxWithNockster(prepared.rawTx, nockster); + completionStarted = true; + result = await send(INTERNAL_METHODS.COMPLETE_SEND_TRANSACTION_V2, [ + preparedWalletTxId, + signedTx, + ]); + } else { + // Send transaction using V2 (builds, locks notes, broadcasts atomically) + // If sendMax is true, this is a sweep transaction (all UTXOs to recipient) + result = await send(INTERNAL_METHODS.SEND_TRANSACTION_V2, [ + lastTransaction.to, + amountInNicks, + feeInNicks, + lastTransaction.sendMax, // Pass sendMax flag for sweep transactions + priceUsd, // Store USD price at time of transaction for historical display + ]); + } + } catch (err) { + if (preparedWalletTxId && !completionStarted) { + await send(INTERNAL_METHODS.CANCEL_PREPARED_SEND_TRANSACTION_V2, [ + preparedWalletTxId, + ]).catch(console.error); + } + throw err; + } if (result?.error) { setError(result.error); diff --git a/extension/popup/screens/SendScreen.tsx b/extension/popup/screens/SendScreen.tsx index 04736c12..b2f2a507 100644 --- a/extension/popup/screens/SendScreen.tsx +++ b/extension/popup/screens/SendScreen.tsx @@ -21,6 +21,7 @@ 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'; +import { getCurrentNocksterAccount } from '../utils/nockster'; function formatInt(n: number) { return n.toLocaleString('en-US', { maximumFractionDigits: 0 }); @@ -73,6 +74,7 @@ export function SendScreen() { 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 + const [nocksterFeeMinimized, setNocksterFeeMinimized] = useState(false); /** Nicks to pass at broadcast; kept in sync with auto-estimate, max-send, or manual save. */ const feeSendNicksRef = useRef(null); @@ -83,6 +85,15 @@ export function SendScreen() { const accounts = (wallet.accounts || []).filter(acc => !acc.hidden); const currentAccount = wallet.currentAccount && !wallet.currentAccount.hidden ? wallet.currentAccount : accounts[0]; + const currentNocksterAccount = (() => { + try { + return getCurrentNocksterAccount(wallet); + } catch { + return null; + } + })(); + const isNocksterAccount = Boolean(currentNocksterAccount); + const includeLockDataForNockster = isNocksterAccount && !nocksterFeeMinimized; // Use spendable balance (only UTXOs that are not in_flight - can be spent NOW) const currentBalance = wallet.spendableBalance; @@ -106,6 +117,7 @@ export function SendScreen() { feeEstimateSeqRef.current += 1; setIsFeeManuallyEdited(false); setIsSendingMax(false); + setNocksterFeeMinimized(false); setError(''); setErrorType(null); @@ -156,7 +168,7 @@ export function SendScreen() { totalAvailable?: number; utxoCount?: number; error?: string; - }>(INTERNAL_METHODS.ESTIMATE_MAX_SEND, [addressToUse]); + }>(INTERNAL_METHODS.ESTIMATE_MAX_SEND, [addressToUse, includeLockDataForNockster]); if (result?.error) { console.error('[SendScreen] Max estimation error:', result.error); @@ -238,6 +250,19 @@ export function SendScreen() { } } + function handleNocksterFeeModeChange(checked: boolean) { + setNocksterFeeMinimized(checked); + setFee(''); + setEditedFee(''); + setMinimumFeeNicks(null); + feeSendNicksRef.current = null; + feeEstimateSeqRef.current += 1; + setIsFeeManuallyEdited(false); + setIsSendingMax(false); + setError(''); + setErrorType(null); + } + function handleFeeInputBlur() { // Auto-save fee when input loses focus handleSaveFee(); @@ -308,6 +333,7 @@ export function SendScreen() { to: receiverAddress.trim(), from: currentAccount?.address, sendMax: isSendingMax, // Flag for sweep transaction (all UTXOs to recipient) + nocksterFeeMinimized: isNocksterAccount ? nocksterFeeMinimized : undefined, }); navigate('send-review'); @@ -414,7 +440,7 @@ export function SendScreen() { const amountNicks = Math.floor(amountNum * NOCK_TO_NICKS); const result = await send<{ fee?: number; error?: string }>( INTERNAL_METHODS.ESTIMATE_TRANSACTION_FEE, - [addressToUse, amountNicks] + [addressToUse, amountNicks, includeLockDataForNockster] ); if (seq !== feeEstimateSeqRef.current) return; @@ -447,7 +473,14 @@ export function SendScreen() { clearTimeout(timeoutId); setIsCalculatingFee(false); }; - }, [receiverAddress, amount, isFeeManuallyEdited, currentAccount?.address, wallet.address]); + }, [ + receiverAddress, + amount, + isFeeManuallyEdited, + currentAccount?.address, + wallet.address, + includeLockDataForNockster, + ]); // ----------------------------------------------------------------------------- @@ -800,6 +833,45 @@ export function SendScreen() { )} + {isNocksterAccount && ( +
+ + {nocksterFeeMinimized && ( +
+ Fee-minimized drafts will show lock roots on the device, not the recipient PKH or + change PKH. +
+ )} +
+ )} + {/* Error display - below fee section */} {error && (
( + INTERNAL_METHODS.COMPLETE_SIGN_MESSAGE, + [id, signed] + ); + if (result?.error) { + throw new Error(result.error); + } + } else { + const result = await send<{ success?: boolean; error?: string }>( + INTERNAL_METHODS.APPROVE_SIGN_MESSAGE, + [id] + ); + if (result?.error) { + throw new Error(result.error); + } + } + setPendingSignRequest(null); + window.close(); + } catch (err) { + setSignError(err instanceof Error ? err.message : 'Failed to sign message'); + setIsSigning(false); + } } const bg = 'var(--color-bg)'; @@ -96,7 +125,7 @@ export function SignMessageScreen() {
{/* Account */} -
+
@@ -119,6 +148,12 @@ export function SignMessageScreen() {
+ + {signError && ( +
+ {signError} +
+ )} @@ -130,8 +165,8 @@ export function SignMessageScreen() { - diff --git a/extension/popup/screens/approvals/SignRawTxScreen.tsx b/extension/popup/screens/approvals/SignRawTxScreen.tsx index db7a7818..0f511e3e 100644 --- a/extension/popup/screens/approvals/SignRawTxScreen.tsx +++ b/extension/popup/screens/approvals/SignRawTxScreen.tsx @@ -8,6 +8,7 @@ import { AccountIcon } from '../../components/AccountIcon'; import { SiteIcon } from '../../components/SiteIcon'; import { truncateAddress } from '../../utils/format'; import { nickToNock, formatNock } from '../../../shared/currency'; +import { getCurrentNocksterAccount, signRawTxWithNockster } from '../../utils/nockster'; interface NoteItemProps { note: any; @@ -84,6 +85,8 @@ function NoteItem({ note, type, textPrimary, textMuted, surface }: NoteItemProps export function SignRawTxScreen() { const { pendingSignRawTxRequest, setPendingSignRawTxRequest, navigate, wallet } = useStore(); + const [isSigning, setIsSigning] = useState(false); + const [signError, setSignError] = useState(''); if (!pendingSignRawTxRequest) { navigate('home'); @@ -101,9 +104,34 @@ export function SignRawTxScreen() { } async function handleSign() { - await send(INTERNAL_METHODS.APPROVE_SIGN_RAW_TX, [id]); - setPendingSignRawTxRequest(null); - window.close(); + setIsSigning(true); + setSignError(''); + try { + const nockster = getCurrentNocksterAccount(wallet); + if (nockster) { + const signedTx = await signRawTxWithNockster(rawTx, nockster); + const result = await send<{ success?: boolean; error?: string }>( + INTERNAL_METHODS.COMPLETE_SIGN_RAW_TX, + [id, signedTx] + ); + if (result?.error) { + throw new Error(result.error); + } + } else { + const result = await send<{ success?: boolean; error?: string }>( + INTERNAL_METHODS.APPROVE_SIGN_RAW_TX, + [id] + ); + if (result?.error) { + throw new Error(result.error); + } + } + setPendingSignRawTxRequest(null); + window.close(); + } catch (err) { + setSignError(err instanceof Error ? err.message : 'Failed to sign transaction'); + setIsSigning(false); + } } // Network fee from native rawTx. RawTxV1.spends is a ZMap @@ -229,7 +257,7 @@ export function SignRawTxScreen() { {/* Account */} -
+
@@ -252,6 +280,12 @@ export function SignRawTxScreen() {
+ + {signError && ( +
+ {signError} +
+ )} @@ -263,8 +297,8 @@ export function SignRawTxScreen() { - diff --git a/extension/popup/screens/approvals/TransactionApprovalScreen.tsx b/extension/popup/screens/approvals/TransactionApprovalScreen.tsx index 753211e7..fd21fca4 100644 --- a/extension/popup/screens/approvals/TransactionApprovalScreen.tsx +++ b/extension/popup/screens/approvals/TransactionApprovalScreen.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { useStore } from '../../store'; import { ChevronRightIcon } from '../../components/icons/ChevronRightIcon'; import { AccountIcon } from '../../components/AccountIcon'; @@ -7,9 +8,12 @@ import { send } from '../../utils/messaging'; import { INTERNAL_METHODS, NOCK_TO_NICKS } from '../../../shared/constants'; import { formatNock, formatNick } from '../../../shared/currency'; import { useAutoRejectOnClose } from '../../hooks/useAutoRejectOnClose'; +import { getCurrentNocksterAccount, signRawTxWithNockster } from '../../utils/nockster'; export function TransactionApprovalScreen() { const { navigate, pendingTransactionRequest, setPendingTransactionRequest, wallet } = useStore(); + const [isApproving, setIsApproving] = useState(false); + const [approvalError, setApprovalError] = useState(''); if (!pendingTransactionRequest) { navigate('home'); @@ -32,9 +36,72 @@ export function TransactionApprovalScreen() { } async function handleApprove() { - await send(INTERNAL_METHODS.APPROVE_TRANSACTION, [id]); - setPendingTransactionRequest(null); - window.close(); + setIsApproving(true); + setApprovalError(''); + try { + const nockster = getCurrentNocksterAccount(wallet); + + if (nockster) { + let preparedWalletTxId: string | null = null; + let completionStarted = false; + + try { + const prepared = await send<{ + walletTx?: { id?: string }; + rawTx?: unknown; + error?: string; + }>(INTERNAL_METHODS.PREPARE_SEND_TRANSACTION_V2, [ + to, + amount, + fee, + false, + undefined, + 'provider_send', + true, + ]); + + if (prepared?.error) { + throw new Error(prepared.error); + } + if (!prepared?.walletTx?.id || !prepared.rawTx) { + throw new Error('Failed to prepare transaction for Nockster signing'); + } + + preparedWalletTxId = prepared.walletTx.id; + const signedTx = await signRawTxWithNockster(prepared.rawTx, nockster); + completionStarted = true; + const completed = await send<{ success?: boolean; txid?: string; error?: string }>( + INTERNAL_METHODS.COMPLETE_TRANSACTION, + [id, preparedWalletTxId, signedTx] + ); + + if (completed?.error) { + throw new Error(completed.error); + } + } catch (err) { + if (preparedWalletTxId && !completionStarted) { + await send(INTERNAL_METHODS.CANCEL_PREPARED_SEND_TRANSACTION_V2, [ + preparedWalletTxId, + ]).catch(console.error); + } + throw err; + } + } else { + const result = await send<{ success?: boolean; error?: string }>( + INTERNAL_METHODS.APPROVE_TRANSACTION, + [id] + ); + if (result?.error) { + throw new Error(result.error); + } + } + + setPendingTransactionRequest(null); + window.close(); + } catch (err) { + setApprovalError(err instanceof Error ? err.message : 'Failed to approve transaction'); + setIsApproving(false); + } } const bg = 'var(--color-bg)'; @@ -144,6 +211,15 @@ export function TransactionApprovalScreen() {
Balance after: {formatNock(wallet.spendableBalance - totalNum / NOCK_TO_NICKS)} NOCK
+ + {approvalError && ( +
+ {approvalError} +
+ )} @@ -156,8 +232,8 @@ export function TransactionApprovalScreen() { - diff --git a/extension/popup/screens/system/LockedScreen.tsx b/extension/popup/screens/system/LockedScreen.tsx index 57d246ff..1531842f 100644 --- a/extension/popup/screens/system/LockedScreen.tsx +++ b/extension/popup/screens/system/LockedScreen.tsx @@ -98,6 +98,8 @@ export function LockedScreen() { navigate('sign-message'); } else if (pendingSignRawTxRequest) { navigate('approve-sign-raw-tx'); + } else if (window.location.hash.slice(1) === 'nockster-pair') { + navigate('wallet-add-nockster'); } else { navigate('home'); } diff --git a/extension/popup/store.ts b/extension/popup/store.ts index 2b72eb33..7314da3f 100644 --- a/extension/popup/store.ts +++ b/extension/popup/store.ts @@ -67,6 +67,7 @@ export type Screen = | 'wallet-add-start' | 'wallet-add-create' | 'wallet-add-import' + | 'wallet-add-nockster' | 'wallet-add-backup' | 'wallet-add-verify' @@ -151,9 +152,12 @@ interface AppStore { createExternalSeedSource: (params: { address: string; name?: string; - provider?: 'ledger' | 'unknown'; + provider?: 'ledger' | 'nockster' | 'unknown'; sourceRef?: string; accountRef?: string; + publicKeyHex?: string; + slot?: number; + path?: number[]; }) => Promise; createChildAccount: (seedAccountId?: string, name?: string) => Promise; @@ -237,6 +241,7 @@ interface AppStore { // Balance fetching state isBalanceFetching: boolean; + balanceError: string | null; // Initialization state - true once cached balances have been loaded isInitialized: boolean; @@ -311,6 +316,7 @@ export const useStore = create((set, get) => ({ setSettingsAccountAddress: (address: string | null) => set({ settingsAccountAddress: address }), swapSubmittedToastVisible: false, isBalanceFetching: false, + balanceError: null, isInitialized: false, priceUsd: 0, priceChange24h: 0, @@ -519,10 +525,12 @@ export const useStore = create((set, get) => ({ try { // Check if we're opening for an approval request const hash = window.location.hash.slice(1); // Remove '#' + const isNocksterPairingRequest = hash === 'nockster-pair'; const isApprovalRequest = hash.startsWith(APPROVAL_CONSTANTS.CONNECT_HASH_PREFIX) || hash.startsWith(APPROVAL_CONSTANTS.TRANSACTION_HASH_PREFIX) || - hash.startsWith(APPROVAL_CONSTANTS.SIGN_MESSAGE_HASH_PREFIX); + hash.startsWith(APPROVAL_CONSTANTS.SIGN_MESSAGE_HASH_PREFIX) || + hash.startsWith(APPROVAL_CONSTANTS.SIGN_RAW_TX_HASH_PREFIX); // Get current vault state from service worker const state = await send<{ @@ -577,6 +585,8 @@ export const useStore = create((set, get) => ({ // For approval requests, don't override the screen // Let the approval useEffect handle navigation initialScreen = walletState.locked ? 'locked' : 'home'; + } else if (isNocksterPairingRequest) { + initialScreen = walletState.locked ? 'locked' : 'wallet-add-nockster'; } else if (!state.hasVault) { const incompleteOnboardingNoVault = await hasIncompleteOnboarding(); if (incompleteOnboardingNoVault) { @@ -655,15 +665,31 @@ export const useStore = create((set, get) => ({ // accounts keep whatever cached balances they had; they'll be refreshed // when the user switches to them. This avoids scaling balance-refresh // latency with wallet count. + let balanceError: string | null = null; + try { const syncResult = await send<{ - ok: boolean; + ok?: boolean; results?: Record; + error?: string; }>(INTERNAL_METHODS.SYNC_UTXOS, [currentAccount.address]); - if (!syncResult.ok) { + + const accountSync = syncResult?.results?.[currentAccount.address]; + if (!syncResult) { + balanceError = 'Balance sync returned no response'; + } else if (syncResult.error) { + balanceError = syncResult.error; + } else if (accountSync && !accountSync.success) { + balanceError = accountSync.error || 'Balance sync failed'; + } else if (!syncResult.ok) { + balanceError = 'Balance sync failed'; + } + + if (balanceError) { console.warn('[Store] UTXO sync failed:', syncResult); } } catch (syncErr) { + balanceError = syncErr instanceof Error ? syncErr.message : String(syncErr); console.warn('[Store] UTXO sync error:', syncErr); } // Vault may have updated walletTxStore during sync (incl. Nockblocks history). Reload list @@ -689,13 +715,31 @@ export const useStore = create((set, get) => ({ total: number; utxoCount: number; availableUtxoCount: number; + error?: string; }>(INTERNAL_METHODS.GET_BALANCE_FROM_STORE, [currentAccount.address]); + if (!storeBalance) { + throw new Error('Balance store returned no response'); + } + if (storeBalance.error) { + throw new Error(storeBalance.error); + } // Convert from nicks to NOCK for display const availableNock = storeBalance.available / NOCK_TO_NICKS; const spendableNock = storeBalance.spendableNow / NOCK_TO_NICKS; const totalNock = storeBalance.total / NOCK_TO_NICKS; const pendingOutNock = storeBalance.pendingOut / NOCK_TO_NICKS; + const hasConfirmedFundedHistory = get().walletTransactions.some( + tx => + tx.accountAddress === currentAccount.address && + tx.status === 'confirmed' && + (tx.amount || 0) > 0 && + (tx.direction === 'incoming' || tx.direction === 'self' || tx.migrationFromV0) + ); + if (!balanceError && availableNock === 0 && hasConfirmedFundedHistory) { + balanceError = + 'History shows confirmed incoming NOCK, but the live UTXO balance query returned zero spendable notes for this account.'; + } accountBalances[currentAccount.address] = availableNock; accountSpendableBalances[currentAccount.address] = spendableNock; accountBalanceDetails[currentAccount.address] = { @@ -705,6 +749,7 @@ export const useStore = create((set, get) => ({ available: availableNock, }; } catch (err) { + balanceError = balanceError || (err instanceof Error ? err.message : String(err)); console.warn(`[Store] Could not get balance for ${currentAccount.name}:`, err); // Keep previous balance if fetch fails if (accountBalances[currentAccount.address] === undefined) { @@ -741,6 +786,7 @@ export const useStore = create((set, get) => ({ const accountStillCurrent = latestCurrent?.address === currentAccount.address; set({ + balanceError, wallet: { ...get().wallet, ...(accountStillCurrent @@ -758,6 +804,7 @@ export const useStore = create((set, get) => ({ clearFetchingIfLatest(); } catch (error) { console.error('[Store] Failed to fetch balance:', error); + set({ balanceError: error instanceof Error ? error.message : String(error) }); clearFetchingIfLatest(); } }, diff --git a/extension/popup/utils/nockster.ts b/extension/popup/utils/nockster.ts new file mode 100644 index 00000000..28dbf918 --- /dev/null +++ b/extension/popup/utils/nockster.ts @@ -0,0 +1,502 @@ +import { + NocksterDevice, + serializeCheetahPublicKey, + formatCheetahPubkey, +} from '@swps/nockster-js'; +import type { SignMessageResponse } from '@nockbox/iris-sdk'; +import { guard } from '@nockbox/iris-sdk/wasm'; +import wasm from '../../shared/sdk-wasm.js'; +import { initWasmModules } from '../../shared/wasm-utils'; +import { assertNativeRawTx } from '../../shared/sign-raw-tx-compat'; +import type { SeedAccount, SubAccount } from '../../shared/types'; + +const NOCKSTER_SIGN_TIMEOUT_MS = 300_000; +const NOCKSTER_HID_VENDOR_ID = 0x303a; +const NOCKSTER_HID_PRODUCT_ID = 0x2001; +const NOCKSTER_SERIAL_VENDOR_ID = 0x303a; +const NOCKSTER_SERIAL_PRODUCT_ID = 0x1001; + +type NounLike = string | [NounLike, NounLike]; +export type NocksterTransportMode = 'hid' | 'serial'; +type NocksterTransportPreference = NocksterTransportMode | 'auto'; +type ConnectedNocksterDevice = { + device: NocksterDevice; + transport: NocksterTransportMode; +}; +type SerialApiWithGetPorts = NonNullable & { + getPorts?: () => Promise; +}; +type SerialPortWithInfo = SerialPort & { + getInfo?: () => { usbVendorId?: number; usbProductId?: number }; +}; +type HidDeviceWithIds = HIDDevice & { + vendorId?: number; + productId?: number; +}; + +type SerialTransportLike = { + connect(): Promise; + disconnect(): Promise; + write(data: Uint8Array): Promise; + startReading(onData: (data: Uint8Array) => void): void; + isConnected(): boolean; +}; + +type WalletLike = { + currentAccount: SubAccount | null; + activeSeedSourceId: string | null; + seedSources: Array>; +}; +type NocksterInfoResponse = Extract>, { type: 'Info' }>; + +export interface NocksterAccountRef { + slot: number; + path: number[]; + publicKeyHex: string; + publicKeyBase58?: string; + transport?: NocksterTransportMode; +} + +export interface PairedNocksterAccount extends NocksterAccountRef { + address: string; + firmware?: string; +} + +export class NocksterDeviceLockedError extends Error { + attemptsRemaining?: number; + + constructor(attemptsRemaining?: number) { + const attempts = + typeof attemptsRemaining === 'number' ? ` ${attemptsRemaining} attempts remaining.` : ''; + super(`Nockster is locked. Enter your device PIN to unlock it.${attempts}`); + this.name = 'NocksterDeviceLockedError'; + this.attemptsRemaining = attemptsRemaining; + } +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); +} + +function hexToBytes(hex: string): Uint8Array { + if (!/^[0-9a-fA-F]*$/.test(hex) || hex.length % 2 !== 0) { + throw new Error('Invalid hex string'); + } + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +function tupleNoun(values: NounLike[]): NounLike { + if (values.length === 0) return '0'; + let acc: NounLike = values[values.length - 1]; + for (let i = values.length - 2; i >= 0; i--) { + acc = [values[i], acc]; + } + return acc; +} + +function limbToAtom(limb: bigint): string { + if (limb < 0n) { + throw new Error('Negative signature limb'); + } + return limb.toString(16); +} + +function schnorrSignatureToWasmSignature(chal: bigint[], sig: bigint[]): wasm.Signature { + if (chal.length !== 8 || sig.length !== 8) { + throw new Error('Nockster returned an invalid message signature'); + } + const noun = tupleNoun([tupleNoun(chal.map(limbToAtom)), tupleNoun(sig.map(limbToAtom))]); + return wasm.signatureFromNoun(noun as wasm.Noun); +} + +class WebSerialNocksterTransport implements SerialTransportLike { + private port: SerialPort | null = null; + private reader: ReadableStreamDefaultReader | null = null; + private writer: WritableStreamDefaultWriter | null = null; + private reading = false; + + async connect(): Promise { + if (!navigator.serial) { + throw new Error('Web Serial API not supported in this browser'); + } + + const serial = navigator.serial as SerialApiWithGetPorts; + const port = (await findAuthorizedSerialPort(serial)) ?? (await serial.requestPort()); + + await port.open({ baudRate: 115200 }); + if (!port.writable) { + await port.close(); + throw new Error('Selected serial device is not writable'); + } + + this.port = port; + this.writer = port.writable.getWriter(); + } + + async disconnect(): Promise { + const reader = this.reader; + this.reader = null; + this.reading = false; + + if (reader) { + try { + await reader.cancel(); + } catch { + // Ignore disconnect races. + } + } + + const writer = this.writer; + this.writer = null; + if (writer) { + try { + await writer.close(); + } catch { + // Ignore disconnect races. + } finally { + try { + writer.releaseLock(); + } catch { + // Ignore already-released locks. + } + } + } + + const port = this.port; + this.port = null; + if (port) { + try { + await port.close(); + } catch { + // Ignore disconnect races. + } + } + } + + async write(data: Uint8Array): Promise { + if (!this.writer) { + throw new Error('Serial device not connected'); + } + await this.writer.write(data); + } + + startReading(onData: (data: Uint8Array) => void): void { + if (this.reading) return; + this.reading = true; + void this.readLoop(onData); + } + + isConnected(): boolean { + return Boolean(this.port && this.writer); + } + + private async readLoop(onData: (data: Uint8Array) => void): Promise { + const port = this.port; + if (!port?.readable) return; + + const reader = port.readable.getReader(); + this.reader = reader; + + try { + while (this.reading) { + const { value, done } = await reader.read(); + if (done) break; + if (value) onData(value); + } + } catch (error) { + console.warn('[Nockster] Serial read failed:', error); + } finally { + if (this.reader === reader) { + this.reader = null; + } + this.reading = false; + try { + reader.releaseLock(); + } catch { + // Ignore already-released locks. + } + } + } +} + +function isNocksterSerialPort(port: SerialPort): boolean { + const info = (port as SerialPortWithInfo).getInfo?.(); + if (!info?.usbVendorId) return false; + return ( + info.usbVendorId === NOCKSTER_SERIAL_VENDOR_ID && + (!info.usbProductId || info.usbProductId === NOCKSTER_SERIAL_PRODUCT_ID) + ); +} + +async function findAuthorizedSerialPort(serial: SerialApiWithGetPorts): Promise { + const ports = (await serial.getPorts?.()) ?? []; + return ports.find(isNocksterSerialPort) ?? (ports.length === 1 ? ports[0] : null); +} + +function isNocksterHidDevice(device: HIDDevice): boolean { + const withIds = device as HidDeviceWithIds; + return ( + withIds.vendorId === NOCKSTER_HID_VENDOR_ID && + withIds.productId === NOCKSTER_HID_PRODUCT_ID + ); +} + +function isNoHidDeviceSelected(error: unknown): boolean { + if (error instanceof DOMException) { + return error.name === 'NotFoundError'; + } + return error instanceof Error && error.message === 'No HID device selected'; +} + +async function ensureNocksterUnlocked(device: NocksterDevice, pin?: string): Promise { + const status = await device.getLockStatus(); + if (!status.locked) return; + + if (!pin) { + throw new NocksterDeviceLockedError(status.attempts_remaining); + } + + await device.unlock(pin); + const nextStatus = await device.getLockStatus(); + if (nextStatus.locked) { + throw new NocksterDeviceLockedError(nextStatus.attempts_remaining); + } +} + +async function connectHidNocksterDevice(): Promise { + if (!navigator.hid) { + throw new Error('WebHID API not supported in this browser'); + } + + const device = new NocksterDevice(); + const authorizedDevices = await navigator.hid.getDevices(); + const authorizedNockster = authorizedDevices.find(isNocksterHidDevice); + if (authorizedNockster) { + await device.connectHidDevice(authorizedNockster); + return device; + } + + const selectedDevices = await navigator.hid.requestDevice({ + filters: [{ vendorId: NOCKSTER_HID_VENDOR_ID, productId: NOCKSTER_HID_PRODUCT_ID }], + }); + if (!selectedDevices.length) { + throw new Error('No HID device selected'); + } + + await device.connectHidDevice(selectedDevices[0]); + return device; +} + +async function connectSerialNocksterDevice(): Promise { + const serialDevice = new NocksterDevice(new WebSerialNocksterTransport()); + await serialDevice.connect(); + return serialDevice; +} + +async function connectNocksterDevice( + preferredTransport: NocksterTransportPreference = 'auto' +): Promise { + if (preferredTransport === 'hid') { + return { device: await connectHidNocksterDevice(), transport: 'hid' }; + } + if (preferredTransport === 'serial') { + return { device: await connectSerialNocksterDevice(), transport: 'serial' }; + } + + if (navigator.hid) { + try { + return { device: await connectHidNocksterDevice(), transport: 'hid' }; + } catch (error) { + if (!isNoHidDeviceSelected(error) || !navigator.serial) { + throw error; + } + } + } + + return { device: await connectSerialNocksterDevice(), transport: 'serial' }; +} + +async function withNocksterDevice( + fn: (device: NocksterDevice, transport: NocksterTransportMode) => Promise, + preferredTransport: NocksterTransportPreference = 'auto' +): Promise { + if (!NocksterDevice.isSupported()) { + throw new Error('Nockster requires WebHID or Web Serial support'); + } + + const { device, transport } = await connectNocksterDevice(preferredTransport); + try { + return await fn(device, transport); + } finally { + if (device.isConnected()) { + await device.disconnect(); + } + } +} + +export async function pairNocksterAccount( + preferredTransport: NocksterTransportMode = 'hid', + pin?: string +): Promise { + const [account] = await listNocksterAccounts(preferredTransport, pin); + if (!account) { + throw new Error('Nockster did not report any public keys'); + } + return account; +} + +function assertNocksterInfo( + info: Awaited> +): NocksterInfoResponse { + if (info.type === 'Err') { + throw new Error('Nockster returned an error while reading device info'); + } + if (info.type !== 'Info') { + throw new Error(`Unexpected Nockster response: ${info.type}`); + } + if (!info.has_seed) { + throw new Error('Nockster has no seed configured'); + } + return info; +} + +async function buildPairedNocksterAccounts( + info: NocksterInfoResponse, + transport: NocksterTransportMode +): Promise { + if (!info.cheetah_pubs.length) { + throw new Error('Nockster did not report any public keys'); + } + + await initWasmModules(); + return info.cheetah_pubs.map(pub => { + const publicKeyBytes = serializeCheetahPublicKey(pub.x, pub.y); + return { + address: wasm.hashPublicKey(publicKeyBytes), + slot: pub.slot, + path: pub.path, + publicKeyHex: bytesToHex(publicKeyBytes), + publicKeyBase58: formatCheetahPubkey(pub.x, pub.y), + transport, + firmware: `${info.fw_major}.${info.fw_minor}`, + }; + }); +} + +export async function listNocksterAccounts( + preferredTransport: NocksterTransportMode = 'hid', + pin?: string +): Promise { + return withNocksterDevice(async (device, transport) => { + await ensureNocksterUnlocked(device, pin); + return buildPairedNocksterAccounts(assertNocksterInfo(await device.getInfo()), transport); + }, preferredTransport); +} + +function parseNocksterSourceRef(sourceRef?: string): Partial { + if (!sourceRef) return {}; + try { + const parsed = JSON.parse(sourceRef) as Partial; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + +function normalizeNocksterTransport(value: unknown): NocksterTransportMode | undefined { + return value === 'hid' || value === 'serial' ? value : undefined; +} + +export function getCurrentNocksterAccount(wallet: WalletLike): NocksterAccountRef | null { + const current = wallet.currentAccount; + if (!current) return null; + + const seed = + wallet.seedSources.find(s => s.accounts.some(a => a.address === current.address)) ?? + wallet.seedSources.find(s => s.id === wallet.activeSeedSourceId); + const external = seed?.external; + if (!external) return null; + + const fallback = parseNocksterSourceRef(external.sourceRef); + const hasNocksterRef = + typeof fallback.slot === 'number' && + Array.isArray(fallback.path) && + typeof fallback.publicKeyHex === 'string'; + if (external.provider !== 'nockster' && !hasNocksterRef) return null; + + const slot = external.slot ?? fallback.slot; + const path = external.path ?? fallback.path; + const publicKeyHex = external.publicKeyHex ?? fallback.publicKeyHex; + + if (typeof slot !== 'number' || !Array.isArray(path) || typeof publicKeyHex !== 'string') { + throw new Error('Nockster account metadata is incomplete'); + } + + return { + slot, + path, + publicKeyHex, + publicKeyBase58: fallback.publicKeyBase58, + transport: normalizeNocksterTransport(fallback.transport), + }; +} + +export async function signRawTxWithNockster( + rawTx: unknown, + account: NocksterAccountRef +): Promise { + assertNativeRawTx(rawTx); + if (!guard.isRawTxV1(rawTx)) { + throw new Error('Only v1 raw transactions are supported'); + } + + return withNocksterDevice(async device => { + await initWasmModules(); + await device.selectSeed(account.slot); + const draft = wasm.jam(wasm.rawTxToNoun(rawTx)); + const signedDraft = await device.signDraft(draft, NOCKSTER_SIGN_TIMEOUT_MS); + const signedRawTx = wasm.rawTxFromNoun(wasm.cue(signedDraft)); + if (!guard.isRawTxV1(signedRawTx)) { + throw new Error('Nockster returned a non-v1 transaction'); + } + return wasm.rawTxV1ToNockchainTx(signedRawTx); + }, account.transport ?? 'auto'); +} + +export async function signMessageWithNockster( + message: string, + account: NocksterAccountRef +): Promise { + const encoded = new TextEncoder().encode(message); + + return withNocksterDevice(async device => { + await initWasmModules(); + const publicKeyBytes = hexToBytes(account.publicKeyHex); + const publicKey = wasm.publicKeyFromBeBytes(publicKeyBytes); + await device.selectSeed(account.slot); + const sig = await device.signMessage( + account.slot, + account.path, + encoded, + NOCKSTER_SIGN_TIMEOUT_MS + ); + const signature = schnorrSignatureToWasmSignature(sig.chal, sig.sig); + + try { + const valid = wasm.verifySignature(publicKeyBytes, signature, message); + if (!valid) { + throw new Error('Nockster message signature did not verify'); + } + } catch (error) { + throw error instanceof Error ? error : new Error('Nockster message signature failed'); + } + + return { signature, publicKey }; + }, account.transport ?? 'auto'); +} diff --git a/extension/shared/constants.ts b/extension/shared/constants.ts index c344ca2d..8fcf8f86 100644 --- a/extension/shared/constants.ts +++ b/extension/shared/constants.ts @@ -124,6 +124,18 @@ export const INTERNAL_METHODS = { /** Send transaction using UTXO store (build, lock, broadcast atomically) */ SEND_TRANSACTION_V2: 'wallet:sendTransactionV2', + /** Build and reserve an unsigned transaction for an external signer */ + PREPARE_SEND_TRANSACTION_V2: 'wallet:prepareSendTransactionV2', + + /** Broadcast a prepared transaction after an external signer returns it */ + COMPLETE_SEND_TRANSACTION_V2: 'wallet:completeSendTransactionV2', + + /** Cancel a prepared external-signing transaction and release reserved notes */ + CANCEL_PREPARED_SEND_TRANSACTION_V2: 'wallet:cancelPreparedSendTransactionV2', + + /** Complete a pending provider send request with an externally signed transaction */ + COMPLETE_TRANSACTION: 'wallet:completeTransaction', + /** Estimate bridge transaction fee for a given destination and amount */ ESTIMATE_BRIDGE_FEE: 'wallet:estimateBridgeFee', @@ -136,6 +148,12 @@ export const INTERNAL_METHODS = { /** Approve pending sign raw transaction request */ APPROVE_SIGN_RAW_TX: 'wallet:approveSignRawTx', + /** Complete pending sign message request with an externally produced signature */ + COMPLETE_SIGN_MESSAGE: 'wallet:completeSignMessage', + + /** Complete pending sign raw transaction request with an externally signed tx */ + COMPLETE_SIGN_RAW_TX: 'wallet:completeSignRawTx', + /** Reject pending sign raw transaction request */ REJECT_SIGN_RAW_TX: 'wallet:rejectSignRawTx', diff --git a/extension/shared/rpc-client-browser.ts b/extension/shared/rpc-client-browser.ts index 6edc68d9..0b760753 100644 --- a/extension/shared/rpc-client-browser.ts +++ b/extension/shared/rpc-client-browser.ts @@ -174,7 +174,15 @@ export class NockchainBrowserRPCClient { * Convert proto Balance format to our Note[] interface */ private convertBalanceToNotes(balance: wasm.PbCom2Balance): Balance { - if (!balance.notes || balance.notes.length === 0 || !balance.height) { + const heightValue = balance.height?.value ?? balance.height ?? 0; + const height = + typeof heightValue === 'bigint' + ? Number(heightValue) + : typeof heightValue === 'string' + ? Number(heightValue) + : Number(heightValue || 0); + + if (!balance.notes || balance.notes.length === 0) { return { notes: [], height: 0 }; } @@ -191,7 +199,7 @@ export class NockchainBrowserRPCClient { } } - return { notes, height: Number(balance.height.value) }; + return { notes, height }; } /** diff --git a/extension/shared/transaction-builder.ts b/extension/shared/transaction-builder.ts index a5409458..e1c42cbd 100644 --- a/extension/shared/transaction-builder.ts +++ b/extension/shared/transaction-builder.ts @@ -150,6 +150,8 @@ export interface TransactionParams { blockHeight?: number; } +export type UnsignedTransactionParams = Omit; + /** * Constructed transaction ready for broadcast */ @@ -164,13 +166,18 @@ export interface ConstructedTransaction { feeUsed: number; } -/** - * Build a complete Nockchain transaction using the new builder API - * - * @param params - Transaction parameters - * @returns Constructed transaction ready for broadcast - */ -export async function buildTransaction(params: TransactionParams): Promise { +export interface UnsignedConstructedTransaction { + /** Unsigned raw transaction draft for an external signer */ + rawTx: wasm.RawTx; + /** Fee used in the transaction (in nicks) */ + feeUsed: number; + /** Transaction version */ + version: number; +} + +async function buildTransactionDraft( + params: UnsignedTransactionParams +): Promise<{ builder: wasm.TxBuilder; feeUsed: number }> { // Initialize both WASM modules await ensureWasmInitialized(); @@ -181,7 +188,6 @@ export async function buildTransaction(params: TransactionParams): Promise { + const { builder, feeUsed } = await buildTransactionDraft(params); + + try { + // Sign and validate the transaction + await builder.sign(params.privateKey); + builder.validate(); + + // Build the final transaction + const nockchainTx = builder.build(); + + return { + txId: getTxIdCompat(nockchainTx), + version: 1, // V1 only + nockchainTx, + feeUsed, + }; + } finally { + builder.free(); + } +} + +/** + * Build an unsigned raw transaction draft for an external signer. + */ +export async function buildUnsignedTransaction( + params: UnsignedTransactionParams +): Promise { + const { builder, feeUsed } = await buildTransactionDraft(params); + + try { + const nockchainTx = builder.build(); + return { + rawTx: wasm.nockchainTxToRawTx(nockchainTx), + version: 1, + feeUsed, + }; + } finally { + builder.free(); + } +} + +async function buildMultiNotePaymentDraft( notes: Note[], recipientPKH: string, amount: Nicks, senderPublicKey: Uint8Array, - privateKey: wasm.PrivateKey, fee?: Nicks, refundPKH?: string, - blockHeight?: number -): Promise { + blockHeight?: number, + includeLockData = false +): Promise { // Initialize WASM await ensureWasmInitialized(); @@ -339,20 +364,100 @@ export async function buildMultiNotePayment( const changeAddress = refundPKH ?? senderPKH; // Build transaction with all notes and their individual spend conditions - return buildTransaction({ + return buildUnsignedTransaction({ notes, spendCondition: spendConditions, // Array of spend conditions (one per note) recipientPKH, amount, fee, refundPKH: changeAddress, - privateKey, - // include_lock_data: false for lower fees (0.5 NOCK per word saved) - includeLockData: false, + // Hardware wallets need output lock data to render PKH recipients instead of lock roots. + includeLockData, blockHeight, }); } +/** + * Create an unsigned payment transaction using multiple notes (UTXOs). + */ +export async function buildUnsignedMultiNotePayment( + notes: Note[], + recipientPKH: string, + amount: Nicks, + senderPublicKey: Uint8Array, + fee?: Nicks, + refundPKH?: string, + blockHeight?: number, + includeLockData = false +): Promise { + return buildMultiNotePaymentDraft( + notes, + recipientPKH, + amount, + senderPublicKey, + fee, + refundPKH, + blockHeight, + includeLockData + ); +} + +/** + * Create a payment transaction using multiple notes (UTXOs) + * + * This allows spending from multiple UTXOs when a single UTXO doesn't have + * sufficient balance. The transaction will use all provided notes as inputs. + * + * @param notes - Array of UTXOs to spend + * @param recipientPKH - Recipient's PKH digest string + * @param amount - Amount to send in nicks + * @param senderPublicKey - Your public key (97 bytes, for creating spend condition) + * @param privateKey - Your private key (wasm object) + * @param fee - Transaction fee in nicks (optional, WASM will auto-calculate if not provided) + * @param refundPKH - Override for change address (optional, defaults to sender's PKH). + * Set to recipientPKH for "send max" to sweep all funds to recipient. + * @param blockHeight - Current block height for tx engine selection (optional). + * @returns Constructed transaction + */ +export async function buildMultiNotePayment( + notes: Note[], + recipientPKH: string, + amount: Nicks, + senderPublicKey: Uint8Array, + privateKey: wasm.PrivateKey, + fee?: Nicks, + refundPKH?: string, + blockHeight?: number, + includeLockData = false +): Promise { + const unsigned = await buildMultiNotePaymentDraft( + notes, + recipientPKH, + amount, + senderPublicKey, + fee, + refundPKH, + blockHeight, + includeLockData + ); + + const settings = await getTxEngineSettingsForHeight(blockHeight ?? 0); + const builder = wasm.TxBuilder.fromRawTx(unsigned.rawTx, settings); + try { + await builder.sign(privateKey); + builder.validate(); + const nockchainTx = builder.build(); + return { + txId: getTxIdCompat(nockchainTx), + version: 1, + nockchainTx, + feeUsed: unsigned.feeUsed, + }; + } finally { + builder.free(); + } +} + /** * Create a spend condition for a single public key * Helper function for the common case diff --git a/extension/shared/types.ts b/extension/shared/types.ts index 683ffeec..8ce4c220 100644 --- a/extension/shared/types.ts +++ b/extension/shared/types.ts @@ -40,8 +40,12 @@ export interface SeedAccount { createdAt: number; accounts: SubAccount[]; external?: { - provider: 'ledger' | 'unknown'; + provider: 'ledger' | 'nockster' | 'unknown'; sourceRef?: string; + accountRef?: string; + publicKeyHex?: string; + slot?: number; + path?: number[]; }; } @@ -105,6 +109,8 @@ export interface TransactionDetails { protobufTx?: any; /** Whether this is a sweep transaction (all UTXOs sent to recipient) */ sendMax?: boolean; + /** Nockster-only: omit output lock data to reduce fee; device will show lock roots. */ + nocksterFeeMinimized?: boolean; } /** diff --git a/extension/shared/vault.ts b/extension/shared/vault.ts index 4cce04c0..b9d71404 100644 --- a/extension/shared/vault.ts +++ b/extension/shared/vault.ts @@ -21,6 +21,7 @@ import { import { SubAccount, SeedAccount } from './types'; import { buildMultiNotePayment, + buildUnsignedMultiNotePayment, discoverSpendConditionForNote, type Note, } from './transaction-builder'; @@ -94,6 +95,50 @@ function nockchainTxToProtobuf(tx: wasm.NockchainTx): any { return wasm.rawTxToProtobuf(rawTx); } +function hexToBytes(hex: string): Uint8Array { + if (!/^[0-9a-fA-F]*$/.test(hex) || hex.length % 2 !== 0) { + throw new Error('Invalid hex string'); + } + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +function parseExternalSourceRef(sourceRef?: string): { + publicKeyHex?: string; + slot?: number; + path?: number[]; +} { + if (!sourceRef) return {}; + try { + const parsed = JSON.parse(sourceRef) as { + publicKeyHex?: unknown; + slot?: unknown; + path?: unknown; + }; + if (!parsed || typeof parsed !== 'object') return {}; + return { + publicKeyHex: typeof parsed.publicKeyHex === 'string' ? parsed.publicKeyHex : undefined, + slot: typeof parsed.slot === 'number' ? parsed.slot : undefined, + path: Array.isArray(parsed.path) ? parsed.path.filter(Number.isInteger) : undefined, + }; + } catch { + return {}; + } +} + +function isNocksterExternalSource(external: NonNullable): boolean { + if (external.provider === 'nockster') return true; + const sourceRef = parseExternalSourceRef(external.sourceRef); + return ( + typeof sourceRef.slot === 'number' && + Array.isArray(sourceRef.path) && + typeof sourceRef.publicKeyHex === 'string' + ); +} + /** * Convert a balance query note to transaction builder note format * @param note - Note from balance query (with Uint8Array names) @@ -471,6 +516,40 @@ export class Vault { return seedAccount.mnemonic || null; } + private getExternalPublicKeyForCurrentAccount( + provider?: NonNullable['provider'] + ) { + const currentAccount = this.getCurrentAccount(); + const seedAccount = this.getSeedAccountForWallet(currentAccount); + if (!seedAccount || seedAccount.type !== 'external' || !seedAccount.external) { + return null; + } + if (provider === 'nockster' && !isNocksterExternalSource(seedAccount.external)) { + return null; + } + if (provider && provider !== 'nockster' && seedAccount.external.provider !== provider) { + return null; + } + const sourceRef = parseExternalSourceRef(seedAccount.external.sourceRef); + const publicKeyHex = seedAccount.external.publicKeyHex ?? sourceRef.publicKeyHex; + if (!publicKeyHex) { + return null; + } + return hexToBytes(publicKeyHex); + } + + private findWalletTransactionById( + walletTxId: string + ): { accountAddress: string; walletTx: WalletTransaction } | null { + for (const [accountAddress, transactions] of Object.entries(this.walletTxStore)) { + const walletTx = transactions.find(tx => tx.id === walletTxId); + if (walletTx) { + return { accountAddress, walletTx }; + } + } + return null; + } + /** * Check if a vault exists in storage (without decrypting) * This is safe to call even after service worker restart @@ -1135,9 +1214,12 @@ export class Vault { async createExternalSeedSource(params: { address: string; name?: string; - provider?: 'ledger' | 'unknown'; + provider?: 'ledger' | 'nockster' | 'unknown'; sourceRef?: string; accountRef?: string; + publicKeyHex?: string; + slot?: number; + path?: number[]; }): Promise< { seedSource: Omit; account: SubAccount } | { error: string } > { @@ -1178,6 +1260,10 @@ export class Vault { external: { provider, sourceRef: params.sourceRef, + accountRef: params.accountRef, + publicKeyHex: params.publicKeyHex, + slot: params.slot, + path: params.path, }, }; @@ -3126,7 +3212,8 @@ export class Vault { */ async estimateTransactionFee( to: string, - amount: Nicks + amount: Nicks, + externalIncludeLockData = true ): Promise<{ fee: number } | { error: string }> { if (this.state.locked) { return { error: ERROR_CODES.LOCKED }; @@ -3138,7 +3225,44 @@ export class Vault { } const signingMnemonic = this.getSigningMnemonicForCurrentAccount(); if (!signingMnemonic) { - return { error: 'Current account is external and cannot sign locally' }; + const publicKey = this.getExternalPublicKeyForCurrentAccount('nockster'); + if (!publicKey) { + return { error: 'Current account is external and cannot sign locally' }; + } + + try { + await initWasmModules(); + + const endpoint = await getEffectiveRpcEndpoint(); + const rpcClient = createBrowserClient(endpoint); + const balanceResult = await queryV1Balance(currentAccount.address, rpcClient); + + if (balanceResult.utxoCount === 0) { + return { error: 'No UTXOs available. Your wallet may have zero balance.' }; + } + + const notes = [...balanceResult.simpleNotes, ...balanceResult.coinbaseNotes]; + const sortedNotes = [...notes].sort((a, b) => b.assets - a.assets); + const txBuilderNotes = await Promise.all( + sortedNotes.map(note => convertNoteForTxBuilder(note, currentAccount.address)) + ); + + const unsignedTx = await buildUnsignedMultiNotePayment( + txBuilderNotes, + to, + amount, + publicKey, + undefined, + undefined, + balanceResult.blockHeight, + externalIncludeLockData + ); + + return { fee: unsignedTx.feeUsed }; + } catch (error) { + console.error('[Vault] External fee estimation failed:', error); + return { error: feeEstimateUserFacingError(error, 'fee') }; + } } try { @@ -3224,7 +3348,8 @@ export class Vault { * @returns Max sendable amount and fee in nicks, or { error } */ async estimateMaxSendAmount( - to: string + to: string, + externalIncludeLockData = true ): Promise< | { maxAmount: number; fee: number; totalAvailable: number; utxoCount: number } | { error: string } @@ -3239,7 +3364,59 @@ export class Vault { } const signingMnemonic = this.getSigningMnemonicForCurrentAccount(); if (!signingMnemonic) { - return { error: 'Current account is external and cannot sign locally' }; + const publicKey = this.getExternalPublicKeyForCurrentAccount('nockster'); + if (!publicKey) { + return { error: 'Current account is external and cannot sign locally' }; + } + + try { + await initWasmModules(); + + const notes = this.getAvailableNotes(currentAccount.address); + const blockHeight = this.getAccountBlockHeight(currentAccount.address); + + if (notes.length === 0) { + return { error: 'No spendable UTXOs available.' }; + } + + const totalAvailable = notes.reduce((sum, note) => sum + note.assets, 0); + const txBuilderNotes = notes.map(convertStoredNoteForTxBuilder); + const sortedByValue = [...notes].sort((a, b) => a.assets - b.assets); + const smallestNote = sortedByValue[0].assets; + const estimationAmount = totalAvailable - Math.floor(smallestNote / 2); + + if (estimationAmount <= 0) { + return { error: 'Balance too low to send. Need more than fee amount.' }; + } + + const unsignedTx = await buildUnsignedMultiNotePayment( + txBuilderNotes, + to, + String(estimationAmount) as Nicks, + publicKey, + undefined, + to, + blockHeight, + externalIncludeLockData + ); + + const fee = unsignedTx.feeUsed; + const maxAmount = totalAvailable - fee; + + if (maxAmount <= 0) { + return { error: 'Balance too low. Fee would exceed available funds.' }; + } + + return { + maxAmount, + fee, + totalAvailable, + utxoCount: notes.length, + }; + } catch (error) { + console.error('[Vault] External max send estimation failed:', error); + return { error: feeEstimateUserFacingError(error, 'max') }; + } } try { @@ -3437,6 +3614,258 @@ export class Vault { } } + /** + * Build an unsigned transaction draft for an external signer using UTXO store bookkeeping. + * + * This reserves the selected notes and creates a wallet transaction record. Call + * completePreparedSendTransactionV2 after the external signer returns a signed tx, + * or cancelPreparedSendTransactionV2 to release notes if signing is abandoned. + */ + async prepareSendTransactionV2( + to: string, + amount: Nicks, + fee?: Nicks, + sendMax?: boolean, + priceUsdAtTime?: number, + origin: WalletTransaction['origin'] = 'popup_send', + externalIncludeLockData = true + ): Promise< + { walletTx: WalletTransaction; rawTx: wasm.RawTx; fee: number; broadcasted: false } | { error: string } + > { + if (this.state.locked) { + return { error: ERROR_CODES.LOCKED }; + } + + const currentAccount = this.getCurrentAccount(); + if (!currentAccount) { + return { error: ERROR_CODES.NO_ACCOUNT }; + } + + const publicKey = this.getExternalPublicKeyForCurrentAccount('nockster'); + if (!publicKey) { + return { error: 'Current account is not a paired Nockster account' }; + } + + return withAccountLock(currentAccount.address, async () => { + const walletTxId = crypto.randomUUID(); + let selectedNoteIds: string[] = []; + + try { + await initWasmModules(); + + const availableStoredNotes = this.getAvailableNotes(currentAccount.address); + const blockHeight = this.getAccountBlockHeight(currentAccount.address); + + if (availableStoredNotes.length === 0) { + return { + error: + 'No spendable UTXOs are available after balance sync. If this account has recent incoming history, the RPC balance query did not return unspent notes for this address yet.', + }; + } + + const estimatedFeeNum = fee !== undefined ? Number(fee) : 2 * NOCK_TO_NICKS; + + let selectedStoredNotes: typeof availableStoredNotes; + + if (sendMax) { + selectedStoredNotes = availableStoredNotes; + } else { + const targetAmount = Number(amount) + estimatedFeeNum; + const selected = selectNotesForAmount(availableStoredNotes, targetAmount); + + if (!selected) { + return { error: 'Insufficient available funds' }; + } + + selectedStoredNotes = selected; + } + + selectedNoteIds = selectedStoredNotes.map(n => n.noteId); + const selectedTotal = selectedStoredNotes.reduce((sum, n) => sum + n.assets, 0); + + await this.markNotesInFlight(currentAccount.address, selectedNoteIds, walletTxId); + + const sortedStoredNotes = [...selectedStoredNotes].sort((a, b) => b.assets - a.assets); + const txBuilderNotes = sortedStoredNotes.map(convertStoredNoteForTxBuilder); + const refundAddress = sendMax ? to : undefined; + + const unsignedTx = await buildUnsignedMultiNotePayment( + txBuilderNotes, + to, + amount, + publicKey, + fee, + refundAddress, + blockHeight, + externalIncludeLockData + ); + + const expectedChange = sendMax + ? 0 + : selectedTotal - Number(amount) - unsignedTx.feeUsed; + + const walletTx: WalletTransaction = { + id: walletTxId, + accountAddress: currentAccount.address, + direction: 'outgoing', + createdAt: Date.now(), + updatedAt: Date.now(), + priceUsdAtTime, + status: 'created', + origin, + inputNoteIds: selectedNoteIds, + recipient: to, + amount: Number(amount), + fee: unsignedTx.feeUsed, + expectedChange: expectedChange > 0 ? expectedChange : 0, + }; + await this.addWalletTransaction(walletTx); + + return { + walletTx, + rawTx: unsignedTx.rawTx, + fee: unsignedTx.feeUsed, + broadcasted: false, + }; + } catch (error) { + console.error('[Vault V2] Transaction draft preparation 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 V2] Error releasing notes:', releaseError); + } + } + + const rawMsg = error instanceof Error ? error.message : String(error); + return { + error: `Transaction failed: ${rewriteInsufficientFeeErrorToDecimalNock(rawMsg)}`, + }; + } + }); + } + + /** + * Broadcast an externally signed transaction prepared by prepareSendTransactionV2. + */ + async completePreparedSendTransactionV2( + walletTxId: string, + signedTx: wasm.NockchainTx + ): Promise< + { txId: string; walletTx: WalletTransaction; broadcasted: boolean } | { error: string } + > { + if (this.state.locked) { + return { error: ERROR_CODES.LOCKED }; + } + + const located = this.findWalletTransactionById(walletTxId); + if (!located) { + return { error: ERROR_CODES.NOT_FOUND }; + } + + const { accountAddress, walletTx } = located; + if ( + walletTx.status === 'broadcasted_unconfirmed' || + walletTx.status === 'mempool_seen' || + walletTx.status === 'confirmed' + ) { + return { error: 'Transaction has already been broadcast' }; + } + + return withAccountLock(accountAddress, async () => { + try { + await initWasmModules(); + const signedRawTx = wasm.nockchainTxToRawTx(signedTx); + assertNativeRawTx(signedRawTx); + if (!guard.isRawTxV1(signedRawTx)) { + throw new Error('Only v1 raw transactions are supported'); + } + + const txId = signedRawTx.id; + const feeUsed = Number(wasm.rawTxTotalFees(signedRawTx)); + + await this.updateWalletTransaction(accountAddress, walletTxId, { + status: 'broadcast_pending', + }); + + const endpoint = await getEffectiveRpcEndpoint(); + const rpcClient = createBrowserClient(endpoint); + const protobufTx = wasm.rawTxToProtobuf(signedRawTx); + await rpcClient.sendTransaction(protobufTx); + + const updatedWalletTx: WalletTransaction = { + ...walletTx, + fee: feeUsed, + txHash: txId, + trackingTxId: txId, + status: 'broadcasted_unconfirmed', + updatedAt: Date.now(), + lastMempoolCheckAt: Date.now(), + lastConfirmationCheckAt: 0, + }; + await this.updateWalletTransaction(accountAddress, walletTxId, updatedWalletTx); + + return { + txId, + walletTx: updatedWalletTx, + broadcasted: true, + }; + } catch (error) { + console.error('[Vault V2] Prepared transaction broadcast failed:', error); + + if (walletTx.inputNoteIds?.length) { + try { + await this.releaseInFlightNotes(accountAddress, walletTx.inputNoteIds); + await this.updateWalletTransaction(accountAddress, walletTxId, { + status: 'failed', + }); + } catch (releaseError) { + console.error('[Vault V2] Error releasing notes:', releaseError); + } + } + + const rawMsg = error instanceof Error ? error.message : String(error); + return { + error: `Transaction failed: ${rewriteInsufficientFeeErrorToDecimalNock(rawMsg)}`, + }; + } + }); + } + + async cancelPreparedSendTransactionV2( + walletTxId: string + ): Promise<{ ok: true } | { error: string }> { + const located = this.findWalletTransactionById(walletTxId); + if (!located) { + return { error: ERROR_CODES.NOT_FOUND }; + } + + const { accountAddress, walletTx } = located; + if ( + walletTx.status === 'broadcasted_unconfirmed' || + walletTx.status === 'mempool_seen' || + walletTx.status === 'confirmed' + ) { + return { ok: true }; + } + + try { + if (walletTx.inputNoteIds?.length) { + await this.releaseInFlightNotes(accountAddress, walletTx.inputNoteIds); + } + await this.updateWalletTransaction(accountAddress, walletTxId, { + status: 'failed', + }); + return { ok: true }; + } catch (error) { + return { error: error instanceof Error ? error.message : 'Failed to cancel transaction' }; + } + } + /** * Build, sign, and broadcast a transaction using UTXO store * This is the new preferred method for sending transactions @@ -3507,7 +3936,10 @@ export class Vault { const blockHeight = this.getAccountBlockHeight(currentAccount.address); if (availableStoredNotes.length === 0) { - return { error: 'No available UTXOs.' }; + return { + error: + 'No spendable UTXOs are available after balance sync. If this account has recent incoming history, the RPC balance query did not return unspent notes for this address yet.', + }; } const totalAvailable = availableStoredNotes.reduce((sum, n) => sum + n.assets, 0); diff --git a/package-lock.json b/package-lock.json index a083c9d4..d4f9d236 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { "name": "iris", - "version": "1.2.4", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "iris", - "version": "1.2.4", + "version": "1.3.0", "dependencies": { "@fontsource/lora": "^5.2.8", "@nockbox/iris-sdk": "0.2.0", "@scure/base": "^2.0.0", "@scure/bip39": "^2.0.1", + "@swps/nockster-js": "^1.0.0", "react": "^19.2.0", "react-dom": "^19.2.0", "zustand": "^5.0.8" @@ -950,6 +951,14 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@swps/nockster-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@swps/nockster-js/-/nockster-js-1.0.0.tgz", + "integrity": "sha512-a4yA7SOaD14Zgr3lPFeIWmF46dcBJdZVOkB7lHEbzLUgx+G8eAuXUSG9WumnbCzPHkIEAQDMbs+2tCr6c6bOpw==", + "dependencies": { + "bs58": "^6.0.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", @@ -1353,6 +1362,12 @@ "postcss": "^8.1.0" } }, + "node_modules/base-x": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz", + "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==", + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.34", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz", @@ -1407,6 +1422,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs58": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", + "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", + "license": "MIT", + "dependencies": { + "base-x": "^5.0.0" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", diff --git a/package.json b/package.json index aa23a922..c83f5dfe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "iris", - "version": "1.2.4", + "version": "1.3.0", "description": "Iris - Chrome Wallet Extension for Nockchain", "type": "module", "scripts": { @@ -35,6 +35,7 @@ "@nockbox/iris-sdk": "0.2.0", "@scure/base": "^2.0.0", "@scure/bip39": "^2.0.1", + "@swps/nockster-js": "^1.0.0", "react": "^19.2.0", "react-dom": "^19.2.0", "zustand": "^5.0.8"