feat(wallet): ecash pay confirmation screen + auto-refund on failed sale (#3)
- PeerFiles: new confirmation step after "pay from ecash" — shows the amount and
which wallet will be spent (Cashu/Fedimint) with balances, lets the user switch
backends, and a styled Confirm button. The chosen backend is passed to the
payment so it spends exactly what was confirmed.
- content.download-peer-paid: accept `method` (cashu|fedimint) to honor the
confirmed choice; log the backend + outcome; backend-specific rejection errors
("not in the same Fedimint federation" / "doesn't accept your Cashu mint").
- AUTO-REFUND: a minted token whose sale fails (peer unreachable, rejected, or
error) is now reclaimed (fedimint reissue / cashu receive) so the buyer no
longer loses the spent ecash — fixes the stuck-Fedimint-notes report.
- wallet.ecash-balance already reports cashu_sats/fedimint_sats/total_sats which
the confirm screen uses to pick/show the covering wallet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
242baf5deb
commit
12f54e390d
@ -19,6 +19,29 @@ fn is_valid_v3_onion(addr: &str) -> bool {
|
|||||||
|
|
||||||
const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-catalog/v1";
|
const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-catalog/v1";
|
||||||
|
|
||||||
|
/// Best-effort reclaim of an ecash payment token that was minted but the sale
|
||||||
|
/// didn't complete (seller unreachable or couldn't redeem it), so the buyer
|
||||||
|
/// doesn't lose the value. For Fedimint the spender can reissue its own
|
||||||
|
/// un-redeemed notes; for Cashu the proofs are received back. Fails silently if
|
||||||
|
/// the seller already claimed the token (then the value is genuinely gone).
|
||||||
|
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) {
|
||||||
|
let res = match backend {
|
||||||
|
"fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token)
|
||||||
|
.await
|
||||||
|
.map(|(sats, _fed)| sats),
|
||||||
|
_ => ecash::receive_token(data_dir, token).await,
|
||||||
|
};
|
||||||
|
match res {
|
||||||
|
Ok(sats) => tracing::info!(
|
||||||
|
"paid download: reclaimed {sats} sats of unspent {backend} ecash after a failed sale"
|
||||||
|
),
|
||||||
|
Err(e) => tracing::warn!(
|
||||||
|
"paid download: could not reclaim {backend} ecash (the peer may have already \
|
||||||
|
claimed it): {e:#}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl RpcHandler {
|
impl RpcHandler {
|
||||||
/// List content I'm sharing.
|
/// List content I'm sharing.
|
||||||
pub(super) async fn handle_content_list_mine(&self) -> Result<serde_json::Value> {
|
pub(super) async fn handle_content_list_mine(&self) -> Result<serde_json::Value> {
|
||||||
@ -383,35 +406,58 @@ impl RpcHandler {
|
|||||||
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
return Err(anyhow::anyhow!("Invalid v3 onion address"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mint an ecash payment token, trying BOTH backends: Cashu first, then
|
// `method` pins the backend the user confirmed in the UI ("cashu" |
|
||||||
// Fedimint. The seller's verify_payment_token accepts either, so a node
|
// "fedimint"); absent = auto (Cashu first, then Fedimint). The seller's
|
||||||
// whose balance lives in one system can still pay (#3). Surface the
|
// verify_payment_token accepts either, so a node whose balance lives in
|
||||||
// Cashu error only if BOTH paths fail.
|
// one system can still pay (#3).
|
||||||
let token_str = match ecash::send_token(&self.config.data_dir, price_sats).await {
|
let method = params.get("method").and_then(|v| v.as_str());
|
||||||
Ok(t) => t,
|
|
||||||
Err(cashu_err) => {
|
let mint_cashu = || ecash::send_token(&self.config.data_dir, price_sats);
|
||||||
match crate::wallet::fedimint_client::spend_from_any(
|
let mint_fedimint =
|
||||||
&self.config.data_dir,
|
|| crate::wallet::fedimint_client::spend_from_any(&self.config.data_dir, price_sats);
|
||||||
price_sats,
|
|
||||||
)
|
let (token_str, used_backend) = match method {
|
||||||
.await
|
Some("cashu") => match mint_cashu().await {
|
||||||
{
|
Ok(t) => (t, "cashu"),
|
||||||
Ok((notes, _fed_id)) => notes,
|
Err(e) => {
|
||||||
|
tracing::warn!("paid download: cashu mint failed for {price_sats} sats: {e:#}");
|
||||||
|
return Ok(serde_json::json!({ "error": format!(
|
||||||
|
"Couldn't pay {price_sats} sats from your Cashu wallet: {e}. \
|
||||||
|
Fund it, or choose Fedimint."
|
||||||
|
) }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Some("fedimint") => match mint_fedimint().await {
|
||||||
|
Ok((notes, fed)) => {
|
||||||
|
tracing::info!("paid download: spending {price_sats} sats Fedimint notes from {fed}");
|
||||||
|
(notes, "fedimint")
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("paid download: fedimint spend failed for {price_sats} sats: {e:#}");
|
||||||
|
return Ok(serde_json::json!({ "error": format!(
|
||||||
|
"Couldn't pay {price_sats} sats from your Fedimint wallet: {e}. \
|
||||||
|
Fund it, or choose Cashu."
|
||||||
|
) }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => match mint_cashu().await {
|
||||||
|
Ok(t) => (t, "cashu"),
|
||||||
|
Err(cashu_err) => match mint_fedimint().await {
|
||||||
|
Ok((notes, _fed)) => (notes, "fedimint"),
|
||||||
Err(fedi_err) => {
|
Err(fedi_err) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"paid download: no ecash backend could pay {price_sats} sats \
|
"paid download: no ecash backend could pay {price_sats} sats \
|
||||||
(cashu: {cashu_err:#}; fedimint: {fedi_err:#})"
|
(cashu: {cashu_err:#}; fedimint: {fedi_err:#})"
|
||||||
);
|
);
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({ "error": format!(
|
||||||
"error": format!(
|
|
||||||
"Couldn't pay {price_sats} sats from your ecash wallet \
|
"Couldn't pay {price_sats} sats from your ecash wallet \
|
||||||
(Cashu or Fedimint). Fund either wallet and try again."
|
(Cashu or Fedimint). Fund either wallet and try again."
|
||||||
)
|
) }));
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
tracing::info!("paid download: paying {price_sats} sats to {onion} via {used_backend} ecash");
|
||||||
|
|
||||||
let (data, _) = self.state_manager.get_snapshot().await;
|
let (data, _) = self.state_manager.get_snapshot().await;
|
||||||
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||||
@ -428,7 +474,7 @@ impl RpcHandler {
|
|||||||
)
|
)
|
||||||
.service(crate::settings::transport::PeerService::PeerFiles)
|
.service(crate::settings::transport::PeerService::PeerFiles)
|
||||||
.header("X-Federation-DID", local_did)
|
.header("X-Federation-DID", local_did)
|
||||||
.header("X-Payment-Token", token_str)
|
.header("X-Payment-Token", token_str.clone())
|
||||||
.timeout(std::time::Duration::from_secs(900))
|
.timeout(std::time::Duration::from_secs(900))
|
||||||
.send_get()
|
.send_get()
|
||||||
.await
|
.await
|
||||||
@ -436,8 +482,11 @@ impl RpcHandler {
|
|||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
|
||||||
|
// The token was already minted/spent — reclaim it so the buyer
|
||||||
|
// doesn't lose the value when the seller was simply unreachable.
|
||||||
|
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"error": "Could not reach the peer over mesh or Tor — it may be offline. Please try again."
|
"error": "Could not reach the peer over mesh or Tor — it may be offline. Your ecash was refunded to your wallet. Please try again."
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -451,15 +500,35 @@ impl RpcHandler {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
|
||||||
// Payment was rejected — token is spent but content not received
|
// Payment was rejected by the seller. Surface the most likely cause
|
||||||
|
// per backend — for ecash both sides must share a redemption network
|
||||||
|
// (a Cashu mint, or a Fedimint federation).
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
tracing::warn!(
|
||||||
|
"paid download: seller {onion} rejected {used_backend} payment of {price_sats} sats: {body}"
|
||||||
|
);
|
||||||
|
// Seller couldn't redeem the token — reclaim it so the buyer keeps
|
||||||
|
// their funds (the spent-but-unredeemed-notes case the user hit).
|
||||||
|
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||||
|
let hint = match used_backend {
|
||||||
|
"fedimint" => "the seller isn't in the same Fedimint federation as you",
|
||||||
|
_ => "the seller doesn't accept your Cashu mint",
|
||||||
|
};
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"error": "Payment rejected by peer — the token may have been insufficient or invalid."
|
"error": format!(
|
||||||
|
"Payment rejected by the seller — {hint}. Your ecash was refunded to \
|
||||||
|
your wallet. Try the other ecash type, or use a shared mint/federation."
|
||||||
|
)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
|
||||||
|
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"error": format!("Peer returned an error ({}).", response.status())
|
"error": format!("Peer returned an error ({status}). Your ecash was refunded to your wallet.")
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -471,10 +540,12 @@ impl RpcHandler {
|
|||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||||
|
|
||||||
|
tracing::info!("paid download: received {} bytes from {onion} (paid {price_sats} sats via {used_backend})", bytes.len());
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"data": encoded,
|
"data": encoded,
|
||||||
"size": bytes.len(),
|
"size": bytes.len(),
|
||||||
"paid_sats": price_sats,
|
"paid_sats": price_sats,
|
||||||
|
"ecash_backend": used_backend,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -287,15 +287,15 @@
|
|||||||
<div v-if="payMode === 'choose'" class="space-y-3">
|
<div v-if="payMode === 'choose'" class="space-y-3">
|
||||||
<button
|
<button
|
||||||
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
||||||
:disabled="downloading === payItem.id"
|
:disabled="ecashPreparing || downloading === payItem.id"
|
||||||
@click="payWithEcash"
|
@click="prepareEcashPay"
|
||||||
>
|
>
|
||||||
<svg class="w-6 h-6 text-green-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-green-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>
|
<span>
|
||||||
<span class="block text-base text-white">{{ downloading === payItem.id ? 'Paying…' : 'Pay from this node’s ecash wallet' }}</span>
|
<span class="block text-base text-white">{{ ecashPreparing ? 'Checking your wallets…' : 'Pay from this node’s ecash wallet' }}</span>
|
||||||
<span class="block text-sm text-white/50">Instant, using your ecash balance</span>
|
<span class="block text-sm text-white/50">Instant, using your Cashu or Fedimint balance</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@ -344,6 +344,51 @@
|
|||||||
<p v-if="lnError" class="text-xs text-red-400 px-1">{{ lnError }}</p>
|
<p v-if="lnError" class="text-xs text-red-400 px-1">{{ lnError }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1b: ecash confirmation — show which wallet will be spent -->
|
||||||
|
<div v-else-if="payMode === 'ecash-confirm' && ecashPlan" class="space-y-4">
|
||||||
|
<div class="text-center py-2">
|
||||||
|
<div class="text-3xl font-bold text-white">{{ getItemPrice(payItem.access) }} <span class="text-lg text-white/50">sats</span></div>
|
||||||
|
<div class="text-xs text-white/50 mt-1">from your node’s ecash wallet</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Backend selector: the chosen one is highlighted; the user can
|
||||||
|
switch to the other if it has enough balance. -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<button
|
||||||
|
v-for="b in (['cashu', 'fedimint'] as const)"
|
||||||
|
:key="b"
|
||||||
|
@click="ecashPlan.chosen = b"
|
||||||
|
:disabled="ecashBalanceOf(b) < getItemPrice(payItem.access)"
|
||||||
|
class="w-full px-4 py-3 rounded-xl flex items-center gap-3 text-left border transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
:class="ecashPlan.chosen === b ? 'border-green-400/70 bg-green-400/10' : 'border-white/10 bg-white/5 hover:bg-white/10'"
|
||||||
|
>
|
||||||
|
<span class="text-xl shrink-0">{{ b === 'cashu' ? '🥜' : '🤝' }}</span>
|
||||||
|
<span class="flex-1 min-w-0">
|
||||||
|
<span class="block text-base text-white">{{ b === 'cashu' ? 'Cashu' : 'Fedimint' }}</span>
|
||||||
|
<span class="block text-xs text-white/50">Balance: {{ ecashBalanceOf(b).toLocaleString() }} sats<span v-if="ecashBalanceOf(b) < getItemPrice(payItem.access)"> · not enough</span></span>
|
||||||
|
</span>
|
||||||
|
<svg v-if="ecashPlan.chosen === b" class="w-5 h-5 text-green-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="purchaseError" class="text-sm text-red-400">{{ purchaseError }}</p>
|
||||||
|
|
||||||
|
<div class="flex gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
class="flex-1 glass-button px-4 py-2.5 rounded-xl text-sm text-white/70"
|
||||||
|
:disabled="downloading === payItem.id"
|
||||||
|
@click="payMode = 'choose'"
|
||||||
|
>Back</button>
|
||||||
|
<button
|
||||||
|
class="flex-1 px-4 py-2.5 rounded-xl text-sm font-semibold text-black bg-green-400 hover:bg-green-300 transition-colors disabled:opacity-50"
|
||||||
|
:disabled="!ecashPlan.chosen || downloading === payItem.id"
|
||||||
|
@click="confirmEcashPay"
|
||||||
|
>{{ downloading === payItem.id ? 'Paying…' : `Confirm · pay with ${ecashPlan.chosen === 'fedimint' ? 'Fedimint' : 'Cashu'}` }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Step 2: pay from another wallet — tabbed QR (on-chain default) -->
|
<!-- Step 2: pay from another wallet — tabbed QR (on-chain default) -->
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<!-- Method tabs, styled like the wallet Send/Receive modal -->
|
<!-- Method tabs, styled like the wallet Send/Receive modal -->
|
||||||
@ -497,7 +542,18 @@ const audioPlayer = useAudioPlayer()
|
|||||||
// wallet (instant), or a Lightning invoice drawn on the SELLER's node that
|
// wallet (instant), or a Lightning invoice drawn on the SELLER's node that
|
||||||
// they can pay from any external wallet by scanning a QR.
|
// they can pay from any external wallet by scanning a QR.
|
||||||
const payItem = ref<CatalogItem | null>(null)
|
const payItem = ref<CatalogItem | null>(null)
|
||||||
const payMode = ref<'choose' | 'qr'>('choose')
|
const payMode = ref<'choose' | 'ecash-confirm' | 'qr'>('choose')
|
||||||
|
// Ecash confirmation step: after the user picks "pay from this node's ecash",
|
||||||
|
// we look at both balances, decide which backend covers the price, and show a
|
||||||
|
// confirm screen so they see (and can switch) which ecash is spent (#3).
|
||||||
|
type EcashBackend = 'cashu' | 'fedimint'
|
||||||
|
const ecashPlan = ref<{
|
||||||
|
cashu: number
|
||||||
|
fedimint: number
|
||||||
|
total: number
|
||||||
|
chosen: EcashBackend | null
|
||||||
|
} | null>(null)
|
||||||
|
const ecashPreparing = ref(false)
|
||||||
// Pay-from-another-wallet QR view: tabbed like the wallet's Send/Receive modal,
|
// Pay-from-another-wallet QR view: tabbed like the wallet's Send/Receive modal,
|
||||||
// on-chain first (the default).
|
// on-chain first (the default).
|
||||||
const qrTab = ref<'onchain' | 'lightning'>('onchain')
|
const qrTab = ref<'onchain' | 'lightning'>('onchain')
|
||||||
@ -733,6 +789,9 @@ function closePayModal() {
|
|||||||
if (invoicePollTimer) { clearTimeout(invoicePollTimer); invoicePollTimer = null }
|
if (invoicePollTimer) { clearTimeout(invoicePollTimer); invoicePollTimer = null }
|
||||||
if (onchainPollTimer) { clearTimeout(onchainPollTimer); onchainPollTimer = null }
|
if (onchainPollTimer) { clearTimeout(onchainPollTimer); onchainPollTimer = null }
|
||||||
payItem.value = null
|
payItem.value = null
|
||||||
|
payMode.value = 'choose'
|
||||||
|
ecashPlan.value = null
|
||||||
|
ecashPreparing.value = false
|
||||||
invoiceWaiting.value = false
|
invoiceWaiting.value = false
|
||||||
onchainWaiting.value = false
|
onchainWaiting.value = false
|
||||||
onchainPaying.value = false
|
onchainPaying.value = false
|
||||||
@ -906,48 +965,75 @@ async function pollOnchain(address: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pay the open item from the local ecash wallet (mint token + download). */
|
/** Spendable balance for a given ecash backend in the current plan. */
|
||||||
async function payWithEcash() {
|
function ecashBalanceOf(b: EcashBackend): number {
|
||||||
|
if (!ecashPlan.value) return 0
|
||||||
|
return b === 'cashu' ? ecashPlan.value.cashu : ecashPlan.value.fedimint
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 1b: look at BOTH ecash balances, pick the backend that covers the price
|
||||||
|
* (Cashu preferred, else Fedimint), and show a confirmation screen so the user
|
||||||
|
* sees exactly which wallet is spent and can switch before committing (#3).
|
||||||
|
*/
|
||||||
|
async function prepareEcashPay() {
|
||||||
|
const item = payItem.value
|
||||||
|
if (!item) return
|
||||||
|
const price = getItemPrice(item.access)
|
||||||
|
ecashPreparing.value = true
|
||||||
|
purchaseError.value = null
|
||||||
|
try {
|
||||||
|
let cashu = 0
|
||||||
|
let fedimint = 0
|
||||||
|
try {
|
||||||
|
const res = await rpcClient.call<{ cashu_sats?: number; fedimint_sats?: number; total_sats?: number; balance_sats?: number }>({
|
||||||
|
method: 'wallet.ecash-balance',
|
||||||
|
})
|
||||||
|
cashu = res?.cashu_sats ?? res?.balance_sats ?? 0
|
||||||
|
fedimint = res?.fedimint_sats ?? 0
|
||||||
|
} catch {
|
||||||
|
// Couldn't read balances — let the user try anyway (auto backend).
|
||||||
|
}
|
||||||
|
const total = cashu + fedimint
|
||||||
|
// Prefer Cashu when it covers the price, else Fedimint, else leave null
|
||||||
|
// (insufficient — shown in the confirm screen, Confirm disabled).
|
||||||
|
const chosen: EcashBackend | null =
|
||||||
|
cashu >= price ? 'cashu' : fedimint >= price ? 'fedimint' : null
|
||||||
|
ecashPlan.value = { cashu, fedimint, total, chosen }
|
||||||
|
if (!chosen) {
|
||||||
|
purchaseError.value = `Not enough ecash: Cashu ${cashu} + Fedimint ${fedimint} sats, need ${price}. Fund a wallet, or pay another way.`
|
||||||
|
}
|
||||||
|
payMode.value = 'ecash-confirm'
|
||||||
|
} finally {
|
||||||
|
ecashPreparing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirm the ecash payment with the backend the user selected. */
|
||||||
|
async function confirmEcashPay() {
|
||||||
const item = payItem.value
|
const item = payItem.value
|
||||||
const onion = props.peerId || currentPeer.value?.onion
|
const onion = props.peerId || currentPeer.value?.onion
|
||||||
if (!item || !onion) return
|
const method = ecashPlan.value?.chosen
|
||||||
|
if (!item || !onion || !method) return
|
||||||
const price = getItemPrice(item.access)
|
const price = getItemPrice(item.access)
|
||||||
|
|
||||||
downloading.value = item.id
|
downloading.value = item.id
|
||||||
purchaseError.value = null
|
purchaseError.value = null
|
||||||
try {
|
try {
|
||||||
// Check ecash balance first — across BOTH backends (Cashu + Fedimint), since
|
const result = await rpcClient.call<{ data?: string; error?: string; ecash_backend?: string }>({
|
||||||
// the payment tries each in turn (#3). Fall back to the Cashu-only field for
|
|
||||||
// older backends that don't report `total_sats`.
|
|
||||||
try {
|
|
||||||
const balanceRes = await rpcClient.call<{ total_sats?: number; balance_sats?: number }>({
|
|
||||||
method: 'wallet.ecash-balance',
|
|
||||||
})
|
|
||||||
const balance = balanceRes?.total_sats ?? balanceRes?.balance_sats ?? 0
|
|
||||||
if (balance < price) {
|
|
||||||
purchaseError.value = `Insufficient ecash balance (${balance} sats across Cashu + Fedimint). Need ${price} sats. Fund either wallet, or pay from another wallet via QR.`
|
|
||||||
closePayModal()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Balance check failed — try the purchase anyway
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await rpcClient.call<{ data?: string; error?: string }>({
|
|
||||||
method: 'content.download-peer-paid',
|
method: 'content.download-peer-paid',
|
||||||
params: { onion, content_id: item.id, price_sats: price },
|
params: { onion, content_id: item.id, price_sats: price, method },
|
||||||
timeout: 120000,
|
timeout: 120000,
|
||||||
})
|
})
|
||||||
if (result?.data) {
|
if (result?.data) {
|
||||||
triggerDownload(result.data, item)
|
triggerDownload(result.data, item)
|
||||||
closePayModal()
|
closePayModal()
|
||||||
} else if (result?.error) {
|
} else if (result?.error) {
|
||||||
purchaseError.value = `Payment failed: ${result.error}`
|
// Keep the confirm screen open so the user can switch backend and retry.
|
||||||
closePayModal()
|
purchaseError.value = result.error
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
purchaseError.value = e instanceof Error ? e.message : 'Download failed'
|
purchaseError.value = e instanceof Error ? e.message : 'Download failed'
|
||||||
closePayModal()
|
|
||||||
} finally {
|
} finally {
|
||||||
downloading.value = null
|
downloading.value = null
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user