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 ( +
+ + + + + + + + + + + {milestones.map((m) => ( + + + + + + + ))} + +
#StatusAmountFreelancer
{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}>`) + } + } + } + }) + }) + }) +})