Skip to content
Merged
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
84 changes: 40 additions & 44 deletions backend/app/api/v1/balance.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,52 +220,48 @@ async def simulate_top_up(

amount_xlm = round(float(amount_xlm), 7)

if method == BalancePaymentMethod.stellar_wallet:
if not settings.LINGAP_RECEIVER_PUBLIC_KEY:
raise HTTPException(status_code=400, detail="LINGAP receiving wallet is not configured.")
if not body.sender_wallet or not body.stellar_tx_hash:
raise HTTPException(status_code=422, detail="Stellar Wallet top-up requires sender wallet and transaction hash.")
verification = await verify_native_payment(
body.stellar_tx_hash,
source_public_key=body.sender_wallet,
destination_public_key=settings.LINGAP_RECEIVER_PUBLIC_KEY,
expected_amount=amount_xlm,
# All methods now require a real Stellar tx signed via Freighter.
# Verify the payment landed on-chain before crediting the balance.
if not settings.LINGAP_RECEIVER_PUBLIC_KEY:
raise HTTPException(status_code=400, detail="LINGAP receiving wallet is not configured.")
if not body.sender_wallet or not body.stellar_tx_hash:
raise HTTPException(
status_code=422,
detail="A Stellar transaction hash and sender wallet are required. All top-ups must be signed via Freighter.",
)
if not verification.get("confirmed"):
raise HTTPException(status_code=400, detail=verification.get("reason") or "Stellar payment could not be verified.")
tx = BalanceTransaction(
user_id=user.id,
kind=BalanceTransactionKind.top_up,
amount_xlm=amount_xlm,
amount_php=xlm_to_php(amount_xlm, rate),
payment_method=method,
payment_reference=body.stellar_tx_hash,
payment_status=BalancePaymentStatus.confirmed,
stellar_tx_hash=body.stellar_tx_hash,
note=f"Direct Stellar Wallet top-up from {body.sender_wallet}.",

verification = await verify_native_payment(
body.stellar_tx_hash,
source_public_key=body.sender_wallet,
destination_public_key=settings.LINGAP_RECEIVER_PUBLIC_KEY,
expected_amount=amount_xlm,
)
if not verification.get("confirmed"):
raise HTTPException(
status_code=400,
detail=verification.get("reason") or "Stellar payment could not be verified on-chain.",
)
db.add(tx)
await db.commit()
await db.refresh(tx)
await record_top_up(tx)
else:
if method not in SIMULATED_TOPUP_METHODS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Payment method is not available for this top-up flow.",
)
if method in {BalancePaymentMethod.gcash, BalancePaymentMethod.maya} and not body.sender_reference:
raise HTTPException(status_code=422, detail=f"{METHOD_LABELS[method]} number is required.")
if method == BalancePaymentMethod.pdax and not (body.sender_reference or body.sender_name):
raise HTTPException(status_code=422, detail="PDAX account email or account name is required.")
tx = await create_confirmed_top_up(db, user, method, amount_xlm, rate)
if body.sender_reference or body.sender_name:
tx.note = (
f"{METHOD_LABELS[method]} confirmation credited to LINGAP balance ledger. "
f"Sender: {body.sender_reference or body.sender_name}."
)
await db.commit()
await db.refresh(tx)

method_label = METHOD_LABELS.get(method, method.value)
sender_info = body.sender_reference or body.sender_name or body.sender_wallet
tx = BalanceTransaction(
user_id=user.id,
kind=BalanceTransactionKind.top_up,
amount_xlm=amount_xlm,
amount_php=xlm_to_php(amount_xlm, rate),
payment_method=method,
payment_reference=body.stellar_tx_hash,
payment_status=BalancePaymentStatus.confirmed,
stellar_tx_hash=body.stellar_tx_hash,
note=(
f"{method_label} top-up confirmed on Stellar. "
f"Sender: {sender_info}."
),
)
db.add(tx)
await db.commit()
await db.refresh(tx)
await record_top_up(tx)

balance = await get_user_xlm_balance(db, user.id)
return {
Expand Down
16 changes: 14 additions & 2 deletions backend/app/api/v1/donations.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,18 +183,30 @@ async def create_donation(

if body.spend_balance:
campaign_id = body.purpose.replace("campaign:", "", 1) if body.purpose and body.purpose.startswith("campaign:") else None
# If a real Stellar tx hash was provided, the XLM came from the user's
# Freighter wallet AND the LINGAP balance is deducted to keep them in sync.
payment_method = (
BalancePaymentMethod.stellar_wallet
if body.stellar_tx_hash and not body.stellar_tx_hash.startswith("LNGP-")
else BalancePaymentMethod.lingap_balance
)
balance_tx = BalanceTransaction(
user_id=user.id,
kind=BalanceTransactionKind.donation,
amount_xlm=body.amount,
amount_php=_php(body.amount),
payment_method=BalancePaymentMethod.lingap_balance,
payment_method=payment_method,
payment_reference=tx_hash,
payment_status=BalancePaymentStatus.confirmed,
campaign_id=campaign_id,
donation_id=donation.id,
stellar_tx_hash=tx_hash,
note=f"Donation locked to campaign vault from LINGAP balance. Wallet: {body.wallet_address or 'not provided'}",
note=(
f"Donation to campaign via Stellar Wallet. TX: {tx_hash}. "
f"Wallet: {body.wallet_address or 'not provided'}."
) if payment_method == BalancePaymentMethod.stellar_wallet else (
f"Donation locked to campaign vault from LINGAP balance. Wallet: {body.wallet_address or 'not provided'}."
),
)
db.add(balance_tx)
await db.commit()
Expand Down
152 changes: 55 additions & 97 deletions frontend/src/app/(marketing)/detail/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { useState, useEffect } from "react";
import Link from "next/link";
import { notFound, useParams } from "next/navigation";
import { useFreighter } from "@/hooks/useFreighter";
import { balanceApi, campaignsApi, donationsApi, escrowApi, type BalanceApi } from "@/lib/api";
import { formatLedgerReference, getStellarExpertContractUrl, getStellarExpertTxUrl, isStellarTxHash, STELLAR_CONFIG } from "@/lib/stellar";
import { balanceApi, campaignsApi, donationsApi, type BalanceApi } from "@/lib/api";
import { formatLedgerReference, getStellarExpertContractUrl, getStellarExpertTxUrl, isStellarTxHash, buildPaymentTransaction, submitSignedTransactionXdr, 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 @@ -252,87 +252,68 @@ export default function DetailPage() {
toast.error("Enter a valid amount.");
return;
}
if (balance.xlm_balance < effectiveAmount) {
toast.error("You do not have enough XLM balance. Please top up first.");

const receivingWallet = process.env.NEXT_PUBLIC_LINGAP_RECEIVER_PUBLIC_KEY;
if (!receivingWallet) {
toast.error("LINGAP receiving wallet is not configured.");
return;
}

setDonating(true);
const donationPurpose = `campaign:${activeCampaign.slug || activeCampaign.id}`;
const sorobanId: number | null = activeCampaign.sorobanCampaignId ?? null;
const campaignId = activeCampaign.slug || activeCampaign.id;
const donationPurpose = `campaign:${campaignId}`;
// Stellar text memo max 28 chars
const memo = `LNGP-DON-${campaignId}`.slice(0, 28);

try {
// ── Path A: On-chain via Soroban + Freighter ──────────────────────────
// Only available when the campaign has a registered Soroban campaign ID.
if (sorobanId !== null) {
// 1. Ask backend to build the unsigned deposit XDR
toast("Preparing Stellar transaction...", { icon: "⏳" });
let xdr: string;
try {
const xdrRes = await escrowApi.getDepositXdr(sorobanId, publicKey, effectiveAmount);
xdr = xdrRes.data.data.xdr;
} catch {
// Soroban RPC may be unavailable — fall through to balance path
throw new Error("SOROBAN_UNAVAILABLE");
}

// 2. Freighter signs → popup appears here
toast("Check your Freighter wallet to sign the transaction.", { icon: "🔐", duration: 8000 });
let signedXdr: string;
try {
signedXdr = await sign(xdr, STELLAR_CONFIG.network);
} 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}`);
}
return;
// 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
toast("Check your Freighter wallet to sign the donation.", { icon: "🔐", duration: 8000 });
let signedXdr: string;
try {
signedXdr = await sign(tx.toXDR(), STELLAR_CONFIG.network);
} 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}`);
}

// 3. Submit signed XDR to Soroban via backend
toast("Submitting to Stellar network...", { icon: "🚀", duration: 6000 });
const submitRes = await escrowApi.submitSignedXdr(signedXdr);
const realTxHash = submitRes.data.data.tx_hash;

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

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

} else {
// ── Path B: Balance-only (no Soroban campaign slot yet) ──────────────
// Funds are deducted from LINGAP balance and recorded in the DB.
// A ledger reference is generated — not a real Stellar tx.
const donationRes = await donationsApi.create({
amount: Number(effectiveAmount.toFixed(7)),
asset: "XLM",
purpose: donationPurpose,
fundingSource: "lingap_balance",
spendBalance: true,
walletAddress: publicKey,
});
const txHash = donationRes.data.data.stellarTxHash;
setConfirmedDonationXlm((c) => c + effectiveAmount);
setLastTxHash(txHash);
toast.success(`Donation recorded! Ref: ${txHash.slice(0, 14)}...`);
return;
}

// Refresh live totals
// 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
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
walletAddress: publicKey,
});

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

// 5. Refresh live totals
try {
const [latest, nextBalance] = await Promise.all([
campaignsApi.publicOne(activeCampaign.slug || activeCampaign.id),
campaignsApi.publicOne(campaignId),
balanceApi.mine(),
]);
if (latest.data.data) setLiveSummary(latest.data.data);
Expand All @@ -342,28 +323,7 @@ export default function DetailPage() {
}

} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg === "SOROBAN_UNAVAILABLE") {
// Soroban RPC down — fall back to balance path silently
try {
const donationRes = await donationsApi.create({
amount: Number(effectiveAmount.toFixed(7)),
asset: "XLM",
purpose: donationPurpose,
fundingSource: "lingap_balance",
spendBalance: true,
walletAddress: publicKey,
});
const txHash = donationRes.data.data.stellarTxHash;
setConfirmedDonationXlm((c) => c + effectiveAmount);
setLastTxHash(txHash);
toast.success("Donation recorded via LINGAP balance (Soroban RPC temporarily unavailable).");
} catch (fallbackErr: unknown) {
toast.error(getDonationErrorMessage(fallbackErr));
}
} else {
toast.error(getDonationErrorMessage(e));
}
toast.error(getDonationErrorMessage(e));
} finally {
setDonating(false);
}
Expand Down Expand Up @@ -574,9 +534,7 @@ export default function DetailPage() {
{donating
? "Submitting to Stellar..."
: connected
? activeCampaign.sorobanCampaignId
? `Donate ${formatXlmAmount(effectiveAmount)} XLM via Stellar`
: `Donate ${formatXlmAmount(effectiveAmount)} XLM`
? `Donate ${formatXlmAmount(effectiveAmount)} XLM via Stellar`
: "Connect Wallet to Donate"}
</button>
{lastTxHash && (
Expand Down
Loading
Loading