From b2ce4080e6db31890855c73e590bc1ae464e119f Mon Sep 17 00:00:00 2001 From: DeborahOlaboye Date: Wed, 22 Jul 2026 08:12:24 +0100 Subject: [PATCH 1/2] feat(escrow): sign and submit Soroban transaction on gig creation Wires the Create Gig wizard's handleSubmit to build a create_gig invocation on the escrow contract, sign it via Freighter, and submit it through Soroban RPC, polling until the transaction lands on-chain. Extends useWallet with a signTransaction wrapper so components no longer need to import @stellar/freighter-api directly for signing. Closes #16 --- hooks/useWallet.ts | 18 +++++++ pages/create-gig.tsx | 49 ++++++++++++++--- shared/escrow-contract.ts | 111 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 shared/escrow-contract.ts diff --git a/hooks/useWallet.ts b/hooks/useWallet.ts index 5732d4d..5e51c11 100644 --- a/hooks/useWallet.ts +++ b/hooks/useWallet.ts @@ -4,6 +4,7 @@ import { getUserInfo, getNetworkDetails, isAllowed, + signTransaction as freighterSignTransaction, } from "@stellar/freighter-api"; export interface AccountInfo { @@ -32,6 +33,8 @@ export interface WalletState { connect: () => Promise; /** Clear the local connection state so the UI prompts to connect again */ disconnect: () => void; + /** Requests a Freighter signature for a transaction XDR envelope, returning the signed XDR */ + signTransaction: (xdr: string) => Promise; } /** @@ -151,6 +154,20 @@ export function useWallet(): WalletState { setError(null); }, []); + const signTransaction = useCallback( + async (xdr: string): Promise => { + if (!account) { + throw new Error("Connect a wallet before signing a transaction"); + } + + return freighterSignTransaction(xdr, { + networkPassphrase: network?.networkPassphrase, + accountToSign: account.address, + }); + }, + [account, network] + ); + return { account, network, @@ -159,5 +176,6 @@ export function useWallet(): WalletState { error, connect, disconnect, + signTransaction, }; } diff --git a/pages/create-gig.tsx b/pages/create-gig.tsx index 97f5d24..a050812 100644 --- a/pages/create-gig.tsx +++ b/pages/create-gig.tsx @@ -1,8 +1,11 @@ import { useState } from 'react' import type { NextPage } from 'next' import Head from 'next/head' +import { useRouter } from 'next/router' import { Navbar } from '../components/organisms' import { useGlobalToast } from './_app' +import { useWallet } from '../hooks' +import { createGigEscrow } from '../shared/escrow-contract' interface Milestone { id: string @@ -34,7 +37,10 @@ const CreateGig: NextPage = () => { milestones: [{ id: '1', title: '', description: '', amount: '', duration: '' }], }) const [errors, setErrors] = useState>({}) + const [isSubmitting, setIsSubmitting] = useState(false) const toast = useGlobalToast() + const router = useRouter() + const { account, signTransaction } = useWallet() const addMilestone = () => { setFormData({ @@ -98,11 +104,39 @@ const CreateGig: NextPage = () => { setCurrentStep((prev) => Math.max(prev - 1, 0)) } - const handleSubmit = () => { - if (validateStep()) { - toast.success('Gig created successfully!') - console.log('Gig data:', formData) - // Here you would integrate with smart contract + const handleSubmit = async () => { + if (!validateStep()) return + + if (!account) { + toast.error('Connect your Stellar wallet before creating a gig') + return + } + + setIsSubmitting(true) + try { + await createGigEscrow( + { + creator: account.address, + title: formData.title, + description: formData.description, + category: formData.category, + totalBudget: formData.totalBudget, + milestones: formData.milestones.map((m) => ({ + title: m.title, + amount: m.amount, + duration: m.duration, + })), + }, + signTransaction + ) + + toast.success('Gig created and escrow funded successfully!') + router.push('/dashboard') + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create gig escrow' + toast.error(message) + } finally { + setIsSubmitting(false) } } @@ -422,9 +456,10 @@ const CreateGig: NextPage = () => { ) : ( )} diff --git a/shared/escrow-contract.ts b/shared/escrow-contract.ts new file mode 100644 index 0000000..5987c45 --- /dev/null +++ b/shared/escrow-contract.ts @@ -0,0 +1,111 @@ +import { Address, BASE_FEE, Contract, TransactionBuilder, nativeToScVal, rpc } from '@stellar/stellar-sdk' +import { getSorobanServer } from './soroban-rpc' +import { ESCROW_CONTRACT_ID, NETWORK_PASSPHRASE } from './contracts' + +const STROOPS_PER_XLM = 10_000_000 + +function xlmToStroops(amount: string): bigint { + return BigInt(Math.round(Number(amount) * STROOPS_PER_XLM)) +} + +export interface EscrowMilestoneInput { + title: string + amount: string + duration: string +} + +export interface CreateGigEscrowInput { + creator: string + title: string + description: string + category: string + totalBudget: string + milestones: EscrowMilestoneInput[] +} + +/** + * Signs a transaction XDR envelope, returning the signed XDR. Matches the + * shape of Freighter's `signTransaction`, but kept generic so this module + * doesn't depend on a specific wallet. + */ +export type SignTransaction = (xdr: string) => Promise + +/** + * Builds a `create_gig` invocation on the escrow contract, signs it via the + * caller-supplied `signTransaction`, submits it to Soroban RPC, and polls + * until it lands on-chain. Returns the transaction hash on success. + * + * There's no generated contract client for the escrow contract (its Rust + * source isn't part of this frontend repo), so the invocation is built by + * hand against `shared/soroban-rpc.ts`'s shared RPC client. + */ +export async function createGigEscrow( + input: CreateGigEscrowInput, + signTransaction: SignTransaction +): Promise { + if (!ESCROW_CONTRACT_ID) { + throw new Error('Escrow contract is not configured (set NEXT_PUBLIC_ESCROW_CONTRACT_ID)') + } + + const server = getSorobanServer() + const sourceAccount = await server.getAccount(input.creator) + const contract = new Contract(ESCROW_CONTRACT_ID) + + const operation = contract.call( + 'create_gig', + Address.fromString(input.creator).toScVal(), + nativeToScVal(input.title, { type: 'string' }), + nativeToScVal(input.description, { type: 'string' }), + nativeToScVal(input.category, { type: 'string' }), + nativeToScVal(xlmToStroops(input.totalBudget), { type: 'i128' }), + nativeToScVal( + input.milestones.map((milestone) => ({ + title: milestone.title, + amount: xlmToStroops(milestone.amount), + duration: milestone.duration, + })) + ) + ) + + const transaction = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation(operation) + .setTimeout(30) + .build() + + const preparedTransaction = await server.prepareTransaction(transaction) + const signedXdr = await signTransaction(preparedTransaction.toXDR()) + const signedTransaction = TransactionBuilder.fromXDR(signedXdr, NETWORK_PASSPHRASE) + + const sendResult = await server.sendTransaction(signedTransaction) + if (sendResult.status === 'ERROR' || sendResult.status === 'DUPLICATE') { + throw new Error(`Failed to submit transaction (status: ${sendResult.status})`) + } + + return waitForTransaction(server, sendResult.hash) +} + +async function waitForTransaction( + server: rpc.Server, + hash: string, + attempts = 15, + intervalMs = 1500 +): Promise { + for (let attempt = 0; attempt < attempts; attempt++) { + const result = await server.getTransaction(hash) + + if (result.status === rpc.Api.GetTransactionStatus.SUCCESS) { + return hash + } + + if (result.status === rpc.Api.GetTransactionStatus.FAILED) { + throw new Error('Transaction failed on-chain') + } + + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } + + throw new Error('Timed out waiting for transaction confirmation') +} From 8ae2288db1e9376933f1a1b30fb0e67101b2ca7d Mon Sep 17 00:00:00 2001 From: DeborahOlaboye Date: Wed, 22 Jul 2026 11:35:36 +0100 Subject: [PATCH 2/2] fix(escrow): call the real init_escrow method instead of nonexistent create_gig The previous commit invoked a create_gig method that doesn't exist on the deployed TrustFlow contract (trustflow-protocol/trustflow-contract). The actual escrow entrypoint is init_escrow(depositor, beneficiary, milestones), where each Milestone is { label, amount, approved } and the escrowed amount is the sum of milestone amounts rather than a separate total. Since the contract requires a beneficiary address up front with no way to change it later, the Create Gig wizard now collects the freelancer's Stellar address (validated via StrKey) alongside the milestones. Also surfaces the new escrow ID on success and maps on-chain TrustFlowError codes to readable messages. --- pages/create-gig.tsx | 52 +++++++++++++--- shared/escrow-contract.ts | 128 ++++++++++++++++++++++++++++---------- 2 files changed, 137 insertions(+), 43 deletions(-) diff --git a/pages/create-gig.tsx b/pages/create-gig.tsx index a050812..347ae58 100644 --- a/pages/create-gig.tsx +++ b/pages/create-gig.tsx @@ -5,7 +5,7 @@ import { useRouter } from 'next/router' import { Navbar } from '../components/organisms' import { useGlobalToast } from './_app' import { useWallet } from '../hooks' -import { createGigEscrow } from '../shared/escrow-contract' +import { createGigEscrow, isValidStellarAddress } from '../shared/escrow-contract' interface Milestone { id: string @@ -20,6 +20,8 @@ interface FormData { description: string category: string totalBudget: string + /** The freelancer's Stellar address; the escrow contract requires a beneficiary up front. */ + beneficiaryAddress: string milestones: Milestone[] } @@ -34,6 +36,7 @@ const CreateGig: NextPage = () => { description: '', category: 'Development', totalBudget: '', + beneficiaryAddress: '', milestones: [{ id: '1', title: '', description: '', amount: '', duration: '' }], }) const [errors, setErrors] = useState>({}) @@ -79,6 +82,11 @@ const CreateGig: NextPage = () => { if (!formData.totalBudget || Number(formData.totalBudget) <= 0) { newErrors.totalBudget = 'Valid budget is required' } + if (!formData.beneficiaryAddress.trim()) { + newErrors.beneficiaryAddress = "Freelancer's wallet address is required" + } else if (!isValidStellarAddress(formData.beneficiaryAddress.trim())) { + newErrors.beneficiaryAddress = 'Enter a valid Stellar address (starts with G)' + } } if (currentStep === 1) { @@ -114,23 +122,19 @@ const CreateGig: NextPage = () => { setIsSubmitting(true) try { - await createGigEscrow( + const { escrowId } = await createGigEscrow( { - creator: account.address, - title: formData.title, - description: formData.description, - category: formData.category, - totalBudget: formData.totalBudget, + depositor: account.address, + beneficiary: formData.beneficiaryAddress.trim(), milestones: formData.milestones.map((m) => ({ - title: m.title, + label: m.title, amount: m.amount, - duration: m.duration, })), }, signTransaction ) - toast.success('Gig created and escrow funded successfully!') + toast.success(`Gig created and escrow #${escrowId} funded successfully!`) router.push('/dashboard') } catch (err) { const message = err instanceof Error ? err.message : 'Failed to create gig escrow' @@ -278,6 +282,28 @@ const CreateGig: NextPage = () => { {errors.totalBudget &&

{errors.totalBudget}

} + +
+ + setFormData({ ...formData, beneficiaryAddress: e.target.value })} + placeholder="G..." + className={`w-full px-4 py-3 bg-white dark:bg-gray-800 border rounded-lg text-gray-900 dark:text-white font-mono text-sm ${ + errors.beneficiaryAddress ? 'border-red-500' : 'border-gray-300 dark:border-gray-700' + }`} + /> +

+ The escrow contract locks funds for a specific freelancer up front. Enter the Stellar + address of the freelancer you're hiring for this gig. +

+ {errors.beneficiaryAddress && ( +

{errors.beneficiaryAddress}

+ )} +
)} @@ -394,6 +420,12 @@ const CreateGig: NextPage = () => { Milestones: {formData.milestones.length} +
+ Freelancer: + + {formData.beneficiaryAddress} + +
diff --git a/shared/escrow-contract.ts b/shared/escrow-contract.ts index 5987c45..52a4046 100644 --- a/shared/escrow-contract.ts +++ b/shared/escrow-contract.ts @@ -1,28 +1,63 @@ -import { Address, BASE_FEE, Contract, TransactionBuilder, nativeToScVal, rpc } from '@stellar/stellar-sdk' +import { Address, BASE_FEE, Contract, StrKey, TransactionBuilder, nativeToScVal, scValToNative, rpc } from '@stellar/stellar-sdk' import { getSorobanServer } from './soroban-rpc' import { ESCROW_CONTRACT_ID, NETWORK_PASSPHRASE } from './contracts' const STROOPS_PER_XLM = 10_000_000 +/** + * Maps the `TrustFlowError` enum from the on-chain contract + * (trustflow-protocol/trustflow-contract, contracts/trustflow/src/lib.rs) + * to a human-readable message. Soroban surfaces contract errors as strings + * like "... Error(Contract, #3) ..." in RPC/simulation failures. + */ +const CONTRACT_ERROR_MESSAGES: Record = { + 1: 'Unauthorized: the connected wallet is not allowed to perform this action', + 2: 'Escrow not found', + 3: 'Invalid amount: every milestone amount must be greater than zero', + 4: 'Dispute not found', + 5: 'This dispute has already been resolved', + 6: 'The escrow is not in a valid state for this action', + 7: 'This juror has already voted on this dispute', + 8: 'Insufficient staked balance', + 9: 'No votes have been cast on this dispute', + 10: 'Milestone amounts do not match the escrow total', +} + +function describeContractError(message: string): string { + const match = message.match(/Error\(Contract,\s*#(\d+)\)/) + if (match) { + const code = Number(match[1]) + return CONTRACT_ERROR_MESSAGES[code] ?? message + } + return message +} + function xlmToStroops(amount: string): bigint { return BigInt(Math.round(Number(amount) * STROOPS_PER_XLM)) } +export function isValidStellarAddress(address: string): boolean { + return StrKey.isValidEd25519PublicKey(address) +} + export interface EscrowMilestoneInput { - title: string + label: string amount: string - duration: string } export interface CreateGigEscrowInput { - creator: string - title: string - description: string - category: string - totalBudget: string + /** The gig poster's wallet address; funds are drawn from this account. */ + depositor: string + /** The freelancer's wallet address; receives the escrowed funds on release/settlement. */ + beneficiary: string milestones: EscrowMilestoneInput[] } +export interface CreateGigEscrowResult { + escrowId: string + txHash: string +} + /** * Signs a transaction XDR envelope, returning the signed XDR. Matches the * shape of Freighter's `signTransaction`, but kept generic so this module @@ -30,41 +65,60 @@ export interface CreateGigEscrowInput { */ export type SignTransaction = (xdr: string) => Promise +function milestoneToScVal(milestone: EscrowMilestoneInput) { + return nativeToScVal( + { + label: milestone.label, + amount: xlmToStroops(milestone.amount), + approved: false, + }, + { + type: { + label: ['symbol', 'string'], + amount: ['symbol', 'i128'], + approved: ['symbol', null], + }, + } + ) +} + /** - * Builds a `create_gig` invocation on the escrow contract, signs it via the - * caller-supplied `signTransaction`, submits it to Soroban RPC, and polls - * until it lands on-chain. Returns the transaction hash on success. + * Builds an `init_escrow` invocation on the TrustFlow contract, signs it via + * the caller-supplied `signTransaction`, submits it to Soroban RPC, and + * polls until it lands on-chain. Returns the new escrow ID and tx hash. * - * There's no generated contract client for the escrow contract (its Rust - * source isn't part of this frontend repo), so the invocation is built by - * hand against `shared/soroban-rpc.ts`'s shared RPC client. + * There's no generated TypeScript client for the contract yet, so the + * invocation is built by hand against the ABI in + * trustflow-protocol/trustflow-contract (contracts/trustflow/src/lib.rs): + * + * fn init_escrow(depositor: Address, beneficiary: Address, milestones: Vec) -> Result + * struct Milestone { label: String, amount: i128, approved: bool } + * + * The contract locks `sum(milestones[].amount)` of its configured token from + * `depositor` and requires `beneficiary` up front (there's no on-chain + * method to change it later), so this must be called with the chosen + * freelancer's address already known. */ export async function createGigEscrow( input: CreateGigEscrowInput, signTransaction: SignTransaction -): Promise { +): Promise { if (!ESCROW_CONTRACT_ID) { throw new Error('Escrow contract is not configured (set NEXT_PUBLIC_ESCROW_CONTRACT_ID)') } + if (input.milestones.length === 0) { + throw new Error('At least one milestone is required') + } const server = getSorobanServer() - const sourceAccount = await server.getAccount(input.creator) + const sourceAccount = await server.getAccount(input.depositor) const contract = new Contract(ESCROW_CONTRACT_ID) const operation = contract.call( - 'create_gig', - Address.fromString(input.creator).toScVal(), - nativeToScVal(input.title, { type: 'string' }), - nativeToScVal(input.description, { type: 'string' }), - nativeToScVal(input.category, { type: 'string' }), - nativeToScVal(xlmToStroops(input.totalBudget), { type: 'i128' }), - nativeToScVal( - input.milestones.map((milestone) => ({ - title: milestone.title, - amount: xlmToStroops(milestone.amount), - duration: milestone.duration, - })) - ) + 'init_escrow', + Address.fromString(input.depositor).toScVal(), + Address.fromString(input.beneficiary).toScVal(), + nativeToScVal(input.milestones.map(milestoneToScVal)) ) const transaction = new TransactionBuilder(sourceAccount, { @@ -75,13 +129,20 @@ export async function createGigEscrow( .setTimeout(30) .build() - const preparedTransaction = await server.prepareTransaction(transaction) + let preparedTransaction + try { + preparedTransaction = await server.prepareTransaction(transaction) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + throw new Error(describeContractError(message)) + } + const signedXdr = await signTransaction(preparedTransaction.toXDR()) const signedTransaction = TransactionBuilder.fromXDR(signedXdr, NETWORK_PASSPHRASE) const sendResult = await server.sendTransaction(signedTransaction) if (sendResult.status === 'ERROR' || sendResult.status === 'DUPLICATE') { - throw new Error(`Failed to submit transaction (status: ${sendResult.status})`) + throw new Error(describeContractError(`Failed to submit transaction (status: ${sendResult.status})`)) } return waitForTransaction(server, sendResult.hash) @@ -92,12 +153,13 @@ async function waitForTransaction( hash: string, attempts = 15, intervalMs = 1500 -): Promise { +): Promise { for (let attempt = 0; attempt < attempts; attempt++) { const result = await server.getTransaction(hash) if (result.status === rpc.Api.GetTransactionStatus.SUCCESS) { - return hash + const escrowId = result.returnValue ? String(scValToNative(result.returnValue)) : '' + return { escrowId, txHash: hash } } if (result.status === rpc.Api.GetTransactionStatus.FAILED) {