From 88b2c295778035f80250fe8c9a849e2354664b65 Mon Sep 17 00:00:00 2001 From: Nordic Operator Date: Sun, 7 Jun 2026 01:39:51 +0200 Subject: [PATCH 1/5] wallet: Max (deduct-fee) button on on-chain melt Signed-off-by: Matthew Vuk --- quasar.config.js | 2 +- src/components/InvoiceDetailDialog.vue | 141 +++++++++++++++++++++++- src/components/MeltQuoteInformation.vue | 22 ++++ src/components/PayInvoiceDialog.vue | 32 +++++- 4 files changed, 193 insertions(+), 4 deletions(-) diff --git a/quasar.config.js b/quasar.config.js index 931f5fd4b..8ce896a8c 100644 --- a/quasar.config.js +++ b/quasar.config.js @@ -103,7 +103,7 @@ module.exports = configure(function (/* ctx */) { // Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#devServer devServer: { - https: true, + https: false, // http so the local http page can reach the local http Hyperborea Mint (no mixed-content) open: true, // opens browser window automatically port: 8080, }, diff --git a/src/components/InvoiceDetailDialog.vue b/src/components/InvoiceDetailDialog.vue index 6752ac183..5e7c45a93 100644 --- a/src/components/InvoiceDetailDialog.vue +++ b/src/components/InvoiceDetailDialog.vue @@ -42,7 +42,10 @@
-
+
+ +
+
+
+
+ Detected incoming payjoin tx + +
+ + view on block explorer + + +
+ {{ pjStatusText }} +
+
+
+
+ + +
+
+
+ + + +
+ Ecash received +
+
+ boarded into Ark via payjoin +
+
+
+
+
= t) + return `Confirmed ${c}/${t} — boarding into Ark, issuing your ecash…`; + if (c >= 1) + return `Confirming ${c}/${t} block${t === 1 ? "" : "s"}…`; + return "Seen in mempool — waiting for the first confirmation…"; + }, }, watch: { showInvoiceDetails(val: boolean) { if (val) { this.metadataRefreshTrigger += 1; + this.pjMeta = null; + this.startPjPoll(); + } else { + this.stopPjPoll(); } }, + "invoiceData.status"(val: string) { + if (val === "paid") this.stopPjPoll(); + }, }, methods: { + startPjPoll() { + this.stopPjPoll(); + if (!this.isPayjoin) return; + this.pollPjOnce(); + this.pjPollTimer = setInterval(() => this.pollPjOnce(), 4000); + }, + stopPjPoll() { + if (this.pjPollTimer) { + clearInterval(this.pjPollTimer); + this.pjPollTimer = null; + } + }, + async pollPjOnce() { + if (!this.isPayjoin || this.invoiceData.status === "paid") { + this.stopPjPoll(); + return; + } + try { + const meta = await fetchAddressTxMetadata(this.pjAddress, 1); + if (meta) this.pjMeta = meta; + } catch (e) { + // transient mempool fetch error — keep polling + } + }, onCopyBolt11: async function () { const request = this.invoiceData?.request; if (request) { @@ -264,6 +393,7 @@ export default defineComponent({ }, }, beforeUnmount() { + this.stopPjPoll(); if (this.copyButtonTimeout) { clearTimeout(this.copyButtonTimeout); } @@ -338,4 +468,11 @@ export default defineComponent({ .checkmark-icon { font-size: clamp(100px, 35vw, 200px); } + +/* payjoin-board on-ramp progress panel */ +.payjoin-progress { + min-height: 320px; + padding: 24px; + text-align: center; +} diff --git a/src/components/MeltQuoteInformation.vue b/src/components/MeltQuoteInformation.vue index 037c743fa..32de9a68e 100644 --- a/src/components/MeltQuoteInformation.vue +++ b/src/components/MeltQuoteInformation.vue @@ -72,6 +72,19 @@
+ + +
+
+ +
@@ -1086,6 +1100,22 @@ export default defineComponent({ lightningMaxAmountFromBalance: function (): number { return this.activeBalance / this.activeUnitCurrencyMultiplyer; }, + // Max amount the user can actually send for the active payment method, in display units. + // For on-chain melts we reserve the offboard fee so a full-balance send doesn't fail + // "funds too low" — prefer the live quote's fee_reserve, else a conservative buffer (cashu + // refunds any unused fee as change). Lightning/bolt12 keep the full balance. + maxSpendableForActivePayment: function (): number { + const mult = this.activeUnitCurrencyMultiplyer || 1; + if (this.isOnchainPay) { + const opts: any[] = this.onchainFeeOptions || []; + const feeReserve = + opts.length && opts[0] && opts[0].fee_reserve + ? opts[0].fee_reserve + : Math.max(1500, Math.ceil(this.activeBalance * 0.005)); + return Math.max(0, this.activeBalance - feeReserve) / mult; + } + return this.activeBalance / mult; + }, insufficientFunds: function (): boolean { if ( !this.payInvoiceData.lnurlpay || From d32336f90238d088fc7bde5ca3e23e9cb85af837 Mon Sep 17 00:00:00 2001 From: Nordic Operator Date: Sun, 7 Jun 2026 01:47:22 +0200 Subject: [PATCH 2/5] wallet: Proof-of-Reserves card in mint details (attested reserve, freshness, verify-independently) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NOTE: integration fixes pending — switch freshness tip from Mutinynet to mainnet esplora; replace placeholder public verifier repo URL with local-only verify instructions. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Matthew Vuk --- src/components/MintProofOfReserves.vue | 512 +++++++++++++++++++++++++ src/pages/MintDetailsPage.vue | 12 + 2 files changed, 524 insertions(+) create mode 100644 src/components/MintProofOfReserves.vue diff --git a/src/components/MintProofOfReserves.vue b/src/components/MintProofOfReserves.vue new file mode 100644 index 000000000..7b88752f6 --- /dev/null +++ b/src/components/MintProofOfReserves.vue @@ -0,0 +1,512 @@ + + + + + diff --git a/src/pages/MintDetailsPage.vue b/src/pages/MintDetailsPage.vue index 80e4a235d..9a4b436ae 100644 --- a/src/pages/MintDetailsPage.vue +++ b/src/pages/MintDetailsPage.vue @@ -270,6 +270,16 @@ @close="() => {}" /> + +
+
+
PROOF OF RESERVES
+
+
+ + + +
@@ -336,6 +346,7 @@ import EditMintDialog from "src/components/EditMintDialog.vue"; import RemoveMintDialog from "src/components/RemoveMintDialog.vue"; import MintMotdMessage from "src/components/MintMotdMessage.vue"; import MintAuditInfo from "src/components/MintAuditInfo.vue"; +import MintProofOfReserves from "src/components/MintProofOfReserves.vue"; import { QrCode as QrCodeIcon, Link as LinkIcon, @@ -370,6 +381,7 @@ export default defineComponent({ RemoveMintDialog, MintMotdMessage, MintAuditInfo, + MintProofOfReserves, }, data: function () { return { From c38bbeae280d74db2242ea2bf0ccaca0a8aea5c6 Mon Sep 17 00:00:00 2001 From: Nordic Operator Date: Sun, 7 Jun 2026 01:50:30 +0200 Subject: [PATCH 3/5] wallet PoR card: mainnet freshness tip (mempool.space) + local-only verify (no public repo link) Signed-off-by: Matthew Vuk --- src/components/MintProofOfReserves.vue | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/MintProofOfReserves.vue b/src/components/MintProofOfReserves.vue index 7b88752f6..9914d60a0 100644 --- a/src/components/MintProofOfReserves.vue +++ b/src/components/MintProofOfReserves.vue @@ -151,12 +151,10 @@ lower bound on assets attested as of block {{ bundle.as_of_block.height }}.

-

- Get the verifier: - - {{ verifierRepoUrl }} - - +

+ The independent verifier (bark-audit) is distributed separately — + you run it yourself. This wallet never asks the mint to verify + its own reserves.

@@ -231,7 +229,7 @@ export default defineComponent({ error: null as string | null, bundle: null as AttestationBundle | null, currentHeight: null as number | null, - verifierRepoUrl: "https://github.com/second-tech/bark-audit", + verifierRepoUrl: "", }; }, computed: { @@ -368,10 +366,12 @@ export default defineComponent({ } }, async loadChainTip() { - // Mutinynet signet tip; best-effort and never blocks rendering. + // Bitcoin MAINNET tip from an independent explorer (not the mint's own esplora), + // so freshness is judged against the real chain the attestation block lives on. + // Best-effort and never blocks rendering. try { const res = await fetch( - "https://mutinynet.com/api/blocks/tip/height", + "https://mempool.space/api/blocks/tip/height", { cache: "no-store" } ); if (!res.ok) return; From fbd9a96e37e07271beeb3dddabacc117128bb61d Mon Sep 17 00:00:00 2001 From: Nordic Operator Date: Sun, 7 Jun 2026 08:48:39 +0200 Subject: [PATCH 4/5] MintDetails: move Proof of Reserves above Audit Info Signed-off-by: Matthew Vuk --- src/pages/MintDetailsPage.vue | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/pages/MintDetailsPage.vue b/src/pages/MintDetailsPage.vue index 9a4b436ae..7fcef03f3 100644 --- a/src/pages/MintDetailsPage.vue +++ b/src/pages/MintDetailsPage.vue @@ -256,6 +256,16 @@
+ +
+
+
PROOF OF RESERVES
+
+
+ + + +
@@ -270,16 +280,6 @@ @close="() => {}" /> - -
-
-
PROOF OF RESERVES
-
-
- - - -
From eed8a3096b73b2020b31c0a1d13e5748b98b87ff Mon Sep 17 00:00:00 2001 From: Matthew Vuk Date: Thu, 18 Jun 2026 14:36:40 +0300 Subject: [PATCH 5/5] wallet(onchain): concurrency + UI robustness for on-chain boards and melts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - checkOnchainAndMint: poll the quote WITHOUT the global mutex (the background checker polls every pending on-chain quote; contending on the single global lock made operations time out as "Please try again."). Take the lock only for the actual mint, and re-check under it to avoid double-minting. - ui store: steal a stale global mutex lock after 30s so a stuck lock self-heals. - confirmations: default 6 for mint (board maturation), keep 1 for melt (mint's own tx settles fast); prefer exact-unit method, fall back to any onchain method. - PoR card: render "unknown" instead of a confident "0 sat"/self-reported freshness when reserve total or an independent chain tip is missing. - history: show "Confirming…" for amountless pending receives instead of "+0". - pay dialog: surface the fee up-front; don't eagerly compute fee_paid for async on-chain melts (would overstate the fee from a still-pending quote). - create-invoice: drop the broken on-chain "reusable address" path (Create Address always mints a new one, so it showed two QRs for one deposit). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Matthew Vuk --- src/components/CreateInvoiceDialog.vue | 7 +++- src/components/HistoryTable.vue | 10 ++++++ src/components/MeltQuoteInformation.vue | 12 ++++--- src/components/MintProofOfReserves.vue | 19 +++++----- src/components/MintQuoteInformation.vue | 13 ++++--- src/components/PayInvoiceDialog.vue | 35 +++++++++++++++--- src/stores/ui.ts | 13 +++++++ src/stores/walletOnchain.ts | 47 +++++++++++++++++++------ 8 files changed, 124 insertions(+), 32 deletions(-) diff --git a/src/components/CreateInvoiceDialog.vue b/src/components/CreateInvoiceDialog.vue index 9f962455d..7da95de84 100644 --- a/src/components/CreateInvoiceDialog.vue +++ b/src/components/CreateInvoiceDialog.vue @@ -381,8 +381,13 @@ export default defineComponent({ return `lightning:${request.toUpperCase()}`; }, showReusableQuote(): boolean { + // On-chain previously showed a "reusable" prior address here, but "Create + // Address" always mints a NEW one — so the user saw two different QR codes for + // a single deposit. The reuse display was never wired into the create button, + // so drop it for on-chain: go straight to the single created-address page + // (the proper deposit page with the copy button). Bolt12 offers still reuse. return ( - (this.isBolt12 || this.isOnchain) && + this.isBolt12 && !this.showAmountInput && this.reusableReceiveQuote !== null ); diff --git a/src/components/HistoryTable.vue b/src/components/HistoryTable.vue index 4fc651753..f34f86f18 100644 --- a/src/components/HistoryTable.vue +++ b/src/components/HistoryTable.vue @@ -35,7 +35,17 @@
+
+ {{ isOnchainTransaction(transaction) ? "Confirming…" : "—" }} +
+
- m.method === LightningMethod.Onchain && m.unit === this.quoteUnit - ); + // Prefer the exact unit match; fall back to any on-chain method so a unit + // mismatch can't silently collapse the denominator. Melt is the mint's own + // outgoing tx (settles at 1 conf), so 1 is the correct fallback here. + const method = + methods?.find( + (m: any) => + m.method === LightningMethod.Onchain && m.unit === this.quoteUnit + ) || methods?.find((m: any) => m.method === LightningMethod.Onchain); return Number(method?.options?.confirmations || 1); }, async loadOnchainMetadata() { diff --git a/src/components/MintProofOfReserves.vue b/src/components/MintProofOfReserves.vue index 9914d60a0..f466d4de1 100644 --- a/src/components/MintProofOfReserves.vue +++ b/src/components/MintProofOfReserves.vue @@ -245,17 +245,20 @@ export default defineComponent({ : 0; }, totalDisplay(): string { - const total = this.bundle?.total_reserve_sat ?? 0; + // A real attestation always has a positive, checked reserve sum; treat a + // missing / zero / non-finite value as suspect ("unknown") rather than + // rendering a confident "0 sat" lower bound. + const total = this.bundle?.total_reserve_sat; + if (typeof total !== "number" || !Number.isFinite(total) || total <= 0) { + return "unknown"; + } return `${this.formatNumber(total)} sat`; }, - // Reference height: chain tip if known, else highest anchor height. + // Reference height for freshness: ONLY an independent chain tip. Falling back + // to the bundle's own anchor / as_of heights would let a stale bundle + // self-report as fresh, so without an independent tip freshness is unknown. referenceHeight(): number | null { - if (typeof this.currentHeight === "number") return this.currentHeight; - const anchors = (this.bundle?.reserve ?? []) - .map((r) => r.anchor?.block_height) - .filter((h): h is number => typeof h === "number"); - if (anchors.length === 0) return null; - return Math.max(...anchors, this.bundle?.as_of_block?.height ?? 0); + return typeof this.currentHeight === "number" ? this.currentHeight : null; }, blocksAgo(): number | null { const ref = this.referenceHeight; diff --git a/src/components/MintQuoteInformation.vue b/src/components/MintQuoteInformation.vue index a4c736a49..c9a632d6e 100644 --- a/src/components/MintQuoteInformation.vue +++ b/src/components/MintQuoteInformation.vue @@ -274,10 +274,15 @@ export default defineComponent({ const mint = mintStore.mints.find((m: any) => m.url === this.mintUrl); const methods = mint?.info?.nuts?.[4]?.methods || mint?.info?.nuts?.["4"]?.methods; - const method = methods?.find( - (m: any) => m.method === LightningMethod.Onchain && m.unit === this.unit - ); - return Number(method?.options?.confirmations || 1); + // Prefer the exact unit match; fall back to any on-chain method so a unit + // mismatch can't silently collapse the denominator. Default 6 (real board + // maturation depth), never 1 — boards credit only once the VTXO is Spendable. + const method = + methods?.find( + (m: any) => + m.method === LightningMethod.Onchain && m.unit === this.unit + ) || methods?.find((m: any) => m.method === LightningMethod.Onchain); + return Number(method?.options?.confirmations || 6); }, async loadOnchainMetadata() { if (!this.isOnchain || !this.invoice?.request) return; diff --git a/src/components/PayInvoiceDialog.vue b/src/components/PayInvoiceDialog.vue index 2334f3dc8..bd43da747 100644 --- a/src/components/PayInvoiceDialog.vue +++ b/src/components/PayInvoiceDialog.vue @@ -141,6 +141,24 @@
+ +
+ {{ $t("PayInvoiceDialog.invoice.fee") }}: + {{ + formatCurrency( + payInvoiceData.meltQuote.response.fee_reserve, + activeUnit, + true + ) + }} +
maxHoldMs + ) { + break; // steal the stale lock + } if (retries >= nRetries) { notify("Please try again."); throw new Error("Failed to acquire global mutex lock"); @@ -65,9 +76,11 @@ export const useUiStore = defineStore("ui", { } this.globalMutexLock = true; + this.globalMutexLockedAt = Date.now(); }, unlockMutex() { this.globalMutexLock = false; + this.globalMutexLockedAt = 0; }, triggerActivityOrb() { this.activityOrb = true; diff --git a/src/stores/walletOnchain.ts b/src/stores/walletOnchain.ts index 5dc37b9ed..984366c53 100644 --- a/src/stores/walletOnchain.ts +++ b/src/stores/walletOnchain.ts @@ -12,7 +12,7 @@ import { } from "@cashu/cashu-ts"; import * as nobleSecp256k1 from "@noble/secp256k1"; import { bytesToHex } from "@noble/hashes/utils"; -import { notifyApiError, notify, notifySuccess } from "src/js/notify"; +import { notifyApiError, notify, notifySuccess, notifyWarning } from "src/js/notify"; import type { InvoiceHistory } from "./wallet"; import { LightningMethod } from "src/stores/walletTypes"; import { mintOnPaidGeneric } from "./walletWebsocket"; @@ -171,9 +171,12 @@ export async function checkOnchainAndMint( invoice.network = onchainNetwork(invoice.request); } - await uIStore.lockMutex(); + // Check the quote WITHOUT holding the global mutex. The background checker polls + // every pending on-chain quote (including abandoned/unpaid ones); if each poll + // grabbed the one global lock they would contend and time out as "Please try + // again." The lock is only needed for the actual mint (proof mutation), below. + uIStore.triggerActivityOrb(); try { - uIStore.triggerActivityOrb(); const updated = await mintWallet.checkMintQuoteOnchain(quoteId); const paid = amountToNumber(updated.amount_paid); const issued = amountToNumber(updated.amount_issued); @@ -187,10 +190,38 @@ export async function checkOnchainAndMint( if (delta <= 0) { throw new Error("Address not paid"); } + } catch (error: any) { + if (verbose) { + if (error?.message === "Address not paid") { + notify("Address not paid"); + } else { + notifyApiError(error); + } + } + throw error; + } + + // Paid — mint under the global mutex (serializes proof mutation). + await uIStore.lockMutex(); + try { + // Re-check under the lock: a concurrent poll or websocket event may have + // already minted this quote while we waited, so we never mint it twice. + const recheck = await mintWallet.checkMintQuoteOnchain(quoteId); + const delta = + amountToNumber(recheck.amount_paid) - + amountToNumber(recheck.amount_issued); + if (delta <= 0) { + this.setInvoicePaid(invoice.quote, { + amount: amountToNumber(recheck.amount_issued), + mintQuote: normalizeMintQuote(recheck), + }); + useInvoicesWorkerStore().removeOnchainQuoteFromChecker(invoice.quote); + return []; + } const proofs = await this.retryOnceOnSignedOutputs(keysetId, async () => mintWallet.ops - .mintOnchain(delta, updated) + .mintOnchain(delta, recheck) .keyset(keysetId) .asDeterministic() .proofsWeHave(mintStore.mintUnitProofs(mint, invoice.unit)) @@ -220,13 +251,7 @@ export async function checkOnchainAndMint( return proofs; } catch (error: any) { console.error(error); - if (verbose) { - if (error?.message === "Address not paid") { - notify("Address not paid"); - } else { - notifyApiError(error); - } - } + if (verbose) notifyApiError(error); this.handleOutputsHaveAlreadyBeenSignedError(keysetId, error); throw error; } finally {