diff --git a/quasar.config.js b/quasar.config.js index 931f5fd4..8ce896a8 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/CreateInvoiceDialog.vue b/src/components/CreateInvoiceDialog.vue index 9f962455..7da95de8 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 4fc65175..f34f86f1 100644 --- a/src/components/HistoryTable.vue +++ b/src/components/HistoryTable.vue @@ -35,7 +35,17 @@
+
+ {{ isOnchainTransaction(transaction) ? "Confirming…" : "—" }} +
+
-
+
+ +
+
+
+
+ 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 037c743f..e057511a 100644 --- a/src/components/MeltQuoteInformation.vue +++ b/src/components/MeltQuoteInformation.vue @@ -72,6 +72,19 @@
+ + +
m.url === this.mintUrl); const methods = mint?.info?.nuts?.[5]?.methods || mint?.info?.nuts?.["5"]?.methods; - const method = methods?.find( - (m: any) => - 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() { @@ -557,6 +574,15 @@ export default defineComponent({ justify-content: flex-end; } +.view-on-explorer { + display: inline-flex; + align-items: center; + color: var(--q-primary); + text-decoration: underline; + font-size: 14px; + font-weight: 600; +} + .chain-status-fade-enter-active, .chain-status-fade-leave-active { transition: opacity 0.2s ease; diff --git a/src/components/MintProofOfReserves.vue b/src/components/MintProofOfReserves.vue new file mode 100644 index 00000000..f466d4de --- /dev/null +++ b/src/components/MintProofOfReserves.vue @@ -0,0 +1,515 @@ + + + + + diff --git a/src/components/MintQuoteInformation.vue b/src/components/MintQuoteInformation.vue index a4c736a4..c9a632d6 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 dde98bc7..bd43da74 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 + ) + }} +
+
+ +
@@ -1086,6 +1118,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 || @@ -1178,10 +1226,19 @@ export default defineComponent({ this.isPaying = true; try { const result = await this.meltInvoiceData(true); - const returnedChange = useProofsStore().sumProofs(result.change); - this.payInvoiceData.fee_paid = - this.payInvoiceData.meltQuote.response.fee_reserve - returnedChange; - console.log("### fee_paid", this.payInvoiceData.fee_paid); + // For on-chain melts the fee isn't known until the tx confirms and any + // change is reconciled (checkOutgoingOnchain) — computing it here from a + // still-pending quote would display the full fee_reserve and overstate the + // actual fee. Only set fee_paid eagerly for lightning (synchronous) melts. + const isOnchainMelt = Array.isArray( + this.payInvoiceData.meltQuote.response?.fee_options + ); + if (!isOnchainMelt) { + const returnedChange = useProofsStore().sumProofs(result.change); + this.payInvoiceData.fee_paid = + this.payInvoiceData.meltQuote.response.fee_reserve - returnedChange; + console.log("### fee_paid", this.payInvoiceData.fee_paid); + } // Success state and closing is handled by the watcher on payInvoiceData.show } catch (error) { // Error handling is done in the store, but we ensure isPaying is reset diff --git a/src/pages/MintDetailsPage.vue b/src/pages/MintDetailsPage.vue index 80e4a235..7fcef03f 100644 --- a/src/pages/MintDetailsPage.vue +++ b/src/pages/MintDetailsPage.vue @@ -256,6 +256,16 @@
+ +
+
+
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 { diff --git a/src/stores/ui.ts b/src/stores/ui.ts index e1e36f9c..0259457f 100644 --- a/src/stores/ui.ts +++ b/src/stores/ui.ts @@ -35,6 +35,7 @@ export const useUiStore = defineStore("ui", { tab: useLocalStorage("cashu.ui.tab", "history" as string), expandHistory: useLocalStorage("cashu.ui.expandHistory", true as boolean), globalMutexLock: false, + globalMutexLockedAt: 0, showDebugConsole: useLocalStorage("cashu.ui.showDebugConsole", false), lastBalanceCached: useLocalStorage("cashu.ui.lastBalanceCached", 0), multinutExperimentalWarningDismissed: useLocalStorage( @@ -53,9 +54,19 @@ export const useUiStore = defineStore("ui", { async lockMutex() { const nRetries = 10; const retryInterval = 500; + // A lock held longer than this is treated as stale — a previous holder that + // threw without unlocking — and stolen, so a stuck lock self-heals instead of + // popping "Please try again." on every subsequent operation until reload. + const maxHoldMs = 30000; let retries = 0; while (this.globalMutexLock) { + if ( + this.globalMutexLockedAt && + Date.now() - this.globalMutexLockedAt > 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 5dc37b9e..984366c5 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 {