diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 00000000..1b0594b7 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,11 @@ +# .env.example + +# Soroban RPC endpoint used to build/submit contract call transactions client-side. +VITE_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org + +# Deployed bounty contract address (same one the backend uses as CONTRACT_ID). +VITE_CONTRACT_ID= + +# Must match the network passphrase Freighter is configured for. +VITE_STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 +VITE_STELLAR_NETWORK=TESTNET diff --git a/frontend/package.json b/frontend/package.json index 8752f4bf..b744a6b4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "test": "vitest run" }, "dependencies": { + "@stellar/stellar-sdk": "^15.0.1", "lucide-react": "^1.11.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4f9e0867..3fbbdf5f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,7 @@ import { submitBounty, } from "./api"; import { useFreighter } from "./hooks/useFreighter"; +import { buildBountyActionTransaction, submitSignedTransaction, ContractCallError } from "./stellarTx"; import FreighterConnectButton from "./components/FreighterConnectButton"; import { statusCopy, @@ -375,6 +376,41 @@ function App() { } } + // Builds the unsigned Soroban transaction, gets it signed by the connected + // wallet, and submits it to Soroban RPC. Returns the on-chain transaction + // hash, or null if the user cancelled/the submission failed (a toast is + // already shown in that case). + async function signAndSubmitMaintainerAction( + contractFunction: "release_bounty" | "refund_bounty", + bounty: Bounty, + cancelledMessage: string, + failureLabel: string + ): Promise { + try { + const unsignedXdr = await buildBountyActionTransaction( + contractFunction, + bounty.id, + freighter.publicKey!, + freighter.publicKey! + ); + const signedXdr = await freighter.signTransaction(unsignedXdr); + return await submitSignedTransaction(signedXdr); + } catch (err) { + if (err instanceof ContractCallError) { + toast.error(err.message); + } else { + const code = (err as { code?: string })?.code; + if (code === "USER_REJECTED") { + toast.error(cancelledMessage); + } else { + const message = (err as { message?: string })?.message ?? failureLabel; + toast.error(message); + } + } + return null; + } + } + async function handleRelease(bounty: Bounty) { // Require Freighter connection for maintainer actions if (!freighter.isConnected || !freighter.publicKey) { @@ -386,11 +422,18 @@ function App() { return; } - const transactionHash = window.prompt("Transaction hash (64 hex chars, optional)") ?? undefined; + const transactionHash = await signAndSubmitMaintainerAction( + "release_bounty", + bounty, + "Release cancelled — the signature request was rejected.", + "Failed to submit the release transaction." + ); + if (!transactionHash) return; + const timestamp = Math.floor(Date.now() / 1000); const payload = { maintainer: freighter.publicKey, - ...(transactionHash ? { transactionHash } : {}), + transactionHash, action: "release" as const, bountyId: bounty.id, timestamp, @@ -426,11 +469,18 @@ function App() { return; } - const transactionHash = window.prompt("Transaction hash (64 hex chars, optional)") ?? undefined; + const transactionHash = await signAndSubmitMaintainerAction( + "refund_bounty", + bounty, + "Refund cancelled — the signature request was rejected.", + "Failed to submit the refund transaction." + ); + if (!transactionHash) return; + const timestamp = Math.floor(Date.now() / 1000); const payload = { maintainer: freighter.publicKey, - ...(transactionHash ? { transactionHash } : {}), + transactionHash, action: "refund" as const, bountyId: bounty.id, timestamp, diff --git a/frontend/src/hooks/useFreighter.ts b/frontend/src/hooks/useFreighter.ts index 2dd2969d..15d2e58f 100644 --- a/frontend/src/hooks/useFreighter.ts +++ b/frontend/src/hooks/useFreighter.ts @@ -4,7 +4,7 @@ * Provides: * - Connection state (isConnected, publicKey) * - Network detection (isOnCorrectNetwork) - * - signPayload() for signing canonical request payload objects + * - signPayload() for signing canonical request payload objects * - connect() / disconnect() lifecycle * - Error state for disconnection / wrong network */ @@ -20,6 +20,10 @@ declare global { message: string, opts?: { networkPassphrase?: string } ) => Promise<{ signature: string }>; + signTransaction: ( + xdr: string, + opts?: { network?: string; networkPassphrase?: string; accountToSign?: string } + ) => Promise<{ signedTxXdr: string }>; getNetwork: () => Promise<{ network: string; networkPassphrase: string; @@ -56,11 +60,12 @@ export interface FreighterState { connecting: boolean; } -export interface FreighterActions { - connect: () => Promise; - disconnect: () => void; - signPayload: (payload: Record) => Promise<{ signature: string; publicKey: string }>; -} +export interface FreighterActions { + connect: () => Promise; + disconnect: () => void; + signPayload: (payload: Record) => Promise<{ signature: string; publicKey: string }>; + signTransaction: (xdr: string) => Promise; +} function freighterError(code: FreighterErrorCode, message: string): FreighterError { return { code, message }; @@ -205,8 +210,8 @@ export function useFreighter(): FreighterState & FreighterActions { setError(null); }, []); - const signPayload = useCallback( - async (payload: Record) => { + const signPayload = useCallback( + async (payload: Record) => { if (!isFreighterInstalled()) { throw freighterError("NO_FREIGHTER", "Freighter wallet is not installed."); } @@ -242,6 +247,46 @@ export function useFreighter(): FreighterState & FreighterActions { [isConnected, publicKey, isOnCorrectNetwork] ); + const signTransaction = useCallback( + async (xdr: string): Promise => { + if (!isFreighterInstalled()) { + throw freighterError("NO_FREIGHTER", "Freighter wallet is not installed."); + } + + if (!isConnected || !publicKey) { + throw freighterError("NOT_CONNECTED", "Freighter wallet is not connected."); + } + + if (!isOnCorrectNetwork) { + throw freighterError( + "WRONG_NETWORK", + `Wrong network. Please switch to ${STELLAR_NETWORK} in Freighter.` + ); + } + + try { + const { signedTxXdr } = await window.freighter!.signTransaction(xdr, { + network: STELLAR_NETWORK, + networkPassphrase: STELLAR_NETWORK_PASSPHRASE, + accountToSign: publicKey, + }); + + return signedTxXdr; + } catch (err: any) { + if ( + err?.code === 4 || + err?.message?.includes("reject") || + err?.message?.includes("cancel") || + err?.message?.includes("Declined") + ) { + throw freighterError("USER_REJECTED", "Transaction signing was cancelled in Freighter."); + } + throw freighterError("SIGNING_FAILED", err?.message ?? "Failed to sign the transaction with Freighter."); + } + }, + [isConnected, publicKey, isOnCorrectNetwork] + ); + return { isConnected, publicKey, @@ -251,5 +296,6 @@ export function useFreighter(): FreighterState & FreighterActions { connect, disconnect, signPayload, + signTransaction, }; -} +} diff --git a/frontend/src/stellarTx.ts b/frontend/src/stellarTx.ts new file mode 100644 index 00000000..e9f7f22e --- /dev/null +++ b/frontend/src/stellarTx.ts @@ -0,0 +1,121 @@ +/** + * Client-side builder/submitter for the bounty contract's Soroban actions + * (release_bounty, refund_bounty, dispute_bounty). + * + * The flow is: build an unsigned XDR here -> hand it to the connected wallet + * for signing (useFreighter.signTransaction) -> submit the signed XDR with + * submitSignedTransaction() -> surface the resulting transaction hash. + */ + +import { Contract, TransactionBuilder, nativeToScVal, rpc } from '@stellar/stellar-sdk'; + +import { STELLAR_NETWORK_PASSPHRASE } from './hooks/useFreighter'; + +const SOROBAN_RPC_URL = + import.meta.env.VITE_SOROBAN_RPC_URL ?? 'https://soroban-testnet.stellar.org'; +const CONTRACT_ID = import.meta.env.VITE_CONTRACT_ID ?? ''; + +const BASE_FEE = '1000000'; +const POLL_INTERVAL_MS = 1500; +const POLL_TIMEOUT_MS = 30000; + +export type BountyContractFunction = 'release_bounty' | 'refund_bounty' | 'dispute_bounty'; + +export class ContractCallError extends Error {} + +function getServer(): rpc.Server { + return new rpc.Server(SOROBAN_RPC_URL); +} + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Builds an unsigned Soroban transaction XDR invoking a bounty contract + * method that takes `(bounty_id: u64, authorized_address: Address)`, ready + * to be handed to the connected wallet's sign method. + */ +export async function buildBountyActionTransaction( + functionName: BountyContractFunction, + bountyId: string, + signerAddress: string, + authorizedAddress: string +): Promise { + if (!CONTRACT_ID) { + throw new ContractCallError( + 'The Soroban contract address is not configured (VITE_CONTRACT_ID is missing).' + ); + } + + const server = getServer(); + + let account; + try { + account = await server.getAccount(signerAddress); + } catch { + throw new ContractCallError( + 'Could not load your Stellar account. Make sure it exists and is funded on this network.' + ); + } + + const contract = new Contract(CONTRACT_ID); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: STELLAR_NETWORK_PASSPHRASE, + }) + .addOperation( + contract.call( + functionName, + nativeToScVal(BigInt(bountyId), { type: 'u64' }), + nativeToScVal(authorizedAddress, { type: 'address' }) + ) + ) + .setTimeout(60) + .build(); + + try { + const prepared = await server.prepareTransaction(tx); + return prepared.toXDR(); + } catch (err) { + throw new ContractCallError( + err instanceof Error ? `Failed to prepare transaction: ${err.message}` : 'Failed to prepare transaction.' + ); + } +} + +/** + * Submits a wallet-signed transaction XDR to Soroban RPC and polls until it + * finalizes, returning the transaction hash on success. + */ +export async function submitSignedTransaction(signedXdr: string): Promise { + const server = getServer(); + const tx = TransactionBuilder.fromXDR(signedXdr, STELLAR_NETWORK_PASSPHRASE); + + const sendResult = await server.sendTransaction(tx); + + if (sendResult.status === 'ERROR') { + throw new ContractCallError('The network rejected the transaction submission.'); + } + + const hash = sendResult.hash; + const deadline = Date.now() + POLL_TIMEOUT_MS; + + while (Date.now() < deadline) { + const statusResult = await server.getTransaction(hash); + + if (statusResult.status === rpc.Api.GetTransactionStatus.SUCCESS) { + return hash; + } + + if (statusResult.status === rpc.Api.GetTransactionStatus.FAILED) { + throw new ContractCallError('The transaction failed on-chain.'); + } + + await wait(POLL_INTERVAL_MS); + } + + throw new ContractCallError( + `Transaction submitted (hash ${hash}) but confirmation timed out. Check a Stellar explorer for its final status.` + ); +}