From b56e34e6e8a0d3c06dbc0cdef4791bfb123d8f9e Mon Sep 17 00:00:00 2001 From: Derek Liu Date: Tue, 30 Jun 2026 09:50:49 +0800 Subject: [PATCH] feat: implement ContributeModal with full Soroban transaction flow --- src/components/groups/contribute-modal.tsx | 394 +++++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 src/components/groups/contribute-modal.tsx diff --git a/src/components/groups/contribute-modal.tsx b/src/components/groups/contribute-modal.tsx new file mode 100644 index 0000000..d366666 --- /dev/null +++ b/src/components/groups/contribute-modal.tsx @@ -0,0 +1,394 @@ +"use client"; + +import { useState, useCallback } from "react"; +import * as Dialog from "@radix-ui/react-dialog"; +import { X, Loader2, CheckCircle2, AlertCircle, ExternalLink } from "lucide-react"; +import { + TransactionBuilder, + Contract, + nativeToScVal, + Address, + xdr, +} from "@stellar/stellar-sdk"; +import { SorobanRpc, Networks } from "@stellar/stellar-sdk"; +import { useQueryClient } from "@tanstack/react-query"; +import { server, CONTRACT_IDS, parseAmount } from "@/lib/stellar"; +import { useWallet } from "@/hooks/use-wallet"; +import type { Group } from "@/types"; + +type TxState = "idle" | "signing" | "pending" | "success" | "error"; + +interface ContributeModalProps { + group: Group; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const STELLAR_EXPLORER_BASE = "https://stellar.expert/explorer/testnet/tx"; + +export function ContributeModal({ group, open, onOpenChange }: ContributeModalProps) { + const { address, signTransaction } = useWallet(); + const queryClient = useQueryClient(); + + const [amount, setAmount] = useState(""); + const [period, setPeriod] = useState(() => { + const now = new Date(); + return `${now.getFullYear()}-Q${Math.floor(now.getMonth() / 3) + 1}`; + }); + const [txState, setTxState] = useState("idle"); + const [txHash, setTxHash] = useState(null); + const [errorMsg, setErrorMsg] = useState(""); + + const minContribution = group.rules.minContribution; + const numericAmount = parseFloat(amount); + const isAmountValid = !isNaN(numericAmount) && numericAmount >= minContribution; + const treasuryContractId = group.contractAddresses.treasury || CONTRACT_IDS.treasury; + + const reset = useCallback(() => { + setAmount(""); + setTxState("idle"); + setTxHash(null); + setErrorMsg(""); + }, []); + + const handleClose = useCallback(() => { + if (txState === "signing" || txState === "pending") return; + reset(); + onOpenChange(false); + }, [txState, reset, onOpenChange]); + + const handleSubmit = useCallback(async () => { + if (!address || !isAmountValid || !treasuryContractId) return; + + setTxState("signing"); + setErrorMsg(""); + setTxHash(null); + + try { + const amountStroops = parseAmount(amount); + const memberAddress = new Address(address); + + const contract = new Contract(treasuryContractId); + + // Build the contribute(member, amount, period) call + const contributeArgs = [ + memberAddress.toScVal(), + nativeToScVal(amountStroops, { type: "i128" }), + nativeToScVal(period, { type: "string" }), + ]; + + // Fetch latest ledger for transaction building + const latestLedger = await server.getLatestLedger(); + + // Get the source account + const sourceAccount = await server.getAccount(address); + + const tx = new TransactionBuilder(sourceAccount, { + fee: "10000", + networkPassphrase: Networks.TESTNET, + timebounds: { + minTime: 0, + maxTime: Math.floor(Date.now() / 1000) + 300, + }, + }) + .addOperation(contract.call("contribute", ...contributeArgs)) + .build(); + + // Simulate first to get the transaction data + const simulated = await server.simulateTransaction(tx); + + if (SorobanRpc.Api.isSimulationError(simulated)) { + throw new Error(`Simulation failed: ${simulated.error}`); + } + + // Prepare the transaction with simulation results + const preparedTx = SorobanRpc.assembleTransaction(tx, simulated).build(); + + // Sign with wallet + const signedXdr = await signTransaction(preparedTx.toXDR()); + const signedTx = xdr.Transaction.fromXDR(signedXdr, "base64"); + const transaction = TransactionBuilder.fromXDR( + signedXdr, + Networks.TESTNET + ); + + setTxState("pending"); + + // Submit to the network + const sendResponse = await server.sendTransaction(transaction); + + if (sendResponse.status === "ERROR") { + throw new Error( + `Transaction submission failed: ${sendResponse.errorResult?.result()?.toString() ?? "Unknown error"}` + ); + } + + if (sendResponse.status === "TRY_AGAIN_LATER") { + throw new Error("Network busy. Please try again later."); + } + + // Poll for transaction result + const hash = sendResponse.hash; + setTxHash(hash); + + let attempts = 0; + const maxAttempts = 30; + + while (attempts < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, 3000)); + attempts++; + + const txResult = await server.getTransaction(hash); + + if (txResult.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) { + setTxState("success"); + // Invalidate queries to refresh group data + await queryClient.invalidateQueries({ queryKey: ["groups"] }); + await queryClient.invalidateQueries({ queryKey: ["group", group.id] }); + await queryClient.invalidateQueries({ queryKey: ["wallet-balance"] }); + return; + } + + if (txResult.status === SorobanRpc.Api.GetTransactionStatus.FAILED) { + throw new Error("Transaction failed on-chain."); + } + + // Still NOT_FOUND — keep polling + } + + throw new Error("Transaction confirmation timed out. Check explorer for status."); + } catch (err) { + setTxState("error"); + if (err instanceof Error) { + // Handle wallet rejection + if (err.message.includes("reject") || err.message.includes("denied") || err.message.includes("cancel")) { + setErrorMsg("Transaction was rejected by the wallet."); + } else if (err.message.includes("network") || err.message.includes("fetch")) { + setErrorMsg("Network error. Please check your connection and try again."); + } else { + setErrorMsg(err.message); + } + } else { + setErrorMsg("An unexpected error occurred."); + } + } + }, [address, amount, period, isAmountValid, treasuryContractId, signTransaction, queryClient, group.id]); + + const isBusy = txState === "signing" || txState === "pending"; + + return ( + + + + +
+ {/* Header */} +
+ + Contribute to {group.name} + + + + +
+ + {/* Wallet Info */} + {address && ( +
+

+ Wallet Address +

+

+ {address} +

+
+ )} + + {/* Success State */} + {txState === "success" && txHash && ( +
+
+ +

+ Contribution Successful! +

+

+ Your contribution of {amount} USDC has been submitted. +

+
+
+

+ Transaction Hash +

+
+

+ {txHash.slice(0, 12)}...{txHash.slice(-8)} +

+ + View + + +
+
+ +
+ )} + + {/* Error State */} + {txState === "error" && ( +
+
+ +

+ Transaction Failed +

+

{errorMsg}

+
+ +
+ )} + + {/* Form (idle / signing / pending) */} + {txState !== "success" && txState !== "error" && ( + <> + {/* Amount Input */} +
+ + setAmount(e.target.value)} + disabled={isBusy} + placeholder={`Min: ${minContribution} USDC`} + className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm text-gray-900 placeholder:text-gray-400 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none disabled:bg-gray-50 disabled:text-gray-400" + /> + {amount && !isAmountValid && ( +

+ Minimum contribution is {minContribution} USDC +

+ )} +
+ + {/* Period Selector */} +
+ + setPeriod(e.target.value)} + disabled={isBusy} + className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm text-gray-900 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none disabled:bg-gray-50 disabled:text-gray-400" + /> +

+ Current cycle auto-populated — editable if needed. +

+
+ + {/* Group Info */} +
+
+ Min Contribution + + {minContribution} USDC + +
+
+ Group Balance + + {group.balance.toLocaleString()} USDC + +
+
+ Total Contributed + + {group.totalContributions.toLocaleString()} USDC + +
+
+ + {/* Loading indicator */} + {isBusy && ( +
+ + + {txState === "signing" + ? "Please sign the transaction in your wallet..." + : "Submitting to the network..."} + +
+ )} + + {/* Submit Button */} + + + {!address && ( +

+ Connect your wallet to make a contribution. +

+ )} + + )} +
+
+
+
+ ); +} + +export default ContributeModal;