diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index efce1f5..e05edbf 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -37,6 +37,9 @@ jobs:
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
+ - name: Verify contract bindings are up-to-date
+ run: npm run codegen:validate
+
- name: Run linter
run: npm run lint
diff --git a/README.md b/README.md
index acc9d0a..5685d6b 100644
--- a/README.md
+++ b/README.md
@@ -44,6 +44,7 @@ TrustFlow Frontend is the Next.js web application that gives users a seamless in
- **`/`** - Landing page with feature showcase
- **`/dashboard`** - User dashboard (My Gigs)
- **`/dashboard/disputes`** - Active disputes
+- **`/dashboard/contracts`** - Contract bindings status and interaction
- **`/dashboard/profile`** - User profile and reputation
- **`/dashboard/settings`** - Account settings
- **`/explore`** - Browse available gigs
diff --git a/package.json b/package.json
index bb6021d..3bc3e77 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"bindings:abundance": "./target/bin/soroban contract bindings typescript --wasm ./target/wasm32-unknown-unknown/release/abundance_token.wasm --id $(cat ./.soroban-example-dapp/abundance_token_id) --output-dir ./.soroban-example-dapp/abundance-token --network $(cat ./.soroban-example-dapp/network) --overwrite",
"bindings": "npm run bindings:crowdfund && npm run bindings:abundance",
"codegen": "node scripts/generate-contract-bindings.js",
+ "codegen:validate": "npm run codegen && git diff --exit-code shared/contracts-gen/",
"convert:assets": "node scripts/convert-to-webp.js"
},
"dependencies": {
diff --git a/pages/dashboard.tsx b/pages/dashboard.tsx
index 3a015d5..b65673f 100644
--- a/pages/dashboard.tsx
+++ b/pages/dashboard.tsx
@@ -17,6 +17,7 @@ interface NavItem {
const NAV_ITEMS: NavItem[] = [
{ label: 'My Gigs', href: '/dashboard', icon: '💼', description: 'View and manage your active gigs' },
{ label: 'Disputes', href: '/dashboard/disputes', icon: '⚖️', description: 'Active dispute resolutions' },
+ { label: 'Contracts', href: '/dashboard/contracts', icon: '🔗', description: 'Contract bindings and interaction' },
{ label: 'Profile', href: '/dashboard/profile', icon: '👤', description: 'Your reputation and work history' },
{ label: 'Settings', href: '/dashboard/settings', icon: '⚙️', description: 'Account and preferences' },
]
diff --git a/pages/dashboard/contracts.tsx b/pages/dashboard/contracts.tsx
new file mode 100644
index 0000000..28b48bf
--- /dev/null
+++ b/pages/dashboard/contracts.tsx
@@ -0,0 +1,267 @@
+import { useState } from 'react'
+import Head from 'next/head'
+import { useEscrowContract, type Milestone } from '../../hooks/useEscrowContract'
+import { useDisputeContract, type Dispute } from '../../hooks/useDisputeContract'
+import { ESCROW_CONTRACT_ID, DISPUTE_CONTRACT_ID } from '../../shared/contracts'
+
+function StatusBadge({ ready, label }: { ready: boolean; label: string }) {
+ return (
+
+
+ {label}
+
+ )
+}
+
+function ContractCard({
+ name,
+ contractId,
+ ready,
+ children,
+}: {
+ name: string
+ contractId: string
+ ready: boolean
+ children: React.ReactNode
+}) {
+ return (
+
+
+
{name}
+
+
+
+
Contract ID
+
+ {contractId || 'Not set'}
+
+
+ {children}
+
+ )
+}
+
+function MilestoneTable({ milestones }: { milestones: Milestone[] }) {
+ if (milestones.length === 0) {
+ return No milestones found.
+ }
+
+ return (
+
+
+
+
+ | # |
+ Status |
+ Amount |
+ Freelancer |
+
+
+
+ {milestones.map((m) => (
+
+ | {m.index} |
+
+
+ {m.status}
+
+ |
+ {m.amount.toString()} |
+
+ {m.freelancer.slice(0, 8)}...{m.freelancer.slice(-4)}
+ |
+
+ ))}
+
+
+
+ )
+}
+
+function DisputeDetail({ dispute }: { dispute: Dispute }) {
+ return (
+
+
+ Status
+ {dispute.status}
+
+
+ Votes For
+ {dispute.votes_for}
+
+
+ Votes Against
+ {dispute.votes_against}
+
+
+ Reason
+ {dispute.reason}
+
+
+ )
+}
+
+export default function ContractsPage() {
+ const escrow = useEscrowContract()
+ const dispute = useDisputeContract()
+ const [gigIdInput, setGigIdInput] = useState('')
+ const [milestoneIndex, setMilestoneIndex] = useState('0')
+ const [disputeIdInput, setDisputeIdInput] = useState('')
+ const [milestones, setMilestones] = useState(null)
+ const [disputeDetail, setDisputeDetail] = useState(null)
+ const [loading, setLoading] = useState(false)
+
+ const handleFetchMilestones = async () => {
+ if (!gigIdInput) return
+ setLoading(true)
+ try {
+ const gigId = Buffer.from(gigIdInput, 'hex')
+ const result = await escrow.getMilestones(gigId)
+ setMilestones(result)
+ } catch {
+ setMilestones(null)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleFetchDispute = async () => {
+ if (!disputeIdInput) return
+ setLoading(true)
+ try {
+ const disputeId = Buffer.from(disputeIdInput, 'hex')
+ const result = await dispute.getDispute(disputeId)
+ setDisputeDetail(result)
+ } catch {
+ setDisputeDetail(null)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+ <>
+
+ Contracts | TrustFlow Dashboard
+
+
+
+
+
Contract Bindings
+
+ Type-safe interfaces for interacting with TrustFlow Soroban contracts.
+ Bindings are auto-generated from contract specs and compile-time checked.
+
+
+
+
+
+
+
+
+ setGigIdInput(e.target.value)}
+ placeholder="e.g. 0x1a2b3c..."
+ className="w-full text-sm font-mono px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+
+
+
+ setMilestoneIndex(e.target.value)}
+ min="0"
+ className="w-full text-sm px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+
+
+ {escrow.error && (
+
{escrow.error}
+ )}
+ {milestones !== null &&
}
+
+
+
+
+
+
+
+ setDisputeIdInput(e.target.value)}
+ placeholder="e.g. 0x4d5e6f..."
+ className="w-full text-sm font-mono px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+
+
+ {dispute.error && (
+
{dispute.error}
+ )}
+ {disputeDetail !== null &&
}
+
+
+
+
+
+
About Contract Bindings
+
+ -
+ •
+ Bindings are auto-generated from Soroban contract spec JSON files in
shared/contracts-raw/
+
+ -
+ •
+ All contract method calls, arguments, and return types are compile-time checked
+
+ -
+ •
+ Run
npm run codegen to regenerate after spec changes
+
+ -
+ •
+ Run
npm run codegen:validate to verify bindings are up-to-date
+
+
+
+
+ >
+ )
+}
diff --git a/scripts/__tests__/generate-contract-bindings.test.js b/scripts/__tests__/generate-contract-bindings.test.js
new file mode 100644
index 0000000..95b7f97
--- /dev/null
+++ b/scripts/__tests__/generate-contract-bindings.test.js
@@ -0,0 +1,124 @@
+const fs = require('fs')
+const path = require('path')
+
+const SPECS_DIR = path.resolve(__dirname, '../../shared/contracts-raw')
+const OUTPUT_DIR = path.resolve(__dirname, '../../shared/contracts-gen')
+
+describe('Contract Bindings Codegen', () => {
+ const specFiles = fs.readdirSync(SPECS_DIR).filter(f => f.endsWith('.spec.json'))
+
+ it('should have spec files', () => {
+ expect(specFiles.length).toBeGreaterThan(0)
+ })
+
+ specFiles.forEach(specFile => {
+ describe(specFile, () => {
+ let spec
+
+ beforeAll(() => {
+ const specPath = path.join(SPECS_DIR, specFile)
+ spec = JSON.parse(fs.readFileSync(specPath, 'utf-8'))
+ })
+
+ it('should have a valid contract name', () => {
+ expect(typeof spec.contract).toBe('string')
+ expect(spec.contract.length).toBeGreaterThan(0)
+ })
+
+ it('should have methods', () => {
+ expect(spec.methods).toBeDefined()
+ expect(typeof spec.methods).toBe('object')
+ expect(Object.keys(spec.methods).length).toBeGreaterThan(0)
+ })
+
+ it('should have valid method args', () => {
+ for (const [methodName, methodDef] of Object.entries(spec.methods)) {
+ expect(methodDef.args).toBeDefined()
+ expect(typeof methodDef.args).toBe('object')
+ }
+ })
+
+ it('should have valid types if defined', () => {
+ if (spec.types) {
+ expect(typeof spec.types).toBe('object')
+ for (const [typeName, typeDef] of Object.entries(spec.types)) {
+ expect(typeDef).toBeDefined()
+ }
+ }
+ })
+
+ it('should have generated bindings', () => {
+ const outputPath = path.join(OUTPUT_DIR, `${spec.contract}.ts`)
+ expect(fs.existsSync(outputPath)).toBe(true)
+ })
+ })
+ })
+
+ describe('Generated bindings', () => {
+ it('should have an index.ts barrel export', () => {
+ const indexPath = path.join(OUTPUT_DIR, 'index.ts')
+ expect(fs.existsSync(indexPath)).toBe(true)
+
+ const indexContent = fs.readFileSync(indexPath, 'utf-8')
+ specFiles.forEach(specFile => {
+ const specPath = path.join(SPECS_DIR, specFile)
+ const spec = JSON.parse(fs.readFileSync(specPath, 'utf-8'))
+ expect(indexContent).toContain(`export * from './${spec.contract}'`)
+ })
+ })
+
+ specFiles.forEach(specFile => {
+ const spec = JSON.parse(
+ fs.readFileSync(path.join(SPECS_DIR, specFile), 'utf-8')
+ )
+
+ it(`${spec.contract}.ts should export a create function`, () => {
+ const outputPath = path.join(OUTPUT_DIR, `${spec.contract}.ts`)
+ const content = fs.readFileSync(outputPath, 'utf-8')
+ const contractName = spec.contract.charAt(0).toUpperCase() + spec.contract.slice(1)
+ expect(content).toContain(`export function create${contractName}Contract()`)
+ })
+
+ it(`${spec.contract}.ts should export types for each method`, () => {
+ const outputPath = path.join(OUTPUT_DIR, `${spec.contract}.ts`)
+ const content = fs.readFileSync(outputPath, 'utf-8')
+ for (const [methodName] of Object.entries(spec.methods)) {
+ expect(content).toContain(methodName)
+ }
+ })
+
+ it(`${spec.contract}.ts should have correct TypeScript types`, () => {
+ const outputPath = path.join(OUTPUT_DIR, `${spec.contract}.ts`)
+ const content = fs.readFileSync(outputPath, 'utf-8')
+
+ const TYPE_MAP = {
+ Void: 'void',
+ Bool: 'boolean',
+ U32: 'number',
+ U64: 'bigint',
+ I32: 'number',
+ I128: 'bigint',
+ String: 'string',
+ Bytes: 'Buffer',
+ Address: 'string',
+ }
+
+ for (const [methodName, methodDef] of Object.entries(spec.methods)) {
+ for (const [argName, argDef] of Object.entries(methodDef.args)) {
+ const expectedType = TYPE_MAP[argDef.type]
+ if (expectedType && expectedType !== 'void') {
+ expect(content).toContain(`${argName}: ${expectedType}`)
+ }
+ }
+
+ if (methodDef.result && methodDef.result.type !== 'Void') {
+ const expectedType = TYPE_MAP[methodDef.result.type]
+ if (expectedType) {
+ expect(content).toContain(`Promise<${expectedType}>`)
+ }
+ }
+ }
+ })
+ })
+ })
+})