From 6cd0f74b2c2341ad55a7b221283ad09ac5db9e19 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 11:05:32 +0000 Subject: [PATCH 1/3] feat: add public nock_estimateTransactionFee provider method Exposes the wallet's existing internal fee estimation (wallet:estimateTransactionFee -> vault.estimateTransactionFee, WASM auto-calc) as a public dApp-callable provider method, so dApps can show the required fee without duplicating transaction-building logic. - Register nock_estimateTransactionFee in PROVIDER_METHODS (defined locally until @nockbox/iris-sdk >= 0.3.0 is published) and isProviderMethod() - New background handler: gated on approved origin + unlocked vault (read-only, no approval popup, same gating as nock_getWalletInfo); validates params and returns { fee } in canonical nicks (string) --- extension/background/index.ts | 49 +++++++++++++++++++++++++++++++++++ extension/shared/constants.ts | 14 ++++++++-- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/extension/background/index.ts b/extension/background/index.ts index 636e3fb..8ddc1d1 100644 --- a/extension/background/index.ts +++ b/extension/background/index.ts @@ -310,6 +310,7 @@ function isProviderMethod(method: unknown): method is string { method === PROVIDER_METHODS.SEND_TRANSACTION || method === PROVIDER_METHODS.GET_WALLET_INFO || method === PROVIDER_METHODS.SIGN_TX || + method === PROVIDER_METHODS.ESTIMATE_TRANSACTION_FEE || // Legacy v0 API method method === 'nock_signRawTx' ); @@ -1043,6 +1044,54 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { } return; + case PROVIDER_METHODS.ESTIMATE_TRANSACTION_FEE: { + // Read-only like GET_WALLET_INFO: approved origin + unlocked vault, no approval popup + const estimateFeeOrigin = _sender.url || _sender.origin || ''; + if (!isOriginApproved(estimateFeeOrigin)) { + await sendBridgedResponse({ error: { code: 4100, message: 'Unauthorized origin' } }); + return; + } + + if (vault.isLocked()) { + await sendBridgedResponse({ error: ERROR_CODES.LOCKED }); + return; + } + + const estimateFeeParams = + payload.params && typeof payload.params === 'object' ? payload.params : {}; + const { to: estimateFeeTo, amount: estimateFeeAmount } = estimateFeeParams; + if (!isNockAddress(estimateFeeTo)) { + await sendBridgedResponse({ error: ERROR_CODES.BAD_ADDRESS }); + return; + } + let estimateFeeAmountNicks: Nicks; + try { + estimateFeeAmountNicks = parseNicksParam(estimateFeeAmount, 'amount'); + } catch (err) { + await sendBridgedResponse(toInvalidParamsError(err)); + return; + } + + try { + const estimateFeeResult = await vault.estimateTransactionFee( + estimateFeeTo, + estimateFeeAmountNicks + ); + if ('error' in estimateFeeResult) { + await sendBridgedResponse({ + error: { code: -32603, message: estimateFeeResult.error }, + }); + return; + } + // Vault returns a number; the public API uses canonical Nicks (string) + await sendBridgedResponse({ fee: String(estimateFeeResult.fee) as Nicks }); + } catch (err) { + console.error('[Background] Public fee estimation failed:', err); + await sendBridgedResponse(toInternalProviderError(err)); + } + return; + } + // Internal methods (called from popup) case INTERNAL_METHODS.SET_AUTO_LOCK: const newMinutes = payload.params?.[0]; diff --git a/extension/shared/constants.ts b/extension/shared/constants.ts index c344ca2..1ce26fc 100644 --- a/extension/shared/constants.ts +++ b/extension/shared/constants.ts @@ -5,8 +5,18 @@ */ // Import provider methods from SDK -import { PROVIDER_METHODS } from '@nockbox/iris-sdk'; -export { PROVIDER_METHODS }; +import { PROVIDER_METHODS as SDK_PROVIDER_METHODS } from '@nockbox/iris-sdk'; + +/** + * Provider methods: SDK methods plus methods added locally ahead of the next SDK release. + * TODO: drop the local ESTIMATE_TRANSACTION_FEE entry once @nockbox/iris-sdk >= 0.3.0 + * (which defines it) is published and the dependency is upgraded. + */ +export const PROVIDER_METHODS = { + ...SDK_PROVIDER_METHODS, + /** Estimate transaction fee for a dApp send (read-only, no approval popup) */ + ESTIMATE_TRANSACTION_FEE: 'nock_estimateTransactionFee', +} as const; /** * Internal Extension Methods - Called by popup UI and other extension components From 62ba71cf196cedad24b5e177afd57b2597dfde69 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 11:07:40 +0000 Subject: [PATCH 2/3] feat: make fee optional in nock_sendTransaction with wallet-side estimation Previously, omitting fee in nock_sendTransaction failed with 'Missing fee', forcing dApps to ask users for a fee or duplicate wallet/WASM transaction-building logic. Now, when a dApp omits fee: - The background estimates it via vault.estimateTransactionFee before showing the approval popup (estimation failure rejects the request up front with -32603, no dangling pending request) - The approval popup labels the fee as '(estimated)' - On approval, undefined is passed to vault.sendTransactionV2 so WASM auto-calculates the exact fee at build time, eliminating the 'Insufficient fee' late-failure for this path - The dApp response reports the actual fee used (walletTx.fee) Explicitly-provided fees keep the existing behavior verbatim: parsed with allowZero, respected as-is, and echoed back in the response. --- extension/background/index.ts | 45 ++++++++++++++++--- .../approvals/TransactionApprovalScreen.tsx | 4 +- extension/shared/types.ts | 7 ++- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/extension/background/index.ts b/extension/background/index.ts index 8ddc1d1..b6947ee 100644 --- a/extension/background/index.ts +++ b/extension/background/index.ts @@ -987,15 +987,41 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { return; } let amountNicks: Nicks; - let feeNicks: Nicks; + let feeNicks: Nicks | undefined; try { amountNicks = parseNicksParam(amount, 'amount'); - feeNicks = parseNicksParam(fee, 'fee', { allowZero: true }); + feeNicks = + fee === undefined || fee === null + ? undefined // omitted: estimate below, auto-calc exact fee at build time + : parseNicksParam(fee, 'fee', { allowZero: true }); } catch (err) { await sendBridgedResponse(toInvalidParamsError(err)); return; } + // Fee omitted: estimate it now so the approval popup can display it. + // Estimation failure rejects the request up front (better than a popup + // with no fee or a guaranteed-to-fail broadcast). + let displayFeeNicks: Nicks; + if (feeNicks === undefined) { + try { + const sendTxEstimate = await vault.estimateTransactionFee(to, amountNicks); + if ('error' in sendTxEstimate) { + await sendBridgedResponse({ + error: { code: -32603, message: `Fee estimation failed: ${sendTxEstimate.error}` }, + }); + return; + } + displayFeeNicks = String(sendTxEstimate.fee) as Nicks; + } catch (err) { + console.error('[Background] Fee estimation for sendTransaction failed:', err); + await sendBridgedResponse(toInternalProviderError(err)); + return; + } + } else { + displayFeeNicks = feeNicks; + } + // Create transaction approval request const txRequestId = crypto.randomUUID(); const txRequest: TransactionRequest = { @@ -1003,7 +1029,8 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { origin: sendTxOrigin, to, amount: amountNicks, - fee: feeNicks, + fee: displayFeeNicks, + feeEstimated: feeNicks === undefined, timestamp: Date.now(), }; @@ -1590,7 +1617,10 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { const v2Result = await vault.sendTransactionV2( txRequest.to, txRequest.amount, - txRequest.fee, + // Estimated fee: pass undefined so WASM auto-calculates the exact + // fee at build time (avoids "Insufficient fee" if the tx shape + // changed since estimation). Explicit dApp fees pass through verbatim. + txRequest.feeEstimated ? undefined : txRequest.fee, false, undefined, 'provider_send' @@ -1603,7 +1633,12 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { approveTxPending.sendResponse({ txid: v2Result.txId, amount: txRequest.amount, - fee: txRequest.fee, + // Report the actual fee used when the dApp omitted fee + // (walletTx.fee = constructedTx.feeUsed, set after broadcast; + // fall back to the estimate if unset) + fee: txRequest.feeEstimated + ? (String(v2Result.walletTx.fee ?? txRequest.fee) as Nicks) + : txRequest.fee, }); cancelPendingRequest(approveTxId); processNextRequest(); diff --git a/extension/popup/screens/approvals/TransactionApprovalScreen.tsx b/extension/popup/screens/approvals/TransactionApprovalScreen.tsx index 753211e..59eeb92 100644 --- a/extension/popup/screens/approvals/TransactionApprovalScreen.tsx +++ b/extension/popup/screens/approvals/TransactionApprovalScreen.tsx @@ -18,6 +18,7 @@ export function TransactionApprovalScreen() { const { id, origin, to, amount } = pendingTransactionRequest; const fee = pendingTransactionRequest.fee; + const isFeeEstimated = Boolean(pendingTransactionRequest.feeEstimated); const amountNum = Number(amount); const feeNum = Number(fee); const totalNum = amountNum + feeNum; @@ -120,10 +121,11 @@ export function TransactionApprovalScreen() { {/* Fee & Total */}
- Network fee + Network fee{isFeeEstimated ? ' (estimated)' : ''}
{formatNock(feeNum / NOCK_TO_NICKS)} NOCK
+ {isFeeEstimated ? '~' : ''} {formatNick(feeNum)} nicks
diff --git a/extension/shared/types.ts b/extension/shared/types.ts index 683ffee..643ec5f 100644 --- a/extension/shared/types.ts +++ b/extension/shared/types.ts @@ -160,8 +160,13 @@ export interface TransactionRequest { to: string; /** Amount in nicks (WASM Nicks = string) */ amount: Nicks; - /** Transaction fee in nicks */ + /** Transaction fee in nicks (explicit from dApp, or wallet-estimated when omitted) */ fee: Nicks; + /** + * True when the dApp omitted `fee` and `fee` holds a wallet-side estimate; + * on approval the exact fee is auto-calculated by WASM at build time. + */ + feeEstimated?: boolean; /** Request timestamp */ timestamp: number; } From 5ad67026ea7ba7c9a8748c0d2385a1a68c79618a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 11:32:23 +0000 Subject: [PATCH 3/3] fix: size omitted-fee provider sends to the displayed estimate For omitted-fee nock_sendTransaction, approval now passes the wallet's fresh estimate as the fee (matching the popup send flow) instead of undefined. The undefined branch in sendTransactionV2 reserves a 2-NOCK placeholder for note selection, so an account that could afford amount + estimate but not amount + 2 NOCK failed at approval with 'Insufficient available funds'. Passing the concrete estimate sizes note selection to the fee actually shown and charged. Also drops the '~' prefix on the popup fee since the displayed value is now applied verbatim rather than approximated. Addresses review feedback (P2) on PR #52. --- extension/background/index.ts | 17 +++++++---------- .../approvals/TransactionApprovalScreen.tsx | 1 - 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/extension/background/index.ts b/extension/background/index.ts index b6947ee..db6b355 100644 --- a/extension/background/index.ts +++ b/extension/background/index.ts @@ -1617,10 +1617,11 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { const v2Result = await vault.sendTransactionV2( txRequest.to, txRequest.amount, - // Estimated fee: pass undefined so WASM auto-calculates the exact - // fee at build time (avoids "Insufficient fee" if the tx shape - // changed since estimation). Explicit dApp fees pass through verbatim. - txRequest.feeEstimated ? undefined : txRequest.fee, + // Always a concrete fee: the dApp's explicit fee, or the wallet's + // fresh estimate (matches the popup send flow). This sizes note + // selection to the displayed fee rather than the 2-NOCK placeholder + // the undefined-fee branch would use. + txRequest.fee, false, undefined, 'provider_send' @@ -1633,12 +1634,8 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { approveTxPending.sendResponse({ txid: v2Result.txId, amount: txRequest.amount, - // Report the actual fee used when the dApp omitted fee - // (walletTx.fee = constructedTx.feeUsed, set after broadcast; - // fall back to the estimate if unset) - fee: txRequest.feeEstimated - ? (String(v2Result.walletTx.fee ?? txRequest.fee) as Nicks) - : txRequest.fee, + // Fee charged equals txRequest.fee (applied verbatim as fee override) + fee: txRequest.fee, }); cancelPendingRequest(approveTxId); processNextRequest(); diff --git a/extension/popup/screens/approvals/TransactionApprovalScreen.tsx b/extension/popup/screens/approvals/TransactionApprovalScreen.tsx index 59eeb92..a48cd63 100644 --- a/extension/popup/screens/approvals/TransactionApprovalScreen.tsx +++ b/extension/popup/screens/approvals/TransactionApprovalScreen.tsx @@ -125,7 +125,6 @@ export function TransactionApprovalScreen() {
{formatNock(feeNum / NOCK_TO_NICKS)} NOCK
- {isFeeEstimated ? '~' : ''} {formatNick(feeNum)} nicks