Browse Source

Make portal crypto tx submission retry-safe

main
def 6 days ago
parent
commit
7a06680250
  1. 197
      templates/portal_invoice_detail.html

197
templates/portal_invoice_detail.html

@ -517,108 +517,155 @@ Reference: ${invoiceRef}`;
} }
const walletButton = document.getElementById("walletPayButton"); const walletButton = document.getElementById("walletPayButton");
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…";
try { function pendingTxStorageKey(invoiceId, paymentId) {
if (!window.ethereum) { return `otb_pending_tx_${invoiceId}_${paymentId}`;
throw new Error("No wallet detected in this browser. Use 'Open in MetaMask Mobile' or copy the payment details.");
} }
const invoiceId = walletButton.dataset.invoiceId; async function submitTxHash(invoiceId, paymentId, asset, txHash) {
const paymentId = walletButton.dataset.paymentId; const res = await fetch(`/portal/invoice/${invoiceId}/submit-crypto-tx`, {
const asset = walletButton.dataset.asset; method: "POST",
const chainId = walletButton.dataset.chainId; headers: {
const assetType = walletButton.dataset.assetType; "Content-Type": "application/json",
const to = walletButton.dataset.to; "Accept": "application/json"
const amount = walletButton.dataset.amount; },
const decimals = Number(walletButton.dataset.decimals || "18"); body: JSON.stringify({
const tokenContract = walletButton.dataset.tokenContract || ""; payment_id: paymentId,
asset: asset,
tx_hash: txHash
})
});
const accounts = await window.ethereum.request({ method: "eth_requestAccounts" }); let data = {};
const from = Array.isArray(accounts) && accounts.length ? accounts[0] : ""; try {
if (!from) { data = await res.json();
throw new Error("Wallet did not return an account address."); } catch (err) {
data = { ok: false, error: "invalid_json_response" };
} }
await switchChain(chainId); if (!res.ok || !data.ok) {
throw new Error(data.error || `submit_failed_http_${res.status}`);
let txHash;
if (assetType === "native") {
txHash = await window.ethereum.request({
method: "eth_sendTransaction",
params: [{
from: from,
to: to,
value: toHexBigIntFromDecimal(amount, decimals)
}]
});
} else {
txHash = await window.ethereum.request({
method: "eth_sendTransaction",
params: [{
from: from,
to: tokenContract,
data: erc20TransferData(to, amount, decimals)
}]
});
} }
if (statusEl) statusEl.textContent = "Submitting transaction hash to portal…"; return data;
}
async function submitTxHashWithRetry() { async function tryRecoverPendingTxFromStorage() {
const maxAttempts = 12; // about 60 seconds total const invoiceId = "{{ invoice.id }}";
const waitMs = 5000; const paymentId = "{{ pending_crypto_payment.id if pending_crypto_payment else '' }}";
const asset = "{{ selected_crypto_option.symbol if selected_crypto_option else '' }}";
for (let attempt = 1; attempt <= maxAttempts; attempt++) { if (!invoiceId || !paymentId || !asset) return;
if (statusEl) { {% if pending_crypto_payment and pending_crypto_payment.txid %}
statusEl.textContent = `Checking RPC for transaction… attempt ${attempt}/${maxAttempts}`; return;
} {% endif %}
const resp = await fetch(`/portal/invoice/${invoiceId}/submit-crypto-tx`, { const key = pendingTxStorageKey(invoiceId, paymentId);
method: "POST", const savedTx = localStorage.getItem(key);
headers: { "Content-Type": "application/json" }, if (!savedTx || !savedTx.startsWith("0x")) return;
body: JSON.stringify({
payment_id: paymentId,
asset: asset,
tx_hash: txHash
})
});
const data = await resp.json(); 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 (resp.ok && data.ok) { if (walletButton) {
window.location.href = data.redirect_url; walletButton.addEventListener("click", async function() {
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; return;
} }
const detail = String((data && (data.detail || data.error)) || ""); try {
const retryable = detail.toLowerCase().includes("not found on rpc"); this.disabled = true;
setStatus("Opening wallet...");
if (!retryable || attempt === maxAttempts) { await window.ethereum.request({ method: "eth_requestAccounts" });
throw new Error(detail || "Portal rejected tx hash");
}
if (statusEl) { if (chainId && chainId !== "None" && chainId !== "") {
statusEl.textContent = `Transaction sent. Waiting for RPC to see it… retrying in ${Math.floor(waitMs / 1000)}s`; try {
await switchChain(Number(chainId));
} catch (err) {
setStatus(`Chain switch failed: ${err.message || err}`);
this.disabled = false;
return;
}
} }
await new Promise(resolve => setTimeout(resolve, waitMs)); 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 {
txParams = {
from: (await window.ethereum.request({ method: "eth_accounts" }))[0],
to: to,
value: toHexBigIntFromDecimal(amount, decimals)
};
} }
setStatus("Waiting for wallet confirmation...");
const txHash = await window.ethereum.request({
method: "eth_sendTransaction",
params: [txParams]
});
if (!txHash || !String(txHash).startsWith("0x")) {
throw new Error("wallet did not return a tx hash");
} }
await submitTxHashWithRetry(); const storageKey = pendingTxStorageKey(invoiceId, paymentId);
localStorage.setItem(storageKey, String(txHash));
setStatus(`Wallet submitted tx: ${txHash}. Sending to billing server...`);
await submitTxHash(invoiceId, paymentId, asset, txHash);
localStorage.removeItem(storageKey);
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) { } catch (err) {
if (statusEl) statusEl.textContent = String(err.message || err); setStatus(`Wallet submit failed: ${err.message || err}`);
walletButton.disabled = false; this.disabled = false;
walletButton.textContent = originalText;
} }
}); });
} }
tryRecoverPendingTxFromStorage();
})(); })();
</script> </script>

Loading…
Cancel
Save