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..347ae58 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, isValidStellarAddress } from '../shared/escrow-contract' interface Milestone { id: string @@ -17,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[] } @@ -31,10 +36,14 @@ const CreateGig: NextPage = () => { description: '', category: 'Development', totalBudget: '', + beneficiaryAddress: '', 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({ @@ -73,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) { @@ -98,11 +112,35 @@ 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 { + const { escrowId } = await createGigEscrow( + { + depositor: account.address, + beneficiary: formData.beneficiaryAddress.trim(), + milestones: formData.milestones.map((m) => ({ + label: m.title, + amount: m.amount, + })), + }, + signTransaction + ) + + 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' + toast.error(message) + } finally { + setIsSubmitting(false) } } @@ -244,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}

+ )} +
)} @@ -360,6 +420,12 @@ const CreateGig: NextPage = () => { Milestones: {formData.milestones.length} +
+ Freelancer: + + {formData.beneficiaryAddress} + +
@@ -422,9 +488,10 @@ const CreateGig: NextPage = () => { ) : ( )} diff --git a/shared/escrow-contract.ts b/shared/escrow-contract.ts new file mode 100644 index 0000000..52a4046 --- /dev/null +++ b/shared/escrow-contract.ts @@ -0,0 +1,173 @@ +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 { + label: string + amount: string +} + +export interface CreateGigEscrowInput { + /** 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 + * doesn't depend on a specific wallet. + */ +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 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 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 { + 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.depositor) + const contract = new Contract(ESCROW_CONTRACT_ID) + + const operation = contract.call( + 'init_escrow', + Address.fromString(input.depositor).toScVal(), + Address.fromString(input.beneficiary).toScVal(), + nativeToScVal(input.milestones.map(milestoneToScVal)) + ) + + const transaction = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation(operation) + .setTimeout(30) + .build() + + 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(describeContractError(`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) { + const escrowId = result.returnValue ? String(scValToNative(result.returnValue)) : '' + return { escrowId, txHash: 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') +}