From b358a736809447662a0b0578249015b090bda1aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Crhemy-arc=E2=80=9D?= <“rhemaadzer@gmail.com”> Date: Tue, 21 Jul 2026 19:56:31 +0100 Subject: [PATCH 01/18] feat(codegen): add Soroban contract spec format and codegen script Add JSON-based contract spec format for describing Soroban contract interfaces (methods, args, return types, events, custom types). Add codegen script that reads specs and generates TypeScript bindings with compile-time checked contract calls and typed event data. --- scripts/generate-contract-bindings.js | 245 +++++++++++++++++++++++++ shared/contracts-raw/dispute.spec.json | 103 +++++++++++ shared/contracts-raw/escrow.spec.json | 93 ++++++++++ 3 files changed, 441 insertions(+) create mode 100644 scripts/generate-contract-bindings.js create mode 100644 shared/contracts-raw/dispute.spec.json create mode 100644 shared/contracts-raw/escrow.spec.json diff --git a/scripts/generate-contract-bindings.js b/scripts/generate-contract-bindings.js new file mode 100644 index 0000000..db99e69 --- /dev/null +++ b/scripts/generate-contract-bindings.js @@ -0,0 +1,245 @@ +#!/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: 'Uint8Array', + Address: 'string', +} + +function mapType(typeDef, localTypes) { + if (!typeDef || !typeDef.type) return 'unknown' + + const raw = typeDef.type + + // Check if it's a native Soroban type + if (TYPE_MAP[raw]) return TYPE_MAP[raw] + + // Check for generic types: Vec, Option, Map + 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 'Uint8Array' + } + + // Check if it's a local custom type + if (localTypes && localTypes[raw]) { + return raw + } + + return 'unknown' +} + +function generateTypeInterface(name, typeDef, localTypes) { + if (typeDef.variants) { + // Generate an enum/union type + 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, Address } 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 tx = await server.simulateTransaction(contract.call(method, ...args))`) + lines.push(` const result = rpc.assembleTransaction(contract.call(method, ...args), tx).toXDR()`) + lines.push(` const sendResponse = await server.sendTransaction(result)`) + 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 returnValue = contract.call(method, ...args).result?.val`) + lines.push(` return rpc.scValToNative(returnValue as xdr.ScVal) 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 `new Address(${name}).toScVal()` + 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(Uint8Array.from(${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) + } + + // Ensure output directory exists + 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 contractName = spec.contract.charAt(0).toUpperCase() + spec.contract.slice(1) + + 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}'`) + } + + // Generate barrel export + 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/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" } + } + } + } +} From ab69678997a7720f5b7352b91314af856914c802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Crhemy-arc=E2=80=9D?= <“rhemaadzer@gmail.com”> Date: Tue, 21 Jul 2026 19:56:41 +0100 Subject: [PATCH 02/18] feat(codegen): generate type-safe contract bindings from specs Generate TypeScript client bindings for escrow and dispute contracts. Each contract gets a typed interface, implementation, and event data types. Bindings are auto-generated from spec files and should not be edited manually. --- shared/contracts-gen/dispute.ts | 130 ++++++++++++++++++++++++++++++++ shared/contracts-gen/escrow.ts | 119 +++++++++++++++++++++++++++++ shared/contracts-gen/index.ts | 7 ++ 3 files changed, 256 insertions(+) create mode 100644 shared/contracts-gen/dispute.ts create mode 100644 shared/contracts-gen/escrow.ts create mode 100644 shared/contracts-gen/index.ts diff --git a/shared/contracts-gen/dispute.ts b/shared/contracts-gen/dispute.ts new file mode 100644 index 0000000..af40f4d --- /dev/null +++ b/shared/contracts-gen/dispute.ts @@ -0,0 +1,130 @@ +/** + * Auto-generated Dispute contract bindings + * Generated from Soroban contract spec — do not edit manually + */ +import { Contract, rpc, xdr, Address } from '@stellar/stellar-sdk' +import { getSorobanServer } from '../soroban-rpc' +import { DISPUTE_CONTRACT_ID } from '../contracts' + +// ── Types ────────────────────────────────────────────────────── + +export interface Dispute { + id: Uint8Array + gig_id: Uint8Array + 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: Uint8Array + 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: Uint8Array, milestone_index: number, reason: string): Promise + /** Submit evidence for a dispute */ + submit_evidence(dispute_id: Uint8Array, evidence_uri: string): Promise + /** Cast a juror vote on a dispute */ + vote(dispute_id: Uint8Array, in_favor: boolean): Promise + /** Resolve a dispute and execute the outcome */ + resolve(dispute_id: Uint8Array): Promise + /** Get dispute details */ + get_dispute(dispute_id: Uint8Array): 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 tx = await server.simulateTransaction(contract.call(method, ...args)) + const result = rpc.assembleTransaction(contract.call(method, ...args), tx).toXDR() + const sendResponse = await server.sendTransaction(result) + const resultResponse = await server.getTransaction(sendResponse.hash) + if (resultResponse.status !== 'SUCCESS') { + throw new Error(`Transaction failed: ${resultResponse.status}`) + } + const returnValue = contract.call(method, ...args).result?.val + return rpc.scValToNative(returnValue as xdr.ScVal) as T + } + + return { + open_dispute: (gig_id: Uint8Array, milestone_index: number, reason: string): Promise => { + return invoke( + 'open_dispute', + xdr.ScVal.scvBytes(Uint8Array.from(gig_id)), xdr.ScVal.scvU32(milestone_index), xdr.ScVal.scvString(reason) + ) + }, + + submit_evidence: (dispute_id: Uint8Array, evidence_uri: string): Promise => { + return invoke( + 'submit_evidence', + xdr.ScVal.scvBytes(Uint8Array.from(dispute_id)), xdr.ScVal.scvString(evidence_uri) + ) + }, + + vote: (dispute_id: Uint8Array, in_favor: boolean): Promise => { + return invoke( + 'vote', + xdr.ScVal.scvBytes(Uint8Array.from(dispute_id)), xdr.ScVal.scvBool(in_favor) + ) + }, + + resolve: (dispute_id: Uint8Array): Promise => { + return invoke( + 'resolve', + xdr.ScVal.scvBytes(Uint8Array.from(dispute_id)) + ) + }, + + get_dispute: (dispute_id: Uint8Array): Promise => { + return invoke( + 'get_dispute', + xdr.ScVal.scvBytes(Uint8Array.from(dispute_id)) + ) + }, + + } +} diff --git a/shared/contracts-gen/escrow.ts b/shared/contracts-gen/escrow.ts new file mode 100644 index 0000000..fb8ee7b --- /dev/null +++ b/shared/contracts-gen/escrow.ts @@ -0,0 +1,119 @@ +/** + * Auto-generated Escrow contract bindings + * Generated from Soroban contract spec — do not edit manually + */ +import { Contract, rpc, xdr, Address } 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: Uint8Array, milestone_index: number, token: string, amount: bigint): Promise + /** Release escrowed funds to the freelancer */ + release(gig_id: Uint8Array, milestone_index: number): Promise + /** Refund escrowed funds to the client */ + refund(gig_id: Uint8Array, milestone_index: number): Promise + /** Get the escrowed balance for a milestone */ + get_balance(gig_id: Uint8Array, milestone_index: number): Promise + /** Get all milestones for a gig */ + get_gig_milestones(gig_id: Uint8Array): 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 tx = await server.simulateTransaction(contract.call(method, ...args)) + const result = rpc.assembleTransaction(contract.call(method, ...args), tx).toXDR() + const sendResponse = await server.sendTransaction(result) + const resultResponse = await server.getTransaction(sendResponse.hash) + if (resultResponse.status !== 'SUCCESS') { + throw new Error(`Transaction failed: ${resultResponse.status}`) + } + const returnValue = contract.call(method, ...args).result?.val + return rpc.scValToNative(returnValue as xdr.ScVal) as T + } + + return { + deposit: (gig_id: Uint8Array, milestone_index: number, token: string, amount: bigint): Promise => { + return invoke( + 'deposit', + xdr.ScVal.scvBytes(Uint8Array.from(gig_id)), xdr.ScVal.scvU32(milestone_index), new Address(token).toScVal(), 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: Uint8Array, milestone_index: number): Promise => { + return invoke( + 'release', + xdr.ScVal.scvBytes(Uint8Array.from(gig_id)), xdr.ScVal.scvU32(milestone_index) + ) + }, + + refund: (gig_id: Uint8Array, milestone_index: number): Promise => { + return invoke( + 'refund', + xdr.ScVal.scvBytes(Uint8Array.from(gig_id)), xdr.ScVal.scvU32(milestone_index) + ) + }, + + get_balance: (gig_id: Uint8Array, milestone_index: number): Promise => { + return invoke( + 'get_balance', + xdr.ScVal.scvBytes(Uint8Array.from(gig_id)), xdr.ScVal.scvU32(milestone_index) + ) + }, + + get_gig_milestones: (gig_id: Uint8Array): Promise => { + return invoke( + 'get_gig_milestones', + xdr.ScVal.scvBytes(Uint8Array.from(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 From ec462a81cf843c5e6671671a57fac0478810c65b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Crhemy-arc=E2=80=9D?= <“rhemaadzer@gmail.com”> Date: Tue, 21 Jul 2026 19:57:16 +0100 Subject: [PATCH 03/18] feat(codegen): integrate codegen into build and dev scripts Add codegen script to package.json and wire it into build pipeline. Running npm run build or npm run dev now automatically regenerates contract bindings from specs before starting. --- package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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": { From 4aa65f255bb374988b6ae038d7f22a42547f282e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Crhemy-arc=E2=80=9D?= <“rhemaadzer@gmail.com”> Date: Tue, 21 Jul 2026 19:57:34 +0100 Subject: [PATCH 04/18] chore: exclude auto-generated contract bindings from version control Add shared/contracts-gen to .gitignore since it is regenerated from specs via npm run codegen. Keeps the repo clean and avoids merge conflicts on generated files. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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 From 7558434ee3d4e1603b5aa0eea73e73f0daab8935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Crhemy-arc=E2=80=9D?= <“rhemaadzer@gmail.com”> Date: Tue, 21 Jul 2026 19:58:19 +0100 Subject: [PATCH 05/18] feat(hooks): add type-safe contract interaction hooks Add useEscrowContract and useDisputeContract hooks that wrap the auto-generated contract bindings. These provide React-friendly methods with error handling and ready state for compile-time checked contract calls. --- hooks/index.ts | 2 + hooks/useDisputeContract.ts | 100 ++++++++++++++++++++++++++++++++++++ hooks/useEscrowContract.ts | 99 +++++++++++++++++++++++++++++++++++ 3 files changed, 201 insertions(+) create mode 100644 hooks/useDisputeContract.ts create mode 100644 hooks/useEscrowContract.ts 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..a4cc01d --- /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: Uint8Array, milestoneIndex: number, reason: string) => Promise + submitEvidence: (disputeId: Uint8Array, evidenceUri: string) => Promise + vote: (disputeId: Uint8Array, inFavor: boolean) => Promise + resolve: (disputeId: Uint8Array) => Promise + getDispute: (disputeId: Uint8Array) => 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: Uint8Array, 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: Uint8Array, evidenceUri: string) => { + if (!contract) throw new Error('Dispute contract not configured') + return withErrorHandling(() => contract.submit_evidence(disputeId, evidenceUri)) + }, + [contract, withErrorHandling] + ) + + const vote = useCallback( + (disputeId: Uint8Array, inFavor: boolean) => { + if (!contract) throw new Error('Dispute contract not configured') + return withErrorHandling(() => contract.vote(disputeId, inFavor)) + }, + [contract, withErrorHandling] + ) + + const resolve = useCallback( + (disputeId: Uint8Array) => { + if (!contract) throw new Error('Dispute contract not configured') + return withErrorHandling(() => contract.resolve(disputeId)) + }, + [contract, withErrorHandling] + ) + + const getDispute = useCallback( + (disputeId: Uint8Array) => { + 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..2db7215 --- /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: Uint8Array, milestoneIndex: number, token: string, amount: bigint) => Promise + release: (gigId: Uint8Array, milestoneIndex: number) => Promise + refund: (gigId: Uint8Array, milestoneIndex: number) => Promise + getBalance: (gigId: Uint8Array, milestoneIndex: number) => Promise + getMilestones: (gigId: Uint8Array) => 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: Uint8Array, 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: Uint8Array, milestoneIndex: number) => { + if (!contract) throw new Error('Escrow contract not configured') + return withErrorHandling(() => contract.release(gigId, milestoneIndex)) + }, + [contract, withErrorHandling] + ) + + const refund = useCallback( + (gigId: Uint8Array, milestoneIndex: number) => { + if (!contract) throw new Error('Escrow contract not configured') + return withErrorHandling(() => contract.refund(gigId, milestoneIndex)) + }, + [contract, withErrorHandling] + ) + + const getBalance = useCallback( + (gigId: Uint8Array, milestoneIndex: number) => { + if (!contract) throw new Error('Escrow contract not configured') + return withErrorHandling(() => contract.get_balance(gigId, milestoneIndex)) + }, + [contract, withErrorHandling] + ) + + const getMilestones = useCallback( + (gigId: Uint8Array) => { + 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, + } +} From b2effd7aec0d5bd5f60147aacbebfbf11088c183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Crhemy-arc=E2=80=9D?= <“rhemaadzer@gmail.com”> Date: Tue, 21 Jul 2026 19:58:53 +0100 Subject: [PATCH 06/18] feat(form-pledge): integrate typed escrow contract for deposits Update FormPledge to use useEscrowContract hook for type-safe deposit calls. Falls back to simulated delay when contract is not configured. Adds gigId and milestoneIndex props for contract interaction. --- components/molecules/form-pledge/index.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/components/molecules/form-pledge/index.tsx b/components/molecules/form-pledge/index.tsx index 2cd0b5b..af7d1f6 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?: Uint8Array + 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('') From 3f55480bdd2d5172bc6473b6d11840803acd6eeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Crhemy-arc=E2=80=9D?= <“rhemaadzer@gmail.com”> Date: Tue, 21 Jul 2026 19:59:27 +0100 Subject: [PATCH 07/18] feat(deposits): integrate typed escrow contract for balance fetching Update Deposits component to use useEscrowContract hook for fetching real escrowed balance. Adds gigId and milestoneIndex props. Falls back to zero when contract is not configured. --- components/molecules/deposits/index.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/components/molecules/deposits/index.tsx b/components/molecules/deposits/index.tsx index 3fa58f2..8cfcce8 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?: Uint8Array + 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 From 6672fec75337d46ae2eec652c8204d7594892bc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Crhemy-arc=E2=80=9D?= <“rhemaadzer@gmail.com”> Date: Tue, 21 Jul 2026 19:59:49 +0100 Subject: [PATCH 08/18] feat(types): add typed event data interfaces for contract events Add EscrowEvent and DisputeEvent interfaces that define the shape of event data for each contract. This enables compile-time checking of event handlers when consuming contract events. --- shared/contract-events/types.ts | 52 +++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) 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 From 806ae1d7ebcd38178e929b1c1f44cb34f1032750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Crhemy-arc=E2=80=9D?= <“rhemaadzer@gmail.com”> Date: Tue, 21 Jul 2026 20:00:28 +0100 Subject: [PATCH 09/18] feat(dispute-event-feed): display typed event data in feed Update DisputeEventFeed to display structured event data using the typed EscrowEvent and DisputeEvent interfaces. Shows relevant details like amounts, reasons, and outcomes for each event type. --- .../molecules/dispute-event-feed/index.tsx | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/components/molecules/dispute-event-feed/index.tsx b/components/molecules/dispute-event-feed/index.tsx index 9249f63..84bdcea 100644 --- a/components/molecules/dispute-event-feed/index.tsx +++ b/components/molecules/dispute-event-feed/index.tsx @@ -1,13 +1,38 @@ 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.source === 'escrow' && event.data) { + const data = event.data as EscrowEvent[keyof EscrowEvent] + if ('amount' in data) { + return `${data.amount} tokens` + } + if ('freelancer' in data) { + return `to ${data.freelancer}` + } + } + if (event.source === 'dispute' && event.data) { + const data = event.data as DisputeEvent[keyof DisputeEvent] + if ('reason' in data) { + return data.reason + } + if ('evidence_uri' in data) { + return data.evidence_uri + } + if ('outcome' in data) { + return 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 +92,9 @@ export function DisputeEventFeed() {
  • {eventTitle(event)}

    + {formatEventData(event) && ( +

    {formatEventData(event)}

    + )}

    {event.source} · ledger {event.ledger} · tx {truncate(event.txHash)}

    From d996e172e533848dc1494698d253fd25dad57864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Crhemy-arc=E2=80=9D?= <“rhemaadzer@gmail.com”> Date: Tue, 21 Jul 2026 20:00:59 +0100 Subject: [PATCH 10/18] docs: document contract bindings codegen in README Add Architecture Notes section explaining the codegen pipeline, how to add new contracts, and generated file locations. Update Roadmap to mark Type-Safe Contract Bindings as completed. --- README.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) 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 From 431d8bc9017c621d1e32ffcf4ed9270c52c37eaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Crhemy-arc=E2=80=9D?= <“rhemaadzer@gmail.com”> Date: Tue, 21 Jul 2026 20:10:37 +0100 Subject: [PATCH 11/18] fix(codegen): fix TypeScript errors in generated bindings Fix several TypeScript compilation errors: - Use correct Stellar SDK imports (TransactionBuilder, Networks) - Fix xdr import to be value import, not type import - Use correct Address encoding approach - Fix assembleTransaction to work with Transaction objects - Update hooks to use Buffer instead of Uint8Array for compatibility --- components/molecules/deposits/index.tsx | 2 +- .../molecules/dispute-event-feed/index.tsx | 30 +++----- components/molecules/form-pledge/index.tsx | 2 +- hooks/useDisputeContract.ts | 20 +++--- hooks/useEscrowContract.ts | 20 +++--- scripts/generate-contract-bindings.js | 38 +++++----- shared/contracts-gen/dispute.ts | 71 +++++++++++-------- shared/contracts-gen/escrow.ts | 63 +++++++++------- 8 files changed, 129 insertions(+), 117 deletions(-) diff --git a/components/molecules/deposits/index.tsx b/components/molecules/deposits/index.tsx index 8cfcce8..0fa0943 100644 --- a/components/molecules/deposits/index.tsx +++ b/components/molecules/deposits/index.tsx @@ -6,7 +6,7 @@ import { useEscrowContract } from '../../../hooks' export interface IDepositsProps { address: string decimals: number - gigId?: Uint8Array + gigId?: Buffer milestoneIndex?: number name?: string symbol?: string diff --git a/components/molecules/dispute-event-feed/index.tsx b/components/molecules/dispute-event-feed/index.tsx index 84bdcea..089a4a9 100644 --- a/components/molecules/dispute-event-feed/index.tsx +++ b/components/molecules/dispute-event-feed/index.tsx @@ -9,26 +9,18 @@ function eventTitle(event: ContractEvent): string { } function formatEventData(event: ContractEvent): string { - if (event.source === 'escrow' && event.data) { - const data = event.data as EscrowEvent[keyof EscrowEvent] - if ('amount' in data) { - return `${data.amount} tokens` - } - if ('freelancer' in data) { - return `to ${data.freelancer}` - } + 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' && event.data) { - const data = event.data as DisputeEvent[keyof DisputeEvent] - if ('reason' in data) { - return data.reason - } - if ('evidence_uri' in data) { - return data.evidence_uri - } - if ('outcome' in data) { - return data.outcome - } + 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 '' } diff --git a/components/molecules/form-pledge/index.tsx b/components/molecules/form-pledge/index.tsx index af7d1f6..30ab3ba 100644 --- a/components/molecules/form-pledge/index.tsx +++ b/components/molecules/form-pledge/index.tsx @@ -10,7 +10,7 @@ export interface IFormPledgeProps { account: string decimals: number symbol?: string - gigId?: Uint8Array + gigId?: Buffer milestoneIndex?: number onPledge: () => void updatedAt: number diff --git a/hooks/useDisputeContract.ts b/hooks/useDisputeContract.ts index a4cc01d..0bc9eaa 100644 --- a/hooks/useDisputeContract.ts +++ b/hooks/useDisputeContract.ts @@ -10,11 +10,11 @@ import { DISPUTE_CONTRACT_ID } from '../shared/contracts' export type { Dispute, DisputeOutcome, DisputeStatus } interface UseDisputeContractResult { - openDispute: (gigId: Uint8Array, milestoneIndex: number, reason: string) => Promise - submitEvidence: (disputeId: Uint8Array, evidenceUri: string) => Promise - vote: (disputeId: Uint8Array, inFavor: boolean) => Promise - resolve: (disputeId: Uint8Array) => Promise - getDispute: (disputeId: Uint8Array) => Promise + 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 @@ -46,7 +46,7 @@ export function useDisputeContract(): UseDisputeContractResult { ) const openDispute = useCallback( - (gigId: Uint8Array, milestoneIndex: number, reason: string) => { + (gigId: Buffer, milestoneIndex: number, reason: string) => { if (!contract) throw new Error('Dispute contract not configured') return withErrorHandling(() => contract.open_dispute(gigId, milestoneIndex, reason)) }, @@ -54,7 +54,7 @@ export function useDisputeContract(): UseDisputeContractResult { ) const submitEvidence = useCallback( - (disputeId: Uint8Array, evidenceUri: string) => { + (disputeId: Buffer, evidenceUri: string) => { if (!contract) throw new Error('Dispute contract not configured') return withErrorHandling(() => contract.submit_evidence(disputeId, evidenceUri)) }, @@ -62,7 +62,7 @@ export function useDisputeContract(): UseDisputeContractResult { ) const vote = useCallback( - (disputeId: Uint8Array, inFavor: boolean) => { + (disputeId: Buffer, inFavor: boolean) => { if (!contract) throw new Error('Dispute contract not configured') return withErrorHandling(() => contract.vote(disputeId, inFavor)) }, @@ -70,7 +70,7 @@ export function useDisputeContract(): UseDisputeContractResult { ) const resolve = useCallback( - (disputeId: Uint8Array) => { + (disputeId: Buffer) => { if (!contract) throw new Error('Dispute contract not configured') return withErrorHandling(() => contract.resolve(disputeId)) }, @@ -78,7 +78,7 @@ export function useDisputeContract(): UseDisputeContractResult { ) const getDispute = useCallback( - (disputeId: Uint8Array) => { + (disputeId: Buffer) => { if (!contract) throw new Error('Dispute contract not configured') return withErrorHandling(() => contract.get_dispute(disputeId)) }, diff --git a/hooks/useEscrowContract.ts b/hooks/useEscrowContract.ts index 2db7215..65604b6 100644 --- a/hooks/useEscrowContract.ts +++ b/hooks/useEscrowContract.ts @@ -9,11 +9,11 @@ import { ESCROW_CONTRACT_ID } from '../shared/contracts' export type { Milestone, MilestoneStatus } interface UseEscrowContractResult { - deposit: (gigId: Uint8Array, milestoneIndex: number, token: string, amount: bigint) => Promise - release: (gigId: Uint8Array, milestoneIndex: number) => Promise - refund: (gigId: Uint8Array, milestoneIndex: number) => Promise - getBalance: (gigId: Uint8Array, milestoneIndex: number) => Promise - getMilestones: (gigId: Uint8Array) => Promise + 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 @@ -45,7 +45,7 @@ export function useEscrowContract(): UseEscrowContractResult { ) const deposit = useCallback( - (gigId: Uint8Array, milestoneIndex: number, token: string, amount: bigint) => { + (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)) }, @@ -53,7 +53,7 @@ export function useEscrowContract(): UseEscrowContractResult { ) const release = useCallback( - (gigId: Uint8Array, milestoneIndex: number) => { + (gigId: Buffer, milestoneIndex: number) => { if (!contract) throw new Error('Escrow contract not configured') return withErrorHandling(() => contract.release(gigId, milestoneIndex)) }, @@ -61,7 +61,7 @@ export function useEscrowContract(): UseEscrowContractResult { ) const refund = useCallback( - (gigId: Uint8Array, milestoneIndex: number) => { + (gigId: Buffer, milestoneIndex: number) => { if (!contract) throw new Error('Escrow contract not configured') return withErrorHandling(() => contract.refund(gigId, milestoneIndex)) }, @@ -69,7 +69,7 @@ export function useEscrowContract(): UseEscrowContractResult { ) const getBalance = useCallback( - (gigId: Uint8Array, milestoneIndex: number) => { + (gigId: Buffer, milestoneIndex: number) => { if (!contract) throw new Error('Escrow contract not configured') return withErrorHandling(() => contract.get_balance(gigId, milestoneIndex)) }, @@ -77,7 +77,7 @@ export function useEscrowContract(): UseEscrowContractResult { ) const getMilestones = useCallback( - (gigId: Uint8Array) => { + (gigId: Buffer) => { if (!contract) throw new Error('Escrow contract not configured') return withErrorHandling(() => contract.get_gig_milestones(gigId)) }, diff --git a/scripts/generate-contract-bindings.js b/scripts/generate-contract-bindings.js index db99e69..86d9db2 100644 --- a/scripts/generate-contract-bindings.js +++ b/scripts/generate-contract-bindings.js @@ -23,7 +23,7 @@ const TYPE_MAP = { I32: 'number', I128: 'bigint', String: 'string', - Bytes: 'Uint8Array', + Bytes: 'Buffer', Address: 'string', } @@ -32,10 +32,8 @@ function mapType(typeDef, localTypes) { const raw = typeDef.type - // Check if it's a native Soroban type if (TYPE_MAP[raw]) return TYPE_MAP[raw] - // Check for generic types: Vec, Option, Map const vecMatch = raw.match(/^Vec<(.+)>$/) if (vecMatch) { return `${mapType({ type: vecMatch[1] }, localTypes)}[]` @@ -48,10 +46,9 @@ function mapType(typeDef, localTypes) { const bytesNMatch = raw.match(/^BytesN<(\d+)>$/) if (bytesNMatch) { - return 'Uint8Array' + return 'Buffer' } - // Check if it's a local custom type if (localTypes && localTypes[raw]) { return raw } @@ -61,8 +58,7 @@ function mapType(typeDef, localTypes) { function generateTypeInterface(name, typeDef, localTypes) { if (typeDef.variants) { - // Generate an enum/union type - const variants = typeDef.variants.map(v => ` ${v}`).join(' |\n') + const variants = typeDef.variants.map(v => ` '${v}'`).join(' |\n') return `export type ${name} =\n${variants}\n` } @@ -107,7 +103,7 @@ function generateBindings(spec) { 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, Address } from '@stellar/stellar-sdk'`) + 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(``) @@ -149,15 +145,24 @@ function generateBindings(spec) { lines.push(` const contract = new Contract(contractId)`) lines.push(``) lines.push(` async function invoke(method: string, ...args: xdr.ScVal[]): Promise {`) - lines.push(` const tx = await server.simulateTransaction(contract.call(method, ...args))`) - lines.push(` const result = rpc.assembleTransaction(contract.call(method, ...args), tx).toXDR()`) - lines.push(` const sendResponse = await server.sendTransaction(result)`) + 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 returnValue = contract.call(method, ...args).result?.val`) - lines.push(` return rpc.scValToNative(returnValue as xdr.ScVal) as T`) + 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 {`) @@ -173,13 +178,13 @@ function generateBindings(spec) { const scVals = Object.entries(methodDef.args || {}) .map(([name, def]) => { - if (def.type === 'Address') return `new Address(${name}).toScVal()` + 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(Uint8Array.from(${name}))` + if (def.type === 'BytesN<32>') return `xdr.ScVal.scvBytes(${name})` return `xdr.ScVal.scvBytes(${name})` }) .join(', ') @@ -210,7 +215,6 @@ function main() { process.exit(1) } - // Ensure output directory exists if (!fs.existsSync(OUTPUT_DIR)) { fs.mkdirSync(OUTPUT_DIR, { recursive: true }) } @@ -225,7 +229,6 @@ function main() { for (const specFile of specFiles) { const specPath = path.join(SPECS_DIR, specFile) const spec = JSON.parse(fs.readFileSync(specPath, 'utf-8')) - const contractName = spec.contract.charAt(0).toUpperCase() + spec.contract.slice(1) const output = generateBindings(spec) const outputPath = path.join(OUTPUT_DIR, `${spec.contract}.ts`) @@ -235,7 +238,6 @@ function main() { indexLines.push(`export * from './${spec.contract}'`) } - // Generate barrel export const indexPath = path.join(OUTPUT_DIR, 'index.ts') fs.writeFileSync(indexPath, indexLines.join('\n'), 'utf-8') console.log(`Generated: ${indexPath}`) diff --git a/shared/contracts-gen/dispute.ts b/shared/contracts-gen/dispute.ts index af40f4d..f785ba6 100644 --- a/shared/contracts-gen/dispute.ts +++ b/shared/contracts-gen/dispute.ts @@ -2,15 +2,15 @@ * Auto-generated Dispute contract bindings * Generated from Soroban contract spec — do not edit manually */ -import { Contract, rpc, xdr, Address } from '@stellar/stellar-sdk' +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: Uint8Array - gig_id: Uint8Array + id: Buffer + gig_id: Buffer milestone_index: number opener: string reason: string @@ -22,20 +22,20 @@ export interface Dispute { } export type DisputeStatus = - Open | - Voting | - Resolved | - Executed + 'Open' | + 'Voting' | + 'Resolved' | + 'Executed' export type DisputeOutcome = - FreelancerWins | - ClientWins | - Split + 'FreelancerWins' | + 'ClientWins' | + 'Split' // ── Event Data Types ─────────────────────────────────────────── export interface DisputeDispute_openedEventData { - dispute_id: Uint8Array + dispute_id: Buffer opener: string reason: string } @@ -60,15 +60,15 @@ export interface DisputeDispute_resolvedEventData { export interface IDisputeContract { /** Open a dispute for a gig milestone */ - open_dispute(gig_id: Uint8Array, milestone_index: number, reason: string): Promise + open_dispute(gig_id: Buffer, milestone_index: number, reason: string): Promise /** Submit evidence for a dispute */ - submit_evidence(dispute_id: Uint8Array, evidence_uri: string): Promise + submit_evidence(dispute_id: Buffer, evidence_uri: string): Promise /** Cast a juror vote on a dispute */ - vote(dispute_id: Uint8Array, in_favor: boolean): Promise + vote(dispute_id: Buffer, in_favor: boolean): Promise /** Resolve a dispute and execute the outcome */ - resolve(dispute_id: Uint8Array): Promise + resolve(dispute_id: Buffer): Promise /** Get dispute details */ - get_dispute(dispute_id: Uint8Array): Promise + get_dispute(dispute_id: Buffer): Promise } // ── Contract Client ───────────────────────────────────────────── @@ -79,50 +79,59 @@ export function createDisputeContract(): IDisputeContract { const contract = new Contract(contractId) async function invoke(method: string, ...args: xdr.ScVal[]): Promise { - const tx = await server.simulateTransaction(contract.call(method, ...args)) - const result = rpc.assembleTransaction(contract.call(method, ...args), tx).toXDR() - const sendResponse = await server.sendTransaction(result) + 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 returnValue = contract.call(method, ...args).result?.val - return rpc.scValToNative(returnValue as xdr.ScVal) as T + const retval = (resultResponse as any).result?.retval + if (!retval) return undefined as T + return retval as T } return { - open_dispute: (gig_id: Uint8Array, milestone_index: number, reason: string): Promise => { + open_dispute: (gig_id: Buffer, milestone_index: number, reason: string): Promise => { return invoke( 'open_dispute', - xdr.ScVal.scvBytes(Uint8Array.from(gig_id)), xdr.ScVal.scvU32(milestone_index), xdr.ScVal.scvString(reason) + xdr.ScVal.scvBytes(gig_id), xdr.ScVal.scvU32(milestone_index), xdr.ScVal.scvString(reason) ) }, - submit_evidence: (dispute_id: Uint8Array, evidence_uri: string): Promise => { + submit_evidence: (dispute_id: Buffer, evidence_uri: string): Promise => { return invoke( 'submit_evidence', - xdr.ScVal.scvBytes(Uint8Array.from(dispute_id)), xdr.ScVal.scvString(evidence_uri) + xdr.ScVal.scvBytes(dispute_id), xdr.ScVal.scvString(evidence_uri) ) }, - vote: (dispute_id: Uint8Array, in_favor: boolean): Promise => { + vote: (dispute_id: Buffer, in_favor: boolean): Promise => { return invoke( 'vote', - xdr.ScVal.scvBytes(Uint8Array.from(dispute_id)), xdr.ScVal.scvBool(in_favor) + xdr.ScVal.scvBytes(dispute_id), xdr.ScVal.scvBool(in_favor) ) }, - resolve: (dispute_id: Uint8Array): Promise => { + resolve: (dispute_id: Buffer): Promise => { return invoke( 'resolve', - xdr.ScVal.scvBytes(Uint8Array.from(dispute_id)) + xdr.ScVal.scvBytes(dispute_id) ) }, - get_dispute: (dispute_id: Uint8Array): Promise => { + get_dispute: (dispute_id: Buffer): Promise => { return invoke( 'get_dispute', - xdr.ScVal.scvBytes(Uint8Array.from(dispute_id)) + xdr.ScVal.scvBytes(dispute_id) ) }, diff --git a/shared/contracts-gen/escrow.ts b/shared/contracts-gen/escrow.ts index fb8ee7b..8077568 100644 --- a/shared/contracts-gen/escrow.ts +++ b/shared/contracts-gen/escrow.ts @@ -2,7 +2,7 @@ * Auto-generated Escrow contract bindings * Generated from Soroban contract spec — do not edit manually */ -import { Contract, rpc, xdr, Address } from '@stellar/stellar-sdk' +import { Contract, rpc, xdr, TransactionBuilder, Networks } from '@stellar/stellar-sdk' import { getSorobanServer } from '../soroban-rpc' import { ESCROW_CONTRACT_ID } from '../contracts' @@ -17,12 +17,12 @@ export interface Milestone { } export type MilestoneStatus = - Pending | - Funded | - Completed | - Released | - Disputed | - Refunded + 'Pending' | + 'Funded' | + 'Completed' | + 'Released' | + 'Disputed' | + 'Refunded' // ── Event Data Types ─────────────────────────────────────────── @@ -49,15 +49,15 @@ export interface EscrowRefundedEventData { export interface IEscrowContract { /** Deposit funds into escrow for a gig milestone */ - deposit(gig_id: Uint8Array, milestone_index: number, token: string, amount: bigint): Promise + deposit(gig_id: Buffer, milestone_index: number, token: string, amount: bigint): Promise /** Release escrowed funds to the freelancer */ - release(gig_id: Uint8Array, milestone_index: number): Promise + release(gig_id: Buffer, milestone_index: number): Promise /** Refund escrowed funds to the client */ - refund(gig_id: Uint8Array, milestone_index: number): Promise + refund(gig_id: Buffer, milestone_index: number): Promise /** Get the escrowed balance for a milestone */ - get_balance(gig_id: Uint8Array, milestone_index: number): Promise + get_balance(gig_id: Buffer, milestone_index: number): Promise /** Get all milestones for a gig */ - get_gig_milestones(gig_id: Uint8Array): Promise + get_gig_milestones(gig_id: Buffer): Promise } // ── Contract Client ───────────────────────────────────────────── @@ -68,50 +68,59 @@ export function createEscrowContract(): IEscrowContract { const contract = new Contract(contractId) async function invoke(method: string, ...args: xdr.ScVal[]): Promise { - const tx = await server.simulateTransaction(contract.call(method, ...args)) - const result = rpc.assembleTransaction(contract.call(method, ...args), tx).toXDR() - const sendResponse = await server.sendTransaction(result) + 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 returnValue = contract.call(method, ...args).result?.val - return rpc.scValToNative(returnValue as xdr.ScVal) as T + const retval = (resultResponse as any).result?.retval + if (!retval) return undefined as T + return retval as T } return { - deposit: (gig_id: Uint8Array, milestone_index: number, token: string, amount: bigint): Promise => { + deposit: (gig_id: Buffer, milestone_index: number, token: string, amount: bigint): Promise => { return invoke( 'deposit', - xdr.ScVal.scvBytes(Uint8Array.from(gig_id)), xdr.ScVal.scvU32(milestone_index), new Address(token).toScVal(), xdr.ScVal.scvI128(new xdr.Int128Parts({ hi: xdr.Int64.fromString(String(BigInt(amount) >> 64n)), lo: xdr.Int64.fromString(String(BigInt(amount) & 0xFFFFFFFFFFFFFFFFn)) })) + 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: Uint8Array, milestone_index: number): Promise => { + release: (gig_id: Buffer, milestone_index: number): Promise => { return invoke( 'release', - xdr.ScVal.scvBytes(Uint8Array.from(gig_id)), xdr.ScVal.scvU32(milestone_index) + xdr.ScVal.scvBytes(gig_id), xdr.ScVal.scvU32(milestone_index) ) }, - refund: (gig_id: Uint8Array, milestone_index: number): Promise => { + refund: (gig_id: Buffer, milestone_index: number): Promise => { return invoke( 'refund', - xdr.ScVal.scvBytes(Uint8Array.from(gig_id)), xdr.ScVal.scvU32(milestone_index) + xdr.ScVal.scvBytes(gig_id), xdr.ScVal.scvU32(milestone_index) ) }, - get_balance: (gig_id: Uint8Array, milestone_index: number): Promise => { + get_balance: (gig_id: Buffer, milestone_index: number): Promise => { return invoke( 'get_balance', - xdr.ScVal.scvBytes(Uint8Array.from(gig_id)), xdr.ScVal.scvU32(milestone_index) + xdr.ScVal.scvBytes(gig_id), xdr.ScVal.scvU32(milestone_index) ) }, - get_gig_milestones: (gig_id: Uint8Array): Promise => { + get_gig_milestones: (gig_id: Buffer): Promise => { return invoke( 'get_gig_milestones', - xdr.ScVal.scvBytes(Uint8Array.from(gig_id)) + xdr.ScVal.scvBytes(gig_id) ) }, From fef478d6e209b9d14aeab66c93818f1c37f7fe66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Crhemy-arc=E2=80=9D?= <“rhemaadzer@gmail.com”> Date: Wed, 22 Jul 2026 03:57:28 +0100 Subject: [PATCH 12/18] feat(codegen): add spec validation and improve event type naming - Add --validate flag to codegen script to check if bindings are up-to-date - Add validateSpec() function to validate spec file structure - Use PascalCase for event data type names (e.g., DisputeDisputeOpenedEventData) - Add codegen:validate script to package.json --- package.json | 1 + scripts/generate-contract-bindings.js | 99 ++++++++++++++++++++++++++- shared/contracts-gen/dispute.ts | 8 +-- 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index bb6021d..5fa8603 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": "node scripts/generate-contract-bindings.js --validate", "convert:assets": "node scripts/convert-to-webp.js" }, "dependencies": { diff --git a/scripts/generate-contract-bindings.js b/scripts/generate-contract-bindings.js index 86d9db2..c8c6997 100644 --- a/scripts/generate-contract-bindings.js +++ b/scripts/generate-contract-bindings.js @@ -87,6 +87,13 @@ function generateMethod(name, methodDef, localTypes) { return `${jsDoc} ${name}(${args}): Promise<${resultType}>` } +function toPascalCase(str) { + return str + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join('') +} + function generateEventDataType(name, dataDef, localTypes) { if (!dataDef || Object.keys(dataDef).length === 0) return 'void' const fields = Object.entries(dataDef) @@ -121,7 +128,8 @@ function generateBindings(spec) { 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}`) + const eventTypeName = `${contractName}${toPascalCase(eventName)}EventData` + lines.push(`export interface ${eventTypeName} ${dataType}`) lines.push(``) } } @@ -207,7 +215,58 @@ function generateBindings(spec) { return lines.join('\n') } +function validateSpec(specPath) { + const spec = JSON.parse(fs.readFileSync(specPath, 'utf-8')) + const errors = [] + + if (!spec.contract || typeof spec.contract !== 'string') { + errors.push('Missing or invalid "contract" field (must be a string)') + } + + if (!spec.methods || typeof spec.methods !== 'object') { + errors.push('Missing or invalid "methods" field (must be an object)') + } else { + for (const [methodName, methodDef] of Object.entries(spec.methods)) { + if (!methodDef.args || typeof methodDef.args !== 'object') { + errors.push(`Method "${methodName}": missing or invalid "args" field`) + } + if (methodDef.result && typeof methodDef.result !== 'object') { + errors.push(`Method "${methodName}": invalid "result" field`) + } + } + } + + if (spec.types && typeof spec.types !== 'object') { + errors.push('Invalid "types" field (must be an object)') + } + + if (spec.events && typeof spec.events !== 'object') { + errors.push('Invalid "events" field (must be an object)') + } + + return errors +} + +function generateBindingsForValidation(spec) { + const output = generateBindings(spec) + const outputPath = path.join(OUTPUT_DIR, `${spec.contract}.ts`) + + if (!fs.existsSync(outputPath)) { + return { changed: true, reason: 'file does not exist' } + } + + const existing = fs.readFileSync(outputPath, 'utf-8') + if (existing !== output) { + return { changed: true, reason: 'bindings are out of date' } + } + + return { changed: false } +} + function main() { + const args = process.argv.slice(2) + const isValidate = args.includes('--validate') + const specFiles = fs.readdirSync(SPECS_DIR).filter(f => f.endsWith('.spec.json')) if (specFiles.length === 0) { @@ -215,6 +274,44 @@ function main() { process.exit(1) } + // Validate spec files first + let hasValidationErrors = false + for (const specFile of specFiles) { + const specPath = path.join(SPECS_DIR, specFile) + const errors = validateSpec(specPath) + if (errors.length > 0) { + console.error(`Validation errors in ${specFile}:`) + errors.forEach(err => console.error(` - ${err}`)) + hasValidationErrors = true + } + } + if (hasValidationErrors) { + process.exit(1) + } + + if (isValidate) { + // Validation mode: check if generated bindings are up-to-date + let outOfDate = false + for (const specFile of specFiles) { + const specPath = path.join(SPECS_DIR, specFile) + const spec = JSON.parse(fs.readFileSync(specPath, 'utf-8')) + const result = generateBindingsForValidation(spec) + if (result.changed) { + console.error(`Bindings for "${spec.contract}" are out of date: ${result.reason}`) + outOfDate = true + } + } + + if (outOfDate) { + console.error('\nRun "npm run codegen" to regenerate bindings.') + process.exit(1) + } + + console.log(`All ${specFiles.length} contract binding(s) are up-to-date.`) + return + } + + // Generation mode if (!fs.existsSync(OUTPUT_DIR)) { fs.mkdirSync(OUTPUT_DIR, { recursive: true }) } diff --git a/shared/contracts-gen/dispute.ts b/shared/contracts-gen/dispute.ts index f785ba6..398b0c6 100644 --- a/shared/contracts-gen/dispute.ts +++ b/shared/contracts-gen/dispute.ts @@ -34,23 +34,23 @@ export type DisputeOutcome = // ── Event Data Types ─────────────────────────────────────────── -export interface DisputeDispute_openedEventData { +export interface DisputeDisputeOpenedEventData { dispute_id: Buffer opener: string reason: string } -export interface DisputeEvidence_submittedEventData { +export interface DisputeEvidenceSubmittedEventData { submitter: string evidence_uri: string } -export interface DisputeVote_castEventData { +export interface DisputeVoteCastEventData { voter: string in_favor: boolean } -export interface DisputeDispute_resolvedEventData { +export interface DisputeDisputeResolvedEventData { outcome: DisputeOutcome votes_for: number votes_against: number From 10535359d06012b5acd5733bc23106040a70be24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Crhemy-arc=E2=80=9D?= <“rhemaadzer@gmail.com”> Date: Wed, 22 Jul 2026 03:57:38 +0100 Subject: [PATCH 13/18] ci: add codegen validation step to CI pipeline - Run codegen:validate before lint to ensure bindings stay in sync with specs - Fail CI early if generated bindings are out of date --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) 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 From 637d223d62abc00e64262c9641fe9adecadc8174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Crhemy-arc=E2=80=9D?= <“rhemaadzer@gmail.com”> Date: Wed, 22 Jul 2026 03:58:42 +0100 Subject: [PATCH 14/18] feat(dashboard): add contracts status page with type-safe contract interaction - Add /dashboard/contracts page showing contract binding health - Display escrow and dispute contract connection status - Allow fetching milestones and disputes through typed contract hooks - Responsive design with dark/light mode support - Educational section explaining the codegen pipeline --- pages/dashboard/contracts.tsx | 267 ++++++++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 pages/dashboard/contracts.tsx 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 +
    • +
    +
    +
    + + ) +} From 808f0965c4fba47603dde8393466eaa1d59dd6e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Crhemy-arc=E2=80=9D?= <“rhemaadzer@gmail.com”> Date: Wed, 22 Jul 2026 03:58:58 +0100 Subject: [PATCH 15/18] feat(dashboard): add Contracts link to sidebar navigation - Add Contracts nav item with link to /dashboard/contracts - Positioned between Disputes and Profile for logical grouping --- pages/dashboard.tsx | 1 + 1 file changed, 1 insertion(+) 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' }, ] From 81fe6b27ab2fd7331f5635ea99277e2db853794b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Crhemy-arc=E2=80=9D?= <“rhemaadzer@gmail.com”> Date: Wed, 22 Jul 2026 03:59:22 +0100 Subject: [PATCH 16/18] test(codegen): add test suite for contract bindings codegen - 18 tests covering spec validation, generated output, and type correctness - Tests verify each contract has valid spec structure and generated bindings - Tests check that TypeScript types match Soroban type mappings - Tests verify barrel export includes all contracts --- .../generate-contract-bindings.test.js | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 scripts/__tests__/generate-contract-bindings.test.js 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}>`) + } + } + } + }) + }) + }) +}) From bf5eef4c8845e562a2d413056687e5a37d5b2f5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Crhemy-arc=E2=80=9D?= <“rhemaadzer@gmail.com”> Date: Wed, 22 Jul 2026 04:00:04 +0100 Subject: [PATCH 17/18] docs: update README with contracts page and codegen validation - Add /dashboard/contracts to available pages list - Document codegen:validate command and CI validation - Add section on validating bindings --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index acc9d0a..82c40ed 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 @@ -193,6 +194,8 @@ TrustFlow uses a codegen pipeline to generate fully typed TypeScript client bind 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 +4. CI validates bindings are up-to-date via `npm run codegen:validate` +5. View contract status and interact with contracts at `/dashboard/contracts` **Adding a new contract:** @@ -200,6 +203,16 @@ TrustFlow uses a codegen pipeline to generate fully typed TypeScript client bind 2. Run `npm run codegen` 3. Import the generated client: `import { createMycontractContract } from '../shared/contracts-gen'` +**Validating bindings:** + +```bash +# Check if bindings are up-to-date +npm run codegen:validate + +# Regenerate bindings +npm run codegen +``` + **Generated files:** - `shared/contracts-gen/escrow.ts` — Escrow contract types and client From 2bd6a1372d06b2cd037b2d6a103f02124ec89f3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Crhemy-arc=E2=80=9D?= <“rhemaadzer@gmail.com”> Date: Thu, 23 Jul 2026 17:50:03 +0100 Subject: [PATCH 18/18] fix: add missing codegen:validate script to package.json The CI step in ci.yml runs 'npm run codegen:validate' but the script was never defined in package.json, causing CI to fail with 'Missing script: codegen:validate'. --- package.json | 1 + 1 file changed, 1 insertion(+) 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": {