Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 16 additions & 22 deletions frontend/src/app/(marketing)/detail/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import Link from "next/link";
import { notFound, useParams } from "next/navigation";
import { useFreighter } from "@/hooks/useFreighter";
import { balanceApi, campaignsApi, donationsApi, type BalanceApi } from "@/lib/api";
import { formatLedgerReference, getStellarExpertContractUrl, getStellarExpertTxUrl, isStellarTxHash, buildPaymentTransaction, submitSignedTransactionXdr, STELLAR_CONFIG } from "@/lib/stellar";
import { formatLedgerReference, getStellarExpertContractUrl, getStellarExpertTxUrl, isStellarTxHash, buildSignAndSubmit, STELLAR_CONFIG } from "@/lib/stellar";
import toast from "react-hot-toast";
import VotingPanel from "@/components/stellar/VotingPanel";
import SafeImageFrame from "@/components/campaign/SafeImageFrame";
Expand Down Expand Up @@ -266,51 +266,45 @@ export default function DetailPage() {
const memo = `LNGP-DON-${campaignId}`.slice(0, 28);

try {
// 1. Build unsigned XLM payment to LINGAP receiver
const tx = await buildPaymentTransaction(
publicKey,
receivingWallet,
effectiveAmount.toFixed(7),
undefined,
memo,
);

// 2. Freighter signs → popup appears here
// buildSignAndSubmit: builds tx with fresh sequence, Freighter signs,
// submits raw XDR, auto-retries once on tx_bad_seq
toast("Check your Freighter wallet to sign the donation.", { icon: "🔐", duration: 8000 });
let signedXdr: string;
let realTxHash: string;
try {
signedXdr = await sign(tx.toXDR(), STELLAR_CONFIG.network);
const result = await buildSignAndSubmit(
publicKey,
receivingWallet,
effectiveAmount.toFixed(7),
memo,
sign,
);
realTxHash = result.hash;
} catch (signErr: unknown) {
const msg = signErr instanceof Error ? signErr.message : String(signErr);
if (msg.toLowerCase().includes("user declined") || msg.toLowerCase().includes("rejected")) {
toast.error("Donation cancelled in Freighter.");
} else {
toast.error(`Freighter error: ${msg}`);
toast.error(msg);
}
return;
}

// 3. Submit to Stellar Horizon — real on-chain tx
toast("Submitting to Stellar network...", { icon: "🚀", duration: 6000 });
const submitResult = await submitSignedTransactionXdr(signedXdr);
const realTxHash = submitResult.hash;

// 4. Record in LINGAP DB with the real Stellar tx hash + deduct balance
// Record in LINGAP DB with the real Stellar tx hash + deduct balance
await donationsApi.create({
amount: Number(effectiveAmount.toFixed(7)),
asset: "XLM",
purpose: donationPurpose,
stellarTxHash: realTxHash,
fundingSource: "stellar_wallet",
spendBalance: true, // deducts from LINGAP balance AND records real tx hash
spendBalance: true,
walletAddress: publicKey,
});

setConfirmedDonationXlm((c) => c + effectiveAmount);
setLastTxHash(realTxHash);
toast.success(`Donation confirmed on Stellar! TX: ${realTxHash.slice(0, 10)}...`);

// 5. Refresh live totals
// Refresh live totals
try {
const [latest, nextBalance] = await Promise.all([
campaignsApi.publicOne(campaignId),
Expand Down
30 changes: 12 additions & 18 deletions frontend/src/components/balance/TopUpModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { balanceApi, type BalanceApi, type BalanceTransactionApi } from "@/lib/a
import { useFreighter } from "@/hooks/useFreighter";
import {
buildPaymentTransaction,
buildSignAndSubmit,
getStellarExpertContractUrl,
getStellarExpertTxUrl,
submitSignedTransactionXdr,
Expand Down Expand Up @@ -116,8 +117,6 @@ export default function TopUpModal({ open, onClose, rate, onConfirmed }: TopUpMo
setConfirmedTxHash("");

try {
let stellarTxHash: string | undefined;

// ── ALL methods: send a real Stellar payment through Freighter ──────────
// This makes every top-up visible on stellar.expert regardless of
// payment method. The memo identifies the source channel.
Expand All @@ -139,39 +138,34 @@ export default function TopUpModal({ open, onClose, rate, onConfirmed }: TopUpMo

toast("Check your Freighter wallet to sign the top-up.", { icon: "🔐", duration: 8000 });

const tx = await buildPaymentTransaction(
publicKey,
receivingWallet,
amountXlm.toFixed(7),
undefined,
memoLabel,
);

let signedXdr: string;
let stellarTxHash: string;
try {
signedXdr = await sign(tx.toXDR(), NETWORK_PASSPHRASE);
const result = await buildSignAndSubmit(
publicKey,
receivingWallet,
amountXlm.toFixed(7),
memoLabel,
sign,
);
stellarTxHash = result.hash;
} catch (signErr: unknown) {
const msg = signErr instanceof Error ? signErr.message : String(signErr);
if (msg.toLowerCase().includes("user declined") || msg.toLowerCase().includes("rejected")) {
toast.error("Transaction cancelled in Freighter.");
} else {
toast.error(`Freighter error: ${msg}`);
toast.error(msg);
}
return;
}

toast("Submitting to Stellar network...", { icon: "🚀", duration: 6000 });
const submitResult = await submitSignedTransactionXdr(signedXdr);
stellarTxHash = submitResult.hash;

// ── Record in LINGAP backend ─────────────────────────────────────────
const res = await balanceApi.simulateTopUp({
paymentMethod: method,
amountXlm: Number(amountXlm.toFixed(7)),
senderReference: senderReference.trim() || undefined,
senderName: senderName.trim() || undefined,
senderWallet: publicKey,
stellarTxHash,
stellarTxHash: stellarTxHash,
});

const topUpTx = res.data.data.top_up;
Expand Down
89 changes: 83 additions & 6 deletions frontend/src/lib/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
Asset,
Keypair,
Horizon,
BASE_FEE,
Memo,
} from "@stellar/stellar-sdk";

Expand Down Expand Up @@ -36,10 +35,13 @@ export async function buildPaymentTransaction(
asset: Asset = Asset.native(),
memo?: string
) {
// Always load a fresh account to get the latest sequence number.
// Stale sequences cause tx_bad_seq errors when multiple transactions
// are submitted in quick succession.
const sourceAccount = await loadAccount(sourcePublicKey);

const txBuilder = new TransactionBuilder(sourceAccount, {
fee: BASE_FEE,
fee: "10000", // 10,000 stroops — avoids tx_insufficient_fee on testnet
networkPassphrase: STELLAR_CONFIG.network,
});

Expand All @@ -49,13 +51,88 @@ export async function buildPaymentTransaction(

if (memo) txBuilder.addMemo(Memo.text(memo));

txBuilder.setTimeout(180);
// 30s timeout — enough for Freighter to sign without the tx expiring
txBuilder.setTimeout(30);
return txBuilder.build();
}

export async function submitSignedTransactionXdr(signedXdr: string) {
const transaction = TransactionBuilder.fromXDR(signedXdr, STELLAR_CONFIG.network);
return horizonServer.submitTransaction(transaction);
export async function submitSignedTransactionXdr(
signedXdr: string,
retries = 1,
): Promise<{ hash: string; successful: boolean }> {
// Submit the raw signed XDR directly to Horizon via fetch.
// Using the SDK's submitTransaction() can silently re-encode the envelope
// and drop the Freighter signature, causing tx_bad_auth errors.
const params = new URLSearchParams({ tx: signedXdr });
const res = await fetch(`${STELLAR_CONFIG.horizonUrl}/transactions`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
});
const data = await res.json();

if (!res.ok) {
const codes = data?.extras?.result_codes;
const txCode: string = codes?.transaction ?? "";
const opCodes: string[] = codes?.operations ?? [];

// tx_bad_seq means the sequence was stale — the caller should rebuild
// the transaction with a fresh sequence and re-sign. We surface this
// as a distinct error so the UI can give a clear message.
if (txCode === "tx_bad_seq") {
throw new Error("TX_BAD_SEQ");
}

// op_underfunded — not enough XLM in the source wallet
if (opCodes.includes("op_underfunded")) {
throw new Error("Insufficient XLM in your Freighter wallet. Please fund your testnet account via friendbot.stellar.org.");
}

// op_no_destination — destination account doesn't exist on this network
if (opCodes.includes("op_no_destination")) {
throw new Error("The LINGAP receiving wallet doesn't exist on this network yet. Contact support.");
}

const detail = codes
? `Stellar error: ${JSON.stringify(codes)}`
: data?.detail || data?.title || "Transaction failed";
throw new Error(detail);
}

return data as { hash: string; successful: boolean };
}

/**
* Build, sign (via Freighter), and submit a payment transaction.
* Automatically retries once on tx_bad_seq by rebuilding with a fresh sequence.
*/
export async function buildSignAndSubmit(
sourcePublicKey: string,
destinationPublicKey: string,
amount: string,
memo: string,
signFn: (xdr: string, networkPassphrase: string) => Promise<string>,
): Promise<{ hash: string }> {
for (let attempt = 0; attempt < 2; attempt++) {
const tx = await buildPaymentTransaction(
sourcePublicKey,
destinationPublicKey,
amount,
undefined,
memo,
);
const signedXdr = await signFn(tx.toXDR(), STELLAR_CONFIG.network);
try {
const result = await submitSignedTransactionXdr(signedXdr);
return { hash: result.hash };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
// On bad sequence, rebuild with fresh account data and retry once
if (msg === "TX_BAD_SEQ" && attempt === 0) continue;
throw err;
}
}
throw new Error("Transaction failed after retry.");
}

export function getStellarExpertTxUrl(txHash: string) {
Expand Down
2 changes: 1 addition & 1 deletion frontend/tsconfig.tsbuildinfo

Large diffs are not rendered by default.