Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,6 @@ yarn-error.log*
*.sublime-workspace

# Local scripts output

# Auto-generated contract bindings (regenerated via npm run codegen)
/shared/contracts-gen
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,28 @@ Full page sections wired to on-chain data.
- The profile page fetches reputation, bio, and past gigs from `GET /api/profile`, which proxies to `PROFILE_API_BASE_URL` when configured and falls back to typed mock data for local/dev environments.
- No new runtime dependencies were added for this feature.

### Contract Bindings Codegen

TrustFlow uses a codegen pipeline to generate fully typed TypeScript client bindings from Soroban contract specs. This ensures all frontend contract calls are compile-time checked and stay in sync with the contract interface.

**How it works:**

1. Contract specs are defined as JSON files in `shared/contracts-raw/`
2. Running `npm run codegen` generates TypeScript bindings in `shared/contracts-gen/`
3. The `npm run build` and `npm run dev` commands automatically run codegen

**Adding a new contract:**

1. Create a spec file: `shared/contracts-raw/mycontract.spec.json`
2. Run `npm run codegen`
3. Import the generated client: `import { createMycontractContract } from '../shared/contracts-gen'`

**Generated files:**

- `shared/contracts-gen/escrow.ts` — Escrow contract types and client
- `shared/contracts-gen/dispute.ts` — Dispute contract types and client
- `shared/contracts-gen/index.ts` — Barrel export

---

## 🛡️ Security
Expand All @@ -205,11 +227,11 @@ Full page sections wired to on-chain data.
- [x] Gig explorer with search and filtering
- [x] Multi-step gig creation wizard
- [x] Responsive mobile-first design
- [x] **Type-Safe Contract Bindings**: Codegen pipeline for compile-time checked contract calls

### 🚧 In Progress

- [ ] **Wallet Integration (#21)**: Full Freighter API integration
- [ ] **Escrow SDK (#16)**: Connect gig creation to smart contracts
- [ ] **Profile Pages (#10)**: On-chain reputation and work history viewer

### 📋 Planned
Expand Down
19 changes: 14 additions & 5 deletions components/molecules/deposits/index.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,32 @@
import React from 'react'
import React, { useEffect, useState } from 'react'
import { Spacer } from '../../atoms/spacer'
import { Utils } from '../../../shared/utils'
import { useEscrowContract } from '../../../hooks'

export interface IDepositsProps {
address: string
decimals: number
gigId?: Buffer
milestoneIndex?: number
name?: string
symbol?: string
}

/**
* Deposits — shows the user's escrowed balance.
*
* TODO: fetch real balance from the TrustFlow contract once RPC integration
* is wired up in shared/contracts.ts.
* Fetches real balance from the escrow contract when configured.
* Falls back to zero if contract is not available.
*/
export function Deposits(props: IDepositsProps) {
// Placeholder — will be replaced with contract call
const balance = BigInt(0)
const [balance, setBalance] = useState<bigint>(BigInt(0))
const escrow = useEscrowContract()

useEffect(() => {
if (!escrow.isReady || !props.gigId || props.milestoneIndex === undefined) return

escrow.getBalance(props.gigId, props.milestoneIndex).then(setBalance).catch(() => {})
}, [escrow, props.gigId, props.milestoneIndex])

if (Number(balance) <= 0) {
return <React.Fragment />
Expand Down
22 changes: 21 additions & 1 deletion components/molecules/dispute-event-feed/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
import moment from 'moment'
import { useContractEvents } from '../../../hooks'
import { DISPUTE_CONTRACT_ID, ESCROW_CONTRACT_ID } from '../../../shared/contracts'
import type { ContractEvent } from '../../../shared/contract-events/types'
import type { ContractEvent, EscrowEvent, DisputeEvent } from '../../../shared/contract-events/types'

function eventTitle(event: ContractEvent): string {
const topLevel = event.topic[0]
return typeof topLevel === 'string' ? topLevel : `${event.source} event`
}

function formatEventData(event: ContractEvent): string {
if (!event.data || typeof event.data !== 'object') return ''

const data = event.data as Record<string, unknown>

if (event.source === 'escrow') {
if ('amount' in data) return `${data.amount} tokens`
if ('freelancer' in data) return `to ${data.freelancer}`
}
if (event.source === 'dispute') {
if ('reason' in data) return String(data.reason)
if ('evidence_uri' in data) return String(data.evidence_uri)
if ('outcome' in data) return String(data.outcome)
}
return ''
}

function truncate(value: string, size = 6): string {
return value.length > size * 2 ? `${value.slice(0, size)}…${value.slice(-size)}` : value
}
Expand Down Expand Up @@ -67,6 +84,9 @@ export function DisputeEventFeed() {
<li key={event.id} className="p-4 flex items-start justify-between gap-3">
<div>
<p className="font-medium text-gray-900 dark:text-white capitalize">{eventTitle(event)}</p>
{formatEventData(event) && (
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1">{formatEventData(event)}</p>
)}
<p className="text-xs text-gray-500 dark:text-gray-400">
<span className="uppercase">{event.source}</span> · ledger {event.ledger} · tx {truncate(event.txHash)}
</p>
Expand Down
18 changes: 14 additions & 4 deletions components/molecules/form-pledge/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ import React, { FunctionComponent, useState } from 'react'
import { AmountInput, Button, Checkbox } from '../../atoms'
import { TransactionModal } from '../../molecules/transaction-modal'
import { Utils } from '../../../shared/utils'
import { useEscrowContract } from '../../../hooks'
import styles from './style.module.css'
import { Spacer } from '../../atoms/spacer'

export interface IFormPledgeProps {
account: string
decimals: number
symbol?: string
gigId?: Buffer
milestoneIndex?: number
onPledge: () => void
updatedAt: number
}
Expand All @@ -23,8 +26,9 @@ export interface IResultSubmit {
/**
* FormPledge — escrow deposit form.
*
* TODO: replace the simulated submit with a real TrustFlow contract call
* once RPC integration is wired up in shared/contracts.ts.
* Uses the auto-generated escrow contract bindings for type-safe
* deposit calls. Falls back to simulated delay if contract is not
* configured.
*/
const FormPledge: FunctionComponent<IFormPledgeProps> = props => {
const [balance] = React.useState<BigInt>(BigInt(0))
Expand All @@ -36,6 +40,8 @@ const FormPledge: FunctionComponent<IFormPledgeProps> = props => {
const [input, setInput] = useState('')
const [isSubmitting, setSubmitting] = useState(false)

const escrow = useEscrowContract()

const clearInput = (): void => {
setInput('')
}
Expand All @@ -44,8 +50,12 @@ const FormPledge: FunctionComponent<IFormPledgeProps> = props => {
if (!amount) return
setSubmitting(true)
try {
// TODO: call TrustFlow contract deposit
await new Promise(r => setTimeout(r, 800)) // simulated delay
if (escrow.isReady && props.gigId && props.milestoneIndex !== undefined) {
const amountBigInt = BigInt(Math.floor(amount * 10 ** decimals))
await escrow.deposit(props.gigId, props.milestoneIndex, props.symbol ?? 'XLM', amountBigInt)
} else {
await new Promise(r => setTimeout(r, 800))
}
setResultSubmit({ status: 'success', value: String(amount), symbol })
props.onPledge()
setInput('')
Expand Down
2 changes: 2 additions & 0 deletions hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export * from "./useAccount";
export * from "./useDebouncedValue";
export * from "./useEscrowContract";
export * from "./useDisputeContract";
export * from "./useGigsExplorer";
export * from "./useWallet";
export * from "./useIsMounted";
Expand Down
100 changes: 100 additions & 0 deletions hooks/useDisputeContract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { useCallback, useState } from 'react'
import {
createDisputeContract,
type Dispute,
type DisputeOutcome,
type DisputeStatus,
} from '../shared/contracts-gen/dispute'
import { DISPUTE_CONTRACT_ID } from '../shared/contracts'

export type { Dispute, DisputeOutcome, DisputeStatus }

interface UseDisputeContractResult {
openDispute: (gigId: Buffer, milestoneIndex: number, reason: string) => Promise<void>
submitEvidence: (disputeId: Buffer, evidenceUri: string) => Promise<void>
vote: (disputeId: Buffer, inFavor: boolean) => Promise<void>
resolve: (disputeId: Buffer) => Promise<DisputeOutcome>
getDispute: (disputeId: Buffer) => Promise<Dispute>
isReady: boolean
error: string | null
clearError: () => void
}

/**
* React hook for interacting with the dispute contract.
*
* Provides type-safe methods for opening disputes, submitting evidence,
* voting, and resolving disputes. All methods return typed results that
* are compile-time checked against the contract spec.
*/
export function useDisputeContract(): UseDisputeContractResult {
const [error, setError] = useState<string | null>(null)
const isReady = !!DISPUTE_CONTRACT_ID

const contract = isReady ? createDisputeContract() : null

const withErrorHandling = useCallback(
<T,>(fn: () => Promise<T>): Promise<T> => {
setError(null)
return fn().catch((err) => {
const message = err instanceof Error ? err.message : 'Contract call failed'
setError(message)
throw err
})
},
[]
)

const openDispute = useCallback(
(gigId: Buffer, milestoneIndex: number, reason: string) => {
if (!contract) throw new Error('Dispute contract not configured')
return withErrorHandling(() => contract.open_dispute(gigId, milestoneIndex, reason))
},
[contract, withErrorHandling]
)

const submitEvidence = useCallback(
(disputeId: Buffer, evidenceUri: string) => {
if (!contract) throw new Error('Dispute contract not configured')
return withErrorHandling(() => contract.submit_evidence(disputeId, evidenceUri))
},
[contract, withErrorHandling]
)

const vote = useCallback(
(disputeId: Buffer, inFavor: boolean) => {
if (!contract) throw new Error('Dispute contract not configured')
return withErrorHandling(() => contract.vote(disputeId, inFavor))
},
[contract, withErrorHandling]
)

const resolve = useCallback(
(disputeId: Buffer) => {
if (!contract) throw new Error('Dispute contract not configured')
return withErrorHandling(() => contract.resolve(disputeId))
},
[contract, withErrorHandling]
)

const getDispute = useCallback(
(disputeId: Buffer) => {
if (!contract) throw new Error('Dispute contract not configured')
return withErrorHandling(() => contract.get_dispute(disputeId))
},
[contract, withErrorHandling]
)

const clearError = useCallback(() => setError(null), [])

return {
openDispute,
submitEvidence,
vote,
resolve,
getDispute,
isReady,
error,
clearError,
}
}
99 changes: 99 additions & 0 deletions hooks/useEscrowContract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { useCallback, useState } from 'react'
import {
createEscrowContract,
type Milestone,
type MilestoneStatus,
} from '../shared/contracts-gen/escrow'
import { ESCROW_CONTRACT_ID } from '../shared/contracts'

export type { Milestone, MilestoneStatus }

interface UseEscrowContractResult {
deposit: (gigId: Buffer, milestoneIndex: number, token: string, amount: bigint) => Promise<void>
release: (gigId: Buffer, milestoneIndex: number) => Promise<void>
refund: (gigId: Buffer, milestoneIndex: number) => Promise<void>
getBalance: (gigId: Buffer, milestoneIndex: number) => Promise<bigint>
getMilestones: (gigId: Buffer) => Promise<Milestone[]>
isReady: boolean
error: string | null
clearError: () => void
}

/**
* React hook for interacting with the escrow contract.
*
* Provides type-safe methods for depositing, releasing, and refunding
* escrowed funds. All methods return typed results that are compile-time
* checked against the contract spec.
*/
export function useEscrowContract(): UseEscrowContractResult {
const [error, setError] = useState<string | null>(null)
const isReady = !!ESCROW_CONTRACT_ID

const contract = isReady ? createEscrowContract() : null

const withErrorHandling = useCallback(
<T,>(fn: () => Promise<T>): Promise<T> => {
setError(null)
return fn().catch((err) => {
const message = err instanceof Error ? err.message : 'Contract call failed'
setError(message)
throw err
})
},
[]
)

const deposit = useCallback(
(gigId: Buffer, milestoneIndex: number, token: string, amount: bigint) => {
if (!contract) throw new Error('Escrow contract not configured')
return withErrorHandling(() => contract.deposit(gigId, milestoneIndex, token, amount))
},
[contract, withErrorHandling]
)

const release = useCallback(
(gigId: Buffer, milestoneIndex: number) => {
if (!contract) throw new Error('Escrow contract not configured')
return withErrorHandling(() => contract.release(gigId, milestoneIndex))
},
[contract, withErrorHandling]
)

const refund = useCallback(
(gigId: Buffer, milestoneIndex: number) => {
if (!contract) throw new Error('Escrow contract not configured')
return withErrorHandling(() => contract.refund(gigId, milestoneIndex))
},
[contract, withErrorHandling]
)

const getBalance = useCallback(
(gigId: Buffer, milestoneIndex: number) => {
if (!contract) throw new Error('Escrow contract not configured')
return withErrorHandling(() => contract.get_balance(gigId, milestoneIndex))
},
[contract, withErrorHandling]
)

const getMilestones = useCallback(
(gigId: Buffer) => {
if (!contract) throw new Error('Escrow contract not configured')
return withErrorHandling(() => contract.get_gig_milestones(gigId))
},
[contract, withErrorHandling]
)

const clearError = useCallback(() => setError(null), [])

return {
deposit,
release,
refund,
getBalance,
getMilestones,
isReady,
error,
clearError,
}
}
Loading
Loading