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
11 changes: 11 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
58 changes: 54 additions & 4 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string | null> {
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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
64 changes: 55 additions & 9 deletions frontend/src/hooks/useFreighter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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;
Expand Down Expand Up @@ -56,11 +60,12 @@ export interface FreighterState {
connecting: boolean;
}

export interface FreighterActions {
connect: () => Promise<void>;
disconnect: () => void;
signPayload: (payload: Record<string, unknown>) => Promise<{ signature: string; publicKey: string }>;
}
export interface FreighterActions {
connect: () => Promise<void>;
disconnect: () => void;
signPayload: (payload: Record<string, unknown>) => Promise<{ signature: string; publicKey: string }>;
signTransaction: (xdr: string) => Promise<string>;
}

function freighterError(code: FreighterErrorCode, message: string): FreighterError {
return { code, message };
Expand Down Expand Up @@ -205,8 +210,8 @@ export function useFreighter(): FreighterState & FreighterActions {
setError(null);
}, []);

const signPayload = useCallback(
async (payload: Record<string, unknown>) => {
const signPayload = useCallback(
async (payload: Record<string, unknown>) => {
if (!isFreighterInstalled()) {
throw freighterError("NO_FREIGHTER", "Freighter wallet is not installed.");
}
Expand Down Expand Up @@ -242,6 +247,46 @@ export function useFreighter(): FreighterState & FreighterActions {
[isConnected, publicKey, isOnCorrectNetwork]
);

const signTransaction = useCallback(
async (xdr: string): Promise<string> => {
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,
Expand All @@ -251,5 +296,6 @@ export function useFreighter(): FreighterState & FreighterActions {
connect,
disconnect,
signPayload,
signTransaction,
};
}
}
121 changes: 121 additions & 0 deletions frontend/src/stellarTx.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string> {
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<string> {
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.`
);
}
Loading