diff --git a/src/components/groups/contribute-modal.tsx b/src/components/groups/contribute-modal.tsx new file mode 100644 index 0000000..3a595f5 --- /dev/null +++ b/src/components/groups/contribute-modal.tsx @@ -0,0 +1,395 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { ExternalLink, Loader2, X } from "lucide-react"; +import { + BASE_FEE, + Contract, + SorobanRpc, + TransactionBuilder, + nativeToScVal, + scValToNative, + Address, +} from "@stellar/stellar-sdk"; + +import { useWallet } from "@/hooks/use-wallet"; +import { + STELLAR_NETWORK, + formatAmount, + networkPassphrase, + parseAmount, + server, + simulateContractCall, +} from "@/lib/stellar"; +import type { Group } from "@/types"; + +type TxStatus = "idle" | "signing" | "pending" | "success" | "error"; + +interface ContributeModalProps { + group: Group; + /** Optional override for the USDC SAC contract id. Falls back to env. */ + usdcContractId?: string; + onClose: () => void; +} + +const USDC_CONTRACT_ID = + process.env.NEXT_PUBLIC_USDC_CONTRACT_ID || ""; + +/** Compute the current contribution period (1-indexed) from group rules. */ +function computeCurrentPeriod(group: Group): number { + const start = new Date(group.createdAt).getTime(); + const now = Date.now(); + const periodMs = Math.max(1, group.rules.contributionPeriodDays) * 86_400_000; + return Math.max(1, Math.floor((now - start) / periodMs) + 1); +} + +function explorerTxUrl(hash: string): string { + const net = STELLAR_NETWORK === "MAINNET" ? "public" : "testnet"; + return `https://stellar.expert/explorer/${net}/tx/${hash}`; +} + +export function ContributeModal({ + group, + usdcContractId = USDC_CONTRACT_ID, + onClose, +}: ContributeModalProps) { + const queryClient = useQueryClient(); + const { address, connect, isConnecting, signTransaction } = useWallet(); + + const initialPeriod = useMemo(() => computeCurrentPeriod(group), [group]); + const [amount, setAmount] = useState( + (group.rules.minContribution / 10_000_000).toString() + ); + const [period, setPeriod] = useState(initialPeriod); + + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [txHash, setTxHash] = useState(null); + + const [balance, setBalance] = useState(null); + const [balanceError, setBalanceError] = useState(null); + const [loadingBalance, setLoadingBalance] = useState(false); + + // Load wallet USDC balance via SAC `balance(Address)` simulation. + useEffect(() => { + let cancelled = false; + async function loadBalance() { + if (!address || !usdcContractId) return; + setLoadingBalance(true); + setBalanceError(null); + try { + const result = (await simulateContractCall( + usdcContractId, + "balance", + [new Address(address).toScVal()], + address + )) as bigint | number | null; + if (cancelled) return; + setBalance(result == null ? 0n : BigInt(result as bigint)); + } catch (err) { + if (cancelled) return; + setBalanceError( + err instanceof Error ? err.message : "Failed to load balance" + ); + } finally { + if (!cancelled) setLoadingBalance(false); + } + } + loadBalance(); + return () => { + cancelled = true; + }; + }, [address, usdcContractId]); + + // ---- Validation ------------------------------------------------------- + const amountStroops = useMemo(() => { + const n = Number(amount); + if (!Number.isFinite(n) || n <= 0) return null; + try { + return parseAmount(amount); + } catch { + return null; + } + }, [amount]); + + const minStroops = BigInt(group.rules.minContribution); + + const validationError = useMemo(() => { + if (amountStroops === null) return "Enter a valid amount"; + if (amountStroops < minStroops) { + return `Minimum contribution is ${formatAmount(minStroops)} USDC`; + } + if (balance !== null && amountStroops > balance) { + return "Amount exceeds wallet balance"; + } + if (!Number.isInteger(period) || period < 1) { + return "Period must be a positive integer"; + } + return null; + }, [amountStroops, minStroops, balance, period]); + + const treasuryId = group.contractAddresses?.treasury; + const canSubmit = + !!address && + !!treasuryId && + validationError === null && + (status === "idle" || status === "error"); + + // ---- Submission ------------------------------------------------------- + const handleSubmit = useCallback(async () => { + if (!address || !treasuryId || amountStroops === null) return; + setError(null); + setTxHash(null); + try { + // 1. Build + const account = await server.getAccount(address); + const contract = new Contract(treasuryId); + const op = contract.call( + "contribute", + new Address(address).toScVal(), + nativeToScVal(amountStroops, { type: "i128" }), + nativeToScVal(period, { type: "u32" }) + ); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation(op) + .setTimeout(60) + .build(); + + // 2. Prepare (simulate + assemble footprint/auth) + const prepared = await server.prepareTransaction(tx); + + // 3. Sign + setStatus("signing"); + const signedXdr = await signTransaction(prepared.toXDR()); + const signedTx = TransactionBuilder.fromXDR(signedXdr, networkPassphrase); + + // 4. Submit + setStatus("pending"); + const sendResponse = await server.sendTransaction(signedTx); + if (sendResponse.status === "ERROR") { + throw new Error( + `Submit failed: ${JSON.stringify(sendResponse.errorResult)}` + ); + } + const hash = sendResponse.hash; + setTxHash(hash); + + // 5. Poll + const deadline = Date.now() + 60_000; + let getResp = await server.getTransaction(hash); + while ( + getResp.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND && + Date.now() < deadline + ) { + await new Promise((r) => setTimeout(r, 2_000)); + getResp = await server.getTransaction(hash); + } + + if (getResp.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) { + setStatus("success"); + // Refresh related queries so balances/history update. + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["groups"] }), + queryClient.invalidateQueries({ queryKey: ["group", group.id] }), + queryClient.invalidateQueries({ + queryKey: ["contributions", group.id], + }), + ]); + } else if ( + getResp.status === SorobanRpc.Api.GetTransactionStatus.FAILED + ) { + throw new Error("Transaction failed on-chain"); + } else { + throw new Error("Transaction not confirmed before timeout"); + } + } catch (err) { + setStatus("error"); + setError(err instanceof Error ? err.message : "Contribution failed"); + } + }, [ + address, + treasuryId, + amountStroops, + period, + signTransaction, + queryClient, + group.id, + ]); + + // ---- Render ----------------------------------------------------------- + const submitting = status === "signing" || status === "pending"; + + return ( +
+
+
+
+

Contribute to {group.name}

+

+ Send USDC to the group treasury on Stellar{" "} + {STELLAR_NETWORK === "MAINNET" ? "Mainnet" : "Testnet"} +

+
+ +
+ + {status === "success" && txHash ? ( +
+
+

Contribution confirmed 🎉

+

+ {formatAmount(amountStroops ?? 0n)} USDC sent for period {period}. +

+ + View on Stellar Expert + +
+
+ +
+
+ ) : ( +
+ {/* Wallet / balance */} +
+ {address ? ( + <> +
+ Wallet balance + + {loadingBalance + ? "Loading…" + : balance !== null + ? `${formatAmount(balance)} USDC` + : balanceError + ? "Unavailable" + : "—"} + +
+ {!usdcContractId && ( +

+ USDC contract id not configured (NEXT_PUBLIC_USDC_CONTRACT_ID). +

+ )} + + ) : ( + + )} +
+ + {/* Amount */} + + + {/* Period */} + + + {/* Validation / errors */} + {address && validationError && ( +

{validationError}

+ )} + {status === "error" && error && ( +
+ {error} +
+ )} + {!treasuryId && ( +

+ Group is missing a treasury contract address. +

+ )} + + {/* Status pill */} + {submitting && ( +
+ + {status === "signing" + ? "Waiting for wallet signature…" + : "Submitting and waiting for confirmation…"} +
+ )} + +
+ + +
+
+ )} +
+
+ ); +} + +export default ContributeModal;