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…" : "—" }}
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+ 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 @@
+
+
+
+
+
+ Loading reserve attestation...
+
+
+
+
+
+ No reserve attestation published yet.
+
+
+ This mint has not published a proof-of-reserves bundle. The attested
+ reserve is a lower bound on assets; absence of a bundle is not evidence
+ of insolvency.
+
+
+
+
+
+
+ Could not load reserve attestation.
+
+ {{ error }}
+
+
+
+
+
+
+
Attested reserve
+
{{ totalDisplay }}
+
lower bound on assets
+
+
+
+
+
+ EXPIRED
+
+ attestation is {{ blocksAgoDisplay }} old (older than
+ {{ expiryBlocks }} blocks)
+
+
+
+
+
+
+
+ {{ formatNumber(bundle.as_of_block.height) }}
+
+
+
+
+
+
+ {{ freshnessDisplay }}
+
+
+
+
+
+
+
{{ formatNumber(reserveCount) }}
+
+
+
+
+
+
+
+
+
+ {{ shortHex(bundle.mint_identity_pubkey) }}
+
+
+
+
+
+
+
+
+
+
+ Do not trust this card. Verification is a
+ wallet-controlled action: download
+ the bundle and check it yourself with the open-source
+ bark-audit verifier. This wallet
+ never asks the mint to verify its own reserves.
+
+
1. Download the bundle (button above).
+
2. Run the verifier:
+
{{ verifyCommand }}
+
+ It checks the signature against the mint identity key, confirms
+ every reserve VTXO anchors to a confirmed on-chain output, and
+ re-sums the total. The total is a
+ lower bound on assets attested as of
+ block {{ bundle.as_of_block.height }}.
+
+
+ The independent verifier (bark-audit) is distributed separately —
+ you run it yourself. This wallet never asks the mint to verify
+ its own reserves.
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
@@ -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 {