From 7a06680250938e16160d08c535d2821bedd75e28 Mon Sep 17 00:00:00 2001 From: def Date: Mon, 16 Mar 2026 01:33:58 +0000 Subject: [PATCH] Make portal crypto tx submission retry-safe --- templates/portal_invoice_detail.html | 213 ++++++++++++++++----------- 1 file changed, 130 insertions(+), 83 deletions(-) diff --git a/templates/portal_invoice_detail.html b/templates/portal_invoice_detail.html index 4fae514..490ca78 100644 --- a/templates/portal_invoice_detail.html +++ b/templates/portal_invoice_detail.html @@ -517,108 +517,155 @@ Reference: ${invoiceRef}`; } const walletButton = document.getElementById("walletPayButton"); + + function pendingTxStorageKey(invoiceId, paymentId) { + return `otb_pending_tx_${invoiceId}_${paymentId}`; + } + + async function submitTxHash(invoiceId, paymentId, asset, txHash) { + const res = await fetch(`/portal/invoice/${invoiceId}/submit-crypto-tx`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json" + }, + body: JSON.stringify({ + payment_id: paymentId, + asset: asset, + tx_hash: txHash + }) + }); + + let data = {}; + try { + data = await res.json(); + } catch (err) { + data = { ok: false, error: "invalid_json_response" }; + } + + if (!res.ok || !data.ok) { + throw new Error(data.error || `submit_failed_http_${res.status}`); + } + + return data; + } + + async function tryRecoverPendingTxFromStorage() { + const invoiceId = "{{ invoice.id }}"; + const paymentId = "{{ pending_crypto_payment.id if pending_crypto_payment else '' }}"; + const asset = "{{ selected_crypto_option.symbol if selected_crypto_option else '' }}"; + + if (!invoiceId || !paymentId || !asset) return; + {% if pending_crypto_payment and pending_crypto_payment.txid %} + return; + {% endif %} + + const key = pendingTxStorageKey(invoiceId, paymentId); + const savedTx = localStorage.getItem(key); + if (!savedTx || !savedTx.startsWith("0x")) return; + + const walletStatus = document.getElementById("walletStatusText"); + try { + if (walletStatus) walletStatus.textContent = "Retrying saved transaction submission..."; + await submitTxHash(invoiceId, paymentId, asset, savedTx); + localStorage.removeItem(key); + const url = new URL(window.location.href); + url.searchParams.set("pay", "crypto"); + url.searchParams.set("asset", asset); + url.searchParams.set("payment_id", paymentId); + window.location.href = url.toString(); + } catch (err) { + if (walletStatus) walletStatus.textContent = `Saved tx retry failed: ${err.message}`; + } + } + if (walletButton) { walletButton.addEventListener("click", async function() { - const statusEl = document.getElementById("walletStatusText"); - const originalText = walletButton.textContent; - walletButton.disabled = true; - if (statusEl) statusEl.textContent = "Opening wallet…"; + const walletStatus = document.getElementById("walletStatusText"); + const invoiceId = this.dataset.invoiceId; + const paymentId = this.dataset.paymentId; + const asset = this.dataset.asset; + const chainId = this.dataset.chainId; + const assetType = this.dataset.assetType; + const to = this.dataset.to; + const amount = this.dataset.amount; + const decimals = Number(this.dataset.decimals || "18"); + const tokenContract = this.dataset.tokenContract || ""; + + const setStatus = (msg) => { + if (walletStatus) walletStatus.textContent = msg; + }; + + if (!window.ethereum || !window.ethereum.request) { + setStatus("No browser wallet detected. Use MetaMask/Rabby or MetaMask Mobile."); + return; + } try { - if (!window.ethereum) { - throw new Error("No wallet detected in this browser. Use 'Open in MetaMask Mobile' or copy the payment details."); - } - - const invoiceId = walletButton.dataset.invoiceId; - const paymentId = walletButton.dataset.paymentId; - const asset = walletButton.dataset.asset; - const chainId = walletButton.dataset.chainId; - const assetType = walletButton.dataset.assetType; - const to = walletButton.dataset.to; - const amount = walletButton.dataset.amount; - const decimals = Number(walletButton.dataset.decimals || "18"); - const tokenContract = walletButton.dataset.tokenContract || ""; - - const accounts = await window.ethereum.request({ method: "eth_requestAccounts" }); - const from = Array.isArray(accounts) && accounts.length ? accounts[0] : ""; - if (!from) { - throw new Error("Wallet did not return an account address."); + this.disabled = true; + setStatus("Opening wallet..."); + + await window.ethereum.request({ method: "eth_requestAccounts" }); + + if (chainId && chainId !== "None" && chainId !== "") { + try { + await switchChain(Number(chainId)); + } catch (err) { + setStatus(`Chain switch failed: ${err.message || err}`); + this.disabled = false; + return; + } } - await switchChain(chainId); - - let txHash; - if (assetType === "native") { - txHash = await window.ethereum.request({ - method: "eth_sendTransaction", - params: [{ - from: from, - to: to, - value: toHexBigIntFromDecimal(amount, decimals) - }] - }); + let txParams; + if (assetType === "token" && tokenContract) { + txParams = { + from: (await window.ethereum.request({ method: "eth_accounts" }))[0], + to: tokenContract, + data: erc20TransferData(to, amount, decimals), + value: "0x0" + }; } else { - txHash = await window.ethereum.request({ - method: "eth_sendTransaction", - params: [{ - from: from, - to: tokenContract, - data: erc20TransferData(to, amount, decimals) - }] - }); + txParams = { + from: (await window.ethereum.request({ method: "eth_accounts" }))[0], + to: to, + value: toHexBigIntFromDecimal(amount, decimals) + }; } - if (statusEl) statusEl.textContent = "Submitting transaction hash to portal…"; - - async function submitTxHashWithRetry() { - const maxAttempts = 12; // about 60 seconds total - const waitMs = 5000; + setStatus("Waiting for wallet confirmation..."); + const txHash = await window.ethereum.request({ + method: "eth_sendTransaction", + params: [txParams] + }); - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - if (statusEl) { - statusEl.textContent = `Checking RPC for transaction… attempt ${attempt}/${maxAttempts}`; - } - - const resp = await fetch(`/portal/invoice/${invoiceId}/submit-crypto-tx`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - payment_id: paymentId, - asset: asset, - tx_hash: txHash - }) - }); - - const data = await resp.json(); + if (!txHash || !String(txHash).startsWith("0x")) { + throw new Error("wallet did not return a tx hash"); + } - if (resp.ok && data.ok) { - window.location.href = data.redirect_url; - return; - } + const storageKey = pendingTxStorageKey(invoiceId, paymentId); + localStorage.setItem(storageKey, String(txHash)); - const detail = String((data && (data.detail || data.error)) || ""); - const retryable = detail.toLowerCase().includes("not found on rpc"); + setStatus(`Wallet submitted tx: ${txHash}. Sending to billing server...`); - if (!retryable || attempt === maxAttempts) { - throw new Error(detail || "Portal rejected tx hash"); - } + await submitTxHash(invoiceId, paymentId, asset, txHash); - if (statusEl) { - statusEl.textContent = `Transaction sent. Waiting for RPC to see it… retrying in ${Math.floor(waitMs / 1000)}s`; - } + localStorage.removeItem(storageKey); - await new Promise(resolve => setTimeout(resolve, waitMs)); - } - } - - await submitTxHashWithRetry(); + setStatus("Transaction submitted. Reloading into processing view..."); + const url = new URL(window.location.href); + url.searchParams.set("pay", "crypto"); + url.searchParams.set("asset", asset); + url.searchParams.set("payment_id", paymentId); + window.location.href = url.toString(); } catch (err) { - if (statusEl) statusEl.textContent = String(err.message || err); - walletButton.disabled = false; - walletButton.textContent = originalText; + setStatus(`Wallet submit failed: ${err.message || err}`); + this.disabled = false; } }); } + + tryRecoverPendingTxFromStorage(); })();