diff --git a/.gitignore b/.gitignore index 39532d3..fcdd599 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ yarn-error.log* *.sublime-workspace # Local scripts output + +# Auto-generated contract bindings (regenerated via npm run codegen) +/shared/contracts-gen diff --git a/README.md b/README.md index d213dbf..acc9d0a 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/components/molecules/deposits/index.tsx b/components/molecules/deposits/index.tsx index 3fa58f2..0fa0943 100644 --- a/components/molecules/deposits/index.tsx +++ b/components/molecules/deposits/index.tsx @@ -1,10 +1,13 @@ -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 } @@ -12,12 +15,18 @@ export interface IDepositsProps { /** * 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(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 diff --git a/components/molecules/dispute-event-feed/index.tsx b/components/molecules/dispute-event-feed/index.tsx index 9249f63..089a4a9 100644 --- a/components/molecules/dispute-event-feed/index.tsx +++ b/components/molecules/dispute-event-feed/index.tsx @@ -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 + + 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 } @@ -67,6 +84,9 @@ export function DisputeEventFeed() {
  • {eventTitle(event)}

    + {formatEventData(event) && ( +

    {formatEventData(event)}

    + )}

    {event.source} ยท ledger {event.ledger} ยท tx {truncate(event.txHash)}

    diff --git a/components/molecules/form-pledge/index.tsx b/components/molecules/form-pledge/index.tsx index 2cd0b5b..30ab3ba 100644 --- a/components/molecules/form-pledge/index.tsx +++ b/components/molecules/form-pledge/index.tsx @@ -2,6 +2,7 @@ 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' @@ -9,6 +10,8 @@ export interface IFormPledgeProps { account: string decimals: number symbol?: string + gigId?: Buffer + milestoneIndex?: number onPledge: () => void updatedAt: number } @@ -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 = props => { const [balance] = React.useState(BigInt(0)) @@ -36,6 +40,8 @@ const FormPledge: FunctionComponent = props => { const [input, setInput] = useState('') const [isSubmitting, setSubmitting] = useState(false) + const escrow = useEscrowContract() + const clearInput = (): void => { setInput('') } @@ -44,8 +50,12 @@ const FormPledge: FunctionComponent = 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('') diff --git a/hooks/index.ts b/hooks/index.ts index cca8547..e447294 100644 --- a/hooks/index.ts +++ b/hooks/index.ts @@ -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"; diff --git a/hooks/useDisputeContract.ts b/hooks/useDisputeContract.ts new file mode 100644 index 0000000..0bc9eaa --- /dev/null +++ b/hooks/useDisputeContract.ts @@ -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 + submitEvidence: (disputeId: Buffer, evidenceUri: string) => Promise + vote: (disputeId: Buffer, inFavor: boolean) => Promise + resolve: (disputeId: Buffer) => Promise + getDispute: (disputeId: Buffer) => Promise + 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(null) + const isReady = !!DISPUTE_CONTRACT_ID + + const contract = isReady ? createDisputeContract() : null + + const withErrorHandling = useCallback( + (fn: () => Promise): Promise => { + 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, + } +} diff --git a/hooks/useEscrowContract.ts b/hooks/useEscrowContract.ts new file mode 100644 index 0000000..65604b6 --- /dev/null +++ b/hooks/useEscrowContract.ts @@ -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 + release: (gigId: Buffer, milestoneIndex: number) => Promise + refund: (gigId: Buffer, milestoneIndex: number) => Promise + getBalance: (gigId: Buffer, milestoneIndex: number) => Promise + getMilestones: (gigId: Buffer) => Promise + 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(null) + const isReady = !!ESCROW_CONTRACT_ID + + const contract = isReady ? createEscrowContract() : null + + const withErrorHandling = useCallback( + (fn: () => Promise): Promise => { + 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, + } +} diff --git a/package.json b/package.json index bff0ca6..bb6021d 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build", + "predev": "npm run codegen", + "build": "npm run codegen && next build", "start": "next start", "lint": "next lint", "typecheck": "tsc --noEmit", @@ -15,6 +16,7 @@ "bindings:crowdfund": "./target/bin/soroban contract bindings typescript --wasm ./target/wasm32-unknown-unknown/release/soroban_crowdfund_contract.wasm --id $(cat ./.soroban-example-dapp/crowdfund_id) --output-dir ./.soroban-example-dapp/crowdfund-contract --network $(cat ./.soroban-example-dapp/network) --overwrite", "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", "convert:assets": "node scripts/convert-to-webp.js" }, "dependencies": { diff --git a/scripts/generate-contract-bindings.js b/scripts/generate-contract-bindings.js new file mode 100644 index 0000000..86d9db2 --- /dev/null +++ b/scripts/generate-contract-bindings.js @@ -0,0 +1,247 @@ +#!/usr/bin/env node +/** + * Soroban Contract Bindings Codegen + * + * Reads Soroban contract spec JSON files and generates fully typed + * TypeScript client bindings for compile-time checked contract calls. + * + * Usage: node scripts/generate-contract-bindings.js + * npm run codegen + */ +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') + +// Soroban native types to TypeScript mappings +const TYPE_MAP = { + Void: 'void', + Bool: 'boolean', + U32: 'number', + U64: 'bigint', + I32: 'number', + I128: 'bigint', + String: 'string', + Bytes: 'Buffer', + Address: 'string', +} + +function mapType(typeDef, localTypes) { + if (!typeDef || !typeDef.type) return 'unknown' + + const raw = typeDef.type + + if (TYPE_MAP[raw]) return TYPE_MAP[raw] + + const vecMatch = raw.match(/^Vec<(.+)>$/) + if (vecMatch) { + return `${mapType({ type: vecMatch[1] }, localTypes)}[]` + } + + const optionMatch = raw.match(/^Option<(.+)>$/) + if (optionMatch) { + return `${mapType({ type: optionMatch[1] }, localTypes)} | null` + } + + const bytesNMatch = raw.match(/^BytesN<(\d+)>$/) + if (bytesNMatch) { + return 'Buffer' + } + + if (localTypes && localTypes[raw]) { + return raw + } + + return 'unknown' +} + +function generateTypeInterface(name, typeDef, localTypes) { + if (typeDef.variants) { + const variants = typeDef.variants.map(v => ` '${v}'`).join(' |\n') + return `export type ${name} =\n${variants}\n` + } + + if (typeDef.fields) { + const fields = Object.entries(typeDef.fields) + .map(([field, def]) => ` ${field}: ${mapType(def, localTypes)}`) + .join('\n') + return `export interface ${name} {\n${fields}\n}\n` + } + + return '' +} + +function generateMethodArgs(args, localTypes) { + if (!args || Object.keys(args).length === 0) return '' + return Object.entries(args) + .map(([name, def]) => `${name}: ${mapType(def, localTypes)}`) + .join(', ') +} + +function generateMethod(name, methodDef, localTypes) { + const args = generateMethodArgs(methodDef.args, localTypes) + const resultType = methodDef.result ? mapType(methodDef.result, localTypes) : 'void' + const jsDoc = methodDef.description ? ` /** ${methodDef.description} */\n` : '' + + return `${jsDoc} ${name}(${args}): Promise<${resultType}>` +} + +function generateEventDataType(name, dataDef, localTypes) { + if (!dataDef || Object.keys(dataDef).length === 0) return 'void' + const fields = Object.entries(dataDef) + .map(([field, def]) => ` ${field}: ${mapType(def, localTypes)}`) + .join('\n') + return `{\n${fields}\n}` +} + +function generateBindings(spec) { + const lines = [] + const contractName = spec.contract.charAt(0).toUpperCase() + spec.contract.slice(1) + + lines.push(`/**`) + lines.push(` * Auto-generated ${contractName} contract bindings`) + lines.push(` * Generated from Soroban contract spec โ€” do not edit manually`) + lines.push(` */`) + lines.push(`import { Contract, rpc, xdr, TransactionBuilder, Networks } from '@stellar/stellar-sdk'`) + lines.push(`import { getSorobanServer } from '../soroban-rpc'`) + lines.push(`import { ${spec.contract.toUpperCase()}_CONTRACT_ID } from '../contracts'`) + lines.push(``) + + // Generate type definitions + lines.push(`// โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€`) + lines.push(``) + for (const [typeName, typeDef] of Object.entries(spec.types || {})) { + lines.push(generateTypeInterface(typeName, typeDef, spec.types)) + } + + // Generate event data types + if (spec.events) { + lines.push(`// โ”€โ”€ Event Data Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€`) + lines.push(``) + for (const [eventName, eventDef] of Object.entries(spec.events)) { + const dataType = generateEventDataType(eventName, eventDef.data, spec.types) + lines.push(`export interface ${contractName}${eventName.charAt(0).toUpperCase() + eventName.slice(1)}EventData ${dataType}`) + lines.push(``) + } + } + + // Generate contract client interface + lines.push(`// โ”€โ”€ Contract Interface โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€`) + lines.push(``) + lines.push(`export interface I${contractName}Contract {`) + for (const [methodName, methodDef] of Object.entries(spec.methods)) { + lines.push(generateMethod(methodName, methodDef, spec.types)) + } + lines.push(`}`) + + // Generate contract client implementation + lines.push(``) + lines.push(`// โ”€โ”€ Contract Client โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€`) + lines.push(``) + lines.push(`export function create${contractName}Contract(): I${contractName}Contract {`) + lines.push(` const server = getSorobanServer()`) + lines.push(` const contractId = ${spec.contract.toUpperCase()}_CONTRACT_ID`) + lines.push(` const contract = new Contract(contractId)`) + lines.push(``) + lines.push(` async function invoke(method: string, ...args: xdr.ScVal[]): Promise {`) + lines.push(` const sourceAccount = await server.getAccount(contractId)`) + lines.push(` const builtTx = new TransactionBuilder(sourceAccount, {`) + lines.push(` fee: '100',`) + lines.push(` networkPassphrase: Networks.TESTNET,`) + lines.push(` })`) + lines.push(` .addOperation(contract.call(method, ...args))`) + lines.push(` .setTimeout(30)`) + lines.push(` .build()`) + lines.push(` const simulation = await server.simulateTransaction(builtTx)`) + lines.push(` const assembledTx = rpc.assembleTransaction(builtTx, simulation)`) + lines.push(` const sendResponse = await server.sendTransaction(assembledTx.build())`) + lines.push(` const resultResponse = await server.getTransaction(sendResponse.hash)`) + lines.push(` if (resultResponse.status !== 'SUCCESS') {`) + lines.push(` throw new Error(\`Transaction failed: \${resultResponse.status}\`)`) + lines.push(` }`) + lines.push(` const retval = (resultResponse as any).result?.retval`) + lines.push(` if (!retval) return undefined as T`) + lines.push(` return retval as T`) + lines.push(` }`) + lines.push(``) + lines.push(` return {`) + + // Generate method implementations + for (const [methodName, methodDef] of Object.entries(spec.methods)) { + const args = Object.entries(methodDef.args || {}) + .map(([name, def]) => { + const tsType = mapType(def, spec.types) + return `${name}: ${tsType}` + }) + .join(', ') + + const scVals = Object.entries(methodDef.args || {}) + .map(([name, def]) => { + if (def.type === 'Address') return `xdr.ScVal.scvString(${name})` + if (def.type === 'I128') return `xdr.ScVal.scvI128(new xdr.Int128Parts({ hi: xdr.Int64.fromString(String(BigInt(${name}) >> 64n)), lo: xdr.Int64.fromString(String(BigInt(${name}) & 0xFFFFFFFFFFFFFFFFn)) }))` + if (def.type === 'U32') return `xdr.ScVal.scvU32(${name})` + if (def.type === 'U64') return `xdr.ScVal.scvU64(xdr.Int64.fromString(String(${name})))` + if (def.type === 'Bool') return `xdr.ScVal.scvBool(${name})` + if (def.type === 'String') return `xdr.ScVal.scvString(${name})` + if (def.type === 'BytesN<32>') return `xdr.ScVal.scvBytes(${name})` + return `xdr.ScVal.scvBytes(${name})` + }) + .join(', ') + + const resultType = methodDef.result ? mapType(methodDef.result, spec.types) : 'void' + + lines.push(` ${methodName}: (${args}): Promise<${resultType}> => {`) + lines.push(` return invoke<${resultType}>(`) + lines.push(` '${methodName}',`) + lines.push(` ${scVals || '// no args'}`) + lines.push(` )`) + lines.push(` },`) + lines.push(``) + } + + lines.push(` }`) + lines.push(`}`) + lines.push(``) + + return lines.join('\n') +} + +function main() { + const specFiles = fs.readdirSync(SPECS_DIR).filter(f => f.endsWith('.spec.json')) + + if (specFiles.length === 0) { + console.log('No spec files found in', SPECS_DIR) + process.exit(1) + } + + if (!fs.existsSync(OUTPUT_DIR)) { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }) + } + + const indexLines = [] + indexLines.push(`/**`) + indexLines.push(` * Auto-generated contract bindings barrel export`) + indexLines.push(` * Generated from Soroban contract specs โ€” do not edit manually`) + indexLines.push(` */`) + indexLines.push(``) + + for (const specFile of specFiles) { + const specPath = path.join(SPECS_DIR, specFile) + const spec = JSON.parse(fs.readFileSync(specPath, 'utf-8')) + + const output = generateBindings(spec) + const outputPath = path.join(OUTPUT_DIR, `${spec.contract}.ts`) + fs.writeFileSync(outputPath, output, 'utf-8') + console.log(`Generated: ${outputPath}`) + + indexLines.push(`export * from './${spec.contract}'`) + } + + const indexPath = path.join(OUTPUT_DIR, 'index.ts') + fs.writeFileSync(indexPath, indexLines.join('\n'), 'utf-8') + console.log(`Generated: ${indexPath}`) + console.log(`\nSuccessfully generated bindings for ${specFiles.length} contract(s)`) +} + +main() diff --git a/shared/contract-events/types.ts b/shared/contract-events/types.ts index 11b9c92..71972a1 100644 --- a/shared/contract-events/types.ts +++ b/shared/contract-events/types.ts @@ -6,6 +6,58 @@ */ export type ContractEventSource = 'escrow' | 'dispute' +/** + * Typed event data for escrow contract events. + * Import from shared/contracts-gen for full type safety. + */ +export interface EscrowEvent { + deposited: { + milestone_index: number + token: string + amount: bigint + depositor: string + } + released: { + milestone_index: number + freelancer: string + amount: bigint + } + refunded: { + milestone_index: number + client: string + amount: bigint + } +} + +/** + * Typed event data for dispute contract events. + * Import from shared/contracts-gen for full type safety. + */ +export interface DisputeEvent { + dispute_opened: { + dispute_id: Uint8Array + opener: string + reason: string + } + evidence_submitted: { + submitter: string + evidence_uri: string + } + vote_cast: { + voter: string + in_favor: boolean + } + dispute_resolved: { + outcome: string + votes_for: number + votes_against: number + } +} + +export type TypedContractEvent = + | { source: 'escrow'; type: keyof EscrowEvent; data: EscrowEvent[keyof EscrowEvent] } + | { source: 'dispute'; type: keyof DisputeEvent; data: DisputeEvent[keyof DisputeEvent] } + export interface ContractEvent { /** Unique + monotonically increasing per contract, used for dedup and as the resume cursor. */ id: string diff --git a/shared/contracts-gen/dispute.ts b/shared/contracts-gen/dispute.ts new file mode 100644 index 0000000..f785ba6 --- /dev/null +++ b/shared/contracts-gen/dispute.ts @@ -0,0 +1,139 @@ +/** + * Auto-generated Dispute contract bindings + * Generated from Soroban contract spec โ€” do not edit manually + */ +import { Contract, rpc, xdr, TransactionBuilder, Networks } from '@stellar/stellar-sdk' +import { getSorobanServer } from '../soroban-rpc' +import { DISPUTE_CONTRACT_ID } from '../contracts' + +// โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export interface Dispute { + id: Buffer + gig_id: Buffer + milestone_index: number + opener: string + reason: string + status: DisputeStatus + votes_for: number + votes_against: number + created_at: bigint + resolved_at: bigint | null +} + +export type DisputeStatus = + 'Open' | + 'Voting' | + 'Resolved' | + 'Executed' + +export type DisputeOutcome = + 'FreelancerWins' | + 'ClientWins' | + 'Split' + +// โ”€โ”€ Event Data Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export interface DisputeDispute_openedEventData { + dispute_id: Buffer + opener: string + reason: string +} + +export interface DisputeEvidence_submittedEventData { + submitter: string + evidence_uri: string +} + +export interface DisputeVote_castEventData { + voter: string + in_favor: boolean +} + +export interface DisputeDispute_resolvedEventData { + outcome: DisputeOutcome + votes_for: number + votes_against: number +} + +// โ”€โ”€ Contract Interface โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export interface IDisputeContract { + /** Open a dispute for a gig milestone */ + open_dispute(gig_id: Buffer, milestone_index: number, reason: string): Promise + /** Submit evidence for a dispute */ + submit_evidence(dispute_id: Buffer, evidence_uri: string): Promise + /** Cast a juror vote on a dispute */ + vote(dispute_id: Buffer, in_favor: boolean): Promise + /** Resolve a dispute and execute the outcome */ + resolve(dispute_id: Buffer): Promise + /** Get dispute details */ + get_dispute(dispute_id: Buffer): Promise +} + +// โ”€โ”€ Contract Client โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export function createDisputeContract(): IDisputeContract { + const server = getSorobanServer() + const contractId = DISPUTE_CONTRACT_ID + const contract = new Contract(contractId) + + async function invoke(method: string, ...args: xdr.ScVal[]): Promise { + const sourceAccount = await server.getAccount(contractId) + const builtTx = new TransactionBuilder(sourceAccount, { + fee: '100', + networkPassphrase: Networks.TESTNET, + }) + .addOperation(contract.call(method, ...args)) + .setTimeout(30) + .build() + const simulation = await server.simulateTransaction(builtTx) + const assembledTx = rpc.assembleTransaction(builtTx, simulation) + const sendResponse = await server.sendTransaction(assembledTx.build()) + const resultResponse = await server.getTransaction(sendResponse.hash) + if (resultResponse.status !== 'SUCCESS') { + throw new Error(`Transaction failed: ${resultResponse.status}`) + } + const retval = (resultResponse as any).result?.retval + if (!retval) return undefined as T + return retval as T + } + + return { + open_dispute: (gig_id: Buffer, milestone_index: number, reason: string): Promise => { + return invoke( + 'open_dispute', + xdr.ScVal.scvBytes(gig_id), xdr.ScVal.scvU32(milestone_index), xdr.ScVal.scvString(reason) + ) + }, + + submit_evidence: (dispute_id: Buffer, evidence_uri: string): Promise => { + return invoke( + 'submit_evidence', + xdr.ScVal.scvBytes(dispute_id), xdr.ScVal.scvString(evidence_uri) + ) + }, + + vote: (dispute_id: Buffer, in_favor: boolean): Promise => { + return invoke( + 'vote', + xdr.ScVal.scvBytes(dispute_id), xdr.ScVal.scvBool(in_favor) + ) + }, + + resolve: (dispute_id: Buffer): Promise => { + return invoke( + 'resolve', + xdr.ScVal.scvBytes(dispute_id) + ) + }, + + get_dispute: (dispute_id: Buffer): Promise => { + return invoke( + 'get_dispute', + xdr.ScVal.scvBytes(dispute_id) + ) + }, + + } +} diff --git a/shared/contracts-gen/escrow.ts b/shared/contracts-gen/escrow.ts new file mode 100644 index 0000000..8077568 --- /dev/null +++ b/shared/contracts-gen/escrow.ts @@ -0,0 +1,128 @@ +/** + * Auto-generated Escrow contract bindings + * Generated from Soroban contract spec โ€” do not edit manually + */ +import { Contract, rpc, xdr, TransactionBuilder, Networks } from '@stellar/stellar-sdk' +import { getSorobanServer } from '../soroban-rpc' +import { ESCROW_CONTRACT_ID } from '../contracts' + +// โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export interface Milestone { + index: number + amount: bigint + token: string + status: MilestoneStatus + freelancer: string +} + +export type MilestoneStatus = + 'Pending' | + 'Funded' | + 'Completed' | + 'Released' | + 'Disputed' | + 'Refunded' + +// โ”€โ”€ Event Data Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export interface EscrowDepositedEventData { + milestone_index: number + token: string + amount: bigint + depositor: string +} + +export interface EscrowReleasedEventData { + milestone_index: number + freelancer: string + amount: bigint +} + +export interface EscrowRefundedEventData { + milestone_index: number + client: string + amount: bigint +} + +// โ”€โ”€ Contract Interface โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export interface IEscrowContract { + /** Deposit funds into escrow for a gig milestone */ + deposit(gig_id: Buffer, milestone_index: number, token: string, amount: bigint): Promise + /** Release escrowed funds to the freelancer */ + release(gig_id: Buffer, milestone_index: number): Promise + /** Refund escrowed funds to the client */ + refund(gig_id: Buffer, milestone_index: number): Promise + /** Get the escrowed balance for a milestone */ + get_balance(gig_id: Buffer, milestone_index: number): Promise + /** Get all milestones for a gig */ + get_gig_milestones(gig_id: Buffer): Promise +} + +// โ”€โ”€ Contract Client โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export function createEscrowContract(): IEscrowContract { + const server = getSorobanServer() + const contractId = ESCROW_CONTRACT_ID + const contract = new Contract(contractId) + + async function invoke(method: string, ...args: xdr.ScVal[]): Promise { + const sourceAccount = await server.getAccount(contractId) + const builtTx = new TransactionBuilder(sourceAccount, { + fee: '100', + networkPassphrase: Networks.TESTNET, + }) + .addOperation(contract.call(method, ...args)) + .setTimeout(30) + .build() + const simulation = await server.simulateTransaction(builtTx) + const assembledTx = rpc.assembleTransaction(builtTx, simulation) + const sendResponse = await server.sendTransaction(assembledTx.build()) + const resultResponse = await server.getTransaction(sendResponse.hash) + if (resultResponse.status !== 'SUCCESS') { + throw new Error(`Transaction failed: ${resultResponse.status}`) + } + const retval = (resultResponse as any).result?.retval + if (!retval) return undefined as T + return retval as T + } + + return { + deposit: (gig_id: Buffer, milestone_index: number, token: string, amount: bigint): Promise => { + return invoke( + 'deposit', + xdr.ScVal.scvBytes(gig_id), xdr.ScVal.scvU32(milestone_index), xdr.ScVal.scvString(token), xdr.ScVal.scvI128(new xdr.Int128Parts({ hi: xdr.Int64.fromString(String(BigInt(amount) >> 64n)), lo: xdr.Int64.fromString(String(BigInt(amount) & 0xFFFFFFFFFFFFFFFFn)) })) + ) + }, + + release: (gig_id: Buffer, milestone_index: number): Promise => { + return invoke( + 'release', + xdr.ScVal.scvBytes(gig_id), xdr.ScVal.scvU32(milestone_index) + ) + }, + + refund: (gig_id: Buffer, milestone_index: number): Promise => { + return invoke( + 'refund', + xdr.ScVal.scvBytes(gig_id), xdr.ScVal.scvU32(milestone_index) + ) + }, + + get_balance: (gig_id: Buffer, milestone_index: number): Promise => { + return invoke( + 'get_balance', + xdr.ScVal.scvBytes(gig_id), xdr.ScVal.scvU32(milestone_index) + ) + }, + + get_gig_milestones: (gig_id: Buffer): Promise => { + return invoke( + 'get_gig_milestones', + xdr.ScVal.scvBytes(gig_id) + ) + }, + + } +} diff --git a/shared/contracts-gen/index.ts b/shared/contracts-gen/index.ts new file mode 100644 index 0000000..10ade70 --- /dev/null +++ b/shared/contracts-gen/index.ts @@ -0,0 +1,7 @@ +/** + * Auto-generated contract bindings barrel export + * Generated from Soroban contract specs โ€” do not edit manually + */ + +export * from './dispute' +export * from './escrow' \ No newline at end of file diff --git a/shared/contracts-raw/dispute.spec.json b/shared/contracts-raw/dispute.spec.json new file mode 100644 index 0000000..1ada941 --- /dev/null +++ b/shared/contracts-raw/dispute.spec.json @@ -0,0 +1,103 @@ +{ + "contract": "dispute", + "address_env": "NEXT_PUBLIC_DISPUTE_CONTRACT_ID", + "methods": { + "open_dispute": { + "description": "Open a dispute for a gig milestone", + "args": { + "gig_id": { "type": "BytesN<32>" }, + "milestone_index": { "type": "U32" }, + "reason": { "type": "String" } + }, + "result": { "type": "Void" }, + "event": "dispute_opened" + }, + "submit_evidence": { + "description": "Submit evidence for a dispute", + "args": { + "dispute_id": { "type": "BytesN<32>" }, + "evidence_uri": { "type": "String" } + }, + "result": { "type": "Void" }, + "event": "evidence_submitted" + }, + "vote": { + "description": "Cast a juror vote on a dispute", + "args": { + "dispute_id": { "type": "BytesN<32>" }, + "in_favor": { "type": "Bool" } + }, + "result": { "type": "Void" }, + "event": "vote_cast" + }, + "resolve": { + "description": "Resolve a dispute and execute the outcome", + "args": { + "dispute_id": { "type": "BytesN<32>" } + }, + "result": { "type": "DisputeOutcome" }, + "event": "dispute_resolved" + }, + "get_dispute": { + "description": "Get dispute details", + "args": { + "dispute_id": { "type": "BytesN<32>" } + }, + "result": { "type": "Dispute" } + } + }, + "types": { + "Dispute": { + "fields": { + "id": { "type": "BytesN<32>" }, + "gig_id": { "type": "BytesN<32>" }, + "milestone_index": { "type": "U32" }, + "opener": { "type": "Address" }, + "reason": { "type": "String" }, + "status": { "type": "DisputeStatus" }, + "votes_for": { "type": "U32" }, + "votes_against": { "type": "U32" }, + "created_at": { "type": "U64" }, + "resolved_at": { "type": "Option" } + } + }, + "DisputeStatus": { + "variants": ["Open", "Voting", "Resolved", "Executed"] + }, + "DisputeOutcome": { + "variants": ["FreelancerWins", "ClientWins", "Split"] + } + }, + "events": { + "dispute_opened": { + "topics": ["dispute_opened", "gig_id"], + "data": { + "dispute_id": { "type": "BytesN<32>" }, + "opener": { "type": "Address" }, + "reason": { "type": "String" } + } + }, + "evidence_submitted": { + "topics": ["evidence_submitted", "dispute_id"], + "data": { + "submitter": { "type": "Address" }, + "evidence_uri": { "type": "String" } + } + }, + "vote_cast": { + "topics": ["vote_cast", "dispute_id"], + "data": { + "voter": { "type": "Address" }, + "in_favor": { "type": "Bool" } + } + }, + "dispute_resolved": { + "topics": ["dispute_resolved", "dispute_id"], + "data": { + "outcome": { "type": "DisputeOutcome" }, + "votes_for": { "type": "U32" }, + "votes_against": { "type": "U32" } + } + } + } +} diff --git a/shared/contracts-raw/escrow.spec.json b/shared/contracts-raw/escrow.spec.json new file mode 100644 index 0000000..ac05739 --- /dev/null +++ b/shared/contracts-raw/escrow.spec.json @@ -0,0 +1,93 @@ +{ + "contract": "escrow", + "address_env": "NEXT_PUBLIC_ESCROW_CONTRACT_ID", + "methods": { + "deposit": { + "description": "Deposit funds into escrow for a gig milestone", + "args": { + "gig_id": { "type": "BytesN<32>" }, + "milestone_index": { "type": "U32" }, + "token": { "type": "Address" }, + "amount": { "type": "I128" } + }, + "result": { "type": "Void" }, + "event": "deposited" + }, + "release": { + "description": "Release escrowed funds to the freelancer", + "args": { + "gig_id": { "type": "BytesN<32>" }, + "milestone_index": { "type": "U32" } + }, + "result": { "type": "Void" }, + "event": "released" + }, + "refund": { + "description": "Refund escrowed funds to the client", + "args": { + "gig_id": { "type": "BytesN<32>" }, + "milestone_index": { "type": "U32" } + }, + "result": { "type": "Void" }, + "event": "refunded" + }, + "get_balance": { + "description": "Get the escrowed balance for a milestone", + "args": { + "gig_id": { "type": "BytesN<32>" }, + "milestone_index": { "type": "U32" } + }, + "result": { "type": "I128" } + }, + "get_gig_milestones": { + "description": "Get all milestones for a gig", + "args": { + "gig_id": { "type": "BytesN<32>" } + }, + "result": { + "type": "Vec" + } + } + }, + "types": { + "Milestone": { + "fields": { + "index": { "type": "U32" }, + "amount": { "type": "I128" }, + "token": { "type": "Address" }, + "status": { "type": "MilestoneStatus" }, + "freelancer": { "type": "Address" } + } + }, + "MilestoneStatus": { + "variants": ["Pending", "Funded", "Completed", "Released", "Disputed", "Refunded"] + } + }, + "events": { + "deposited": { + "topics": ["deposited", "gig_id"], + "data": { + "milestone_index": { "type": "U32" }, + "token": { "type": "Address" }, + "amount": { "type": "I128" }, + "depositor": { "type": "Address" } + } + }, + "released": { + "topics": ["released", "gig_id"], + "data": { + "milestone_index": { "type": "U32" }, + "freelancer": { "type": "Address" }, + "amount": { "type": "I128" } + } + }, + "refunded": { + "topics": ["refunded", "gig_id"], + "data": { + "milestone_index": { "type": "U32" }, + "client": { "type": "Address" }, + "amount": { "type": "I128" } + } + } + } +}