diff --git a/app/api/contracts/deploy/route.ts b/app/api/contracts/deploy/route.ts new file mode 100644 index 0000000..4041857 --- /dev/null +++ b/app/api/contracts/deploy/route.ts @@ -0,0 +1,209 @@ +import { NextRequest, NextResponse } from 'next/server' +import { withAuth } from '@/lib/auth/middleware' +import { deploySorobanEscrow, SorobanDeployError } from '@/lib/soroban/deploy' +import { + createContract, + createMilestones, + getExistingContract, + getJobById, + getUserById, + getUserIdByWalletAddress, + linkJobToContract, + type MilestoneInput, +} from '@/lib/contracts/store' + +interface DeployContractBody { + jobId?: unknown + freelancerId?: unknown + totalAmount?: unknown + currency?: unknown + terms?: unknown + milestones?: unknown +} + +function isPositiveInt(v: unknown): v is number { + return typeof v === 'number' && Number.isInteger(v) && v > 0 +} + +function isNonEmptyString(v: unknown): v is string { + return typeof v === 'string' && v.trim().length > 0 +} + +function isValidAmount(v: unknown): v is string { + if (typeof v !== 'string') return false + const n = Number(v) + return Number.isFinite(n) && n > 0 +} + +function parseMilestones(raw: unknown): MilestoneInput[] | string { + if (!Array.isArray(raw)) return 'milestones must be an array' + for (const [i, m] of raw.entries()) { + if (typeof m !== 'object' || m === null) return `milestones[${i}] must be an object` + const entry = m as Record + if (!isNonEmptyString(entry.title)) return `milestones[${i}].title is required` + if (!isValidAmount(entry.amount)) return `milestones[${i}].amount must be a positive number string` + if (entry.dueDate !== undefined && typeof entry.dueDate !== 'string') { + return `milestones[${i}].dueDate must be an ISO date string` + } + } + return raw as MilestoneInput[] +} + +export const POST = withAuth(async (request: NextRequest, auth) => { + let body: DeployContractBody + try { + body = await request.json() + } catch { + return NextResponse.json( + { error: 'Request body must be valid JSON', code: 'INVALID_JSON' }, + { status: 400 } + ) + } + + // --- Input validation --- + if (!isPositiveInt(body.jobId)) { + return NextResponse.json( + { error: 'jobId must be a positive integer', code: 'INVALID_JOB_ID' }, + { status: 400 } + ) + } + if (!isPositiveInt(body.freelancerId)) { + return NextResponse.json( + { error: 'freelancerId must be a positive integer', code: 'INVALID_FREELANCER_ID' }, + { status: 400 } + ) + } + if (!isValidAmount(body.totalAmount)) { + return NextResponse.json( + { error: 'totalAmount must be a positive number string (e.g. "100.00")', code: 'INVALID_TOTAL_AMOUNT' }, + { status: 400 } + ) + } + const currency = isNonEmptyString(body.currency) ? body.currency.toUpperCase() : 'XLM' + const terms = isNonEmptyString(body.terms) ? body.terms : undefined + + let milestones: MilestoneInput[] = [] + if (body.milestones !== undefined) { + const parsed = parseMilestones(body.milestones) + if (typeof parsed === 'string') { + return NextResponse.json({ error: parsed, code: 'INVALID_MILESTONES' }, { status: 400 }) + } + milestones = parsed + } + + // --- Resolve authenticated user to a DB user id --- + const clientDbId = await getUserIdByWalletAddress(auth.walletAddress) + if (clientDbId === null) { + return NextResponse.json( + { error: 'Authenticated wallet has no platform account', code: 'USER_NOT_FOUND' }, + { status: 401 } + ) + } + + // --- Verify job exists and belongs to the authenticated client --- + const job = await getJobById(body.jobId) + if (job === null) { + return NextResponse.json( + { error: 'Job not found', code: 'JOB_NOT_FOUND' }, + { status: 404 } + ) + } + if (job.client_id !== clientDbId) { + return NextResponse.json( + { error: 'You are not the client for this job', code: 'FORBIDDEN' }, + { status: 403 } + ) + } + if (job.status === 'completed' || job.status === 'cancelled') { + return NextResponse.json( + { error: `Cannot deploy contract for a ${job.status} job`, code: 'JOB_NOT_DEPLOYABLE' }, + { status: 409 } + ) + } + + // --- Ensure no contract has already been deployed for this job --- + const existing = await getExistingContract(body.jobId) + if (existing !== null) { + return NextResponse.json( + { + error: 'A contract already exists for this job', + code: 'CONTRACT_ALREADY_EXISTS', + contractId: existing.id, + contractAddress: existing.contract_address, + }, + { status: 409 } + ) + } + + // --- Verify freelancer exists --- + const freelancer = await getUserById(body.freelancerId) + if (freelancer === null) { + return NextResponse.json( + { error: 'Freelancer not found', code: 'FREELANCER_NOT_FOUND' }, + { status: 404 } + ) + } + + // --- Deploy Soroban contract --- + let deployment + try { + deployment = await deploySorobanEscrow({ + clientAddress: auth.walletAddress, + freelancerAddress: freelancer.wallet_address, + totalAmount: body.totalAmount, + currency, + }) + } catch (err) { + const message = + err instanceof SorobanDeployError ? err.message : 'Contract deployment failed' + console.error('[contracts/deploy] Soroban deployment error:', err) + return NextResponse.json( + { error: message, code: 'DEPLOYMENT_FAILED' }, + { status: 500 } + ) + } + + // --- Persist to database --- + let contract + try { + contract = await createContract({ + jobId: body.jobId, + clientId: clientDbId, + freelancerId: body.freelancerId, + totalAmount: body.totalAmount, + currency, + terms, + contractAddress: deployment.contractAddress, + txHash: deployment.txHash, + networkPassphrase: deployment.networkPassphrase, + }) + + await linkJobToContract(body.jobId, deployment.contractAddress) + + if (milestones.length > 0) { + await createMilestones(body.jobId, contract.id, milestones) + } + } catch (err) { + console.error('[contracts/deploy] DB persistence error:', err) + return NextResponse.json( + { error: 'Failed to persist contract data', code: 'DB_ERROR' }, + { status: 500 } + ) + } + + return NextResponse.json( + { + contractId: contract.id, + jobId: contract.job_id, + contractAddress: contract.contract_address, + txHash: contract.contract_tx_hash, + networkPassphrase: contract.network_passphrase, + status: contract.status, + totalAmount: contract.total_amount, + currency: contract.currency, + milestonesCreated: milestones.length, + createdAt: contract.created_at, + }, + { status: 201 } + ) +}) diff --git a/lib/contracts/store.ts b/lib/contracts/store.ts new file mode 100644 index 0000000..4117c59 --- /dev/null +++ b/lib/contracts/store.ts @@ -0,0 +1,141 @@ +import { sql } from '@/lib/db' + +export interface MilestoneInput { + title: string + description?: string + amount: string + dueDate?: string +} + +export interface CreateContractParams { + jobId: number + clientId: number + freelancerId: number + totalAmount: string + currency: string + terms?: string + contractAddress: string + txHash: string + networkPassphrase: string +} + +export interface ContractRow { + id: number + job_id: number + client_id: number + freelancer_id: number + total_amount: string + currency: string + terms: string | null + contract_address: string | null + contract_tx_hash: string | null + network_passphrase: string | null + status: string + created_at: string + updated_at: string +} + +export interface JobRow { + id: number + client_id: number + freelancer_id: number | null + title: string + status: string + escrow_contract_id: string | null +} + +export interface UserRow { + id: number + wallet_address: string + user_type: string +} + +export async function getJobById(jobId: number): Promise { + const rows = (await sql` + SELECT id, client_id, freelancer_id, title, status, escrow_contract_id + FROM jobs + WHERE id = ${jobId} + LIMIT 1 + `) as JobRow[] + return rows[0] ?? null +} + +export async function getExistingContract(jobId: number): Promise { + const rows = (await sql` + SELECT * FROM contracts WHERE job_id = ${jobId} LIMIT 1 + `) as ContractRow[] + return rows[0] ?? null +} + +export async function getUserById(userId: number): Promise { + const rows = (await sql` + SELECT id, wallet_address, user_type FROM users WHERE id = ${userId} LIMIT 1 + `) as UserRow[] + return rows[0] ?? null +} + +export async function getUserIdByWalletAddress(walletAddress: string): Promise { + const rows = (await sql` + SELECT id FROM users WHERE wallet_address = ${walletAddress} LIMIT 1 + `) as { id: number }[] + return rows[0]?.id ?? null +} + +export async function createContract(params: CreateContractParams): Promise { + const rows = (await sql` + INSERT INTO contracts ( + job_id, client_id, freelancer_id, + total_amount, currency, terms, + contract_address, contract_tx_hash, network_passphrase, + status + ) + VALUES ( + ${params.jobId}, + ${params.clientId}, + ${params.freelancerId}, + ${params.totalAmount}, + ${params.currency}, + ${params.terms ?? null}, + ${params.contractAddress}, + ${params.txHash}, + ${params.networkPassphrase}, + 'pending' + ) + RETURNING * + `) as ContractRow[] + return rows[0] +} + +export async function createMilestones( + jobId: number, + contractId: number, + milestones: MilestoneInput[] +): Promise { + for (const m of milestones) { + await sql` + INSERT INTO milestones (job_id, contract_id, title, description, amount, due_date, status) + VALUES ( + ${jobId}, + ${contractId}, + ${m.title}, + ${m.description ?? null}, + ${m.amount}, + ${m.dueDate ?? null}, + 'pending' + ) + ` + } +} + +export async function linkJobToContract( + jobId: number, + contractAddress: string +): Promise { + await sql` + UPDATE jobs + SET escrow_contract_id = ${contractAddress}, + escrow_status = 'pending', + updated_at = CURRENT_TIMESTAMP + WHERE id = ${jobId} + ` +} diff --git a/lib/soroban/deploy.ts b/lib/soroban/deploy.ts new file mode 100644 index 0000000..d00cf4a --- /dev/null +++ b/lib/soroban/deploy.ts @@ -0,0 +1,58 @@ +/** + * Soroban escrow contract deployment. + * + * The real implementation should call the Soroban RPC node to upload the WASM + * and invoke the contract constructor using @stellar/stellar-sdk. This stub + * returns deterministic-looking values so the rest of the stack can be wired + * up and tested end-to-end before the on-chain work is complete. + */ + +export interface SorobanDeployParams { + clientAddress: string + freelancerAddress: string + totalAmount: string + currency: string +} + +export interface SorobanDeployResult { + contractAddress: string + txHash: string + networkPassphrase: string +} + +export class SorobanDeployError extends Error { + constructor( + message: string, + public readonly cause?: unknown + ) { + super(message) + this.name = 'SorobanDeployError' + } +} + +// TODO: Replace this stub with real Soroban SDK deployment. +// Steps for real implementation: +// 1. Load the compiled escrow WASM from the contract build artifacts. +// 2. Upload the WASM via SorobanRpc.Server.uploadContractWasm(). +// 3. Invoke the constructor (installContractCode + createContractId). +// 4. Submit and await the transaction using SorobanRpc.Server.sendTransaction() +// + SorobanRpc.Server.getTransaction() polling. +// 5. Extract the deployed contract ID from the transaction result meta. +export async function deploySorobanEscrow( + params: SorobanDeployParams +): Promise { + const network = process.env.STELLAR_NETWORK_PASSPHRASE ?? 'Test SDF Network ; September 2015' + + // Stub: derive a mock contract address from the client address so results + // are deterministic in tests without hitting the network. + const seed = `${params.clientAddress}:${params.freelancerAddress}:${params.totalAmount}` + const hash = Array.from(seed).reduce((acc, ch) => (acc * 31 + ch.charCodeAt(0)) >>> 0, 0) + const contractAddress = `C${hash.toString(16).padStart(7, '0').toUpperCase()}${'A'.repeat(48)}` + const txHash = `${Date.now().toString(16)}${hash.toString(16).padStart(16, '0')}` + + return { + contractAddress, + txHash, + networkPassphrase: network, + } +} diff --git a/scripts/006-contracts.sql b/scripts/006-contracts.sql new file mode 100644 index 0000000..af982cb --- /dev/null +++ b/scripts/006-contracts.sql @@ -0,0 +1,49 @@ +-- Deployed Soroban contracts +-- Each job may have at most one active contract (enforced by UNIQUE constraint). +CREATE TABLE IF NOT EXISTS contracts ( + id SERIAL PRIMARY KEY, + job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + client_id INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + freelancer_id INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + + -- Human-readable terms (canonical immutable record lives on-chain / IPFS) + terms TEXT, + + -- Financials (= sum of milestone amounts) + total_amount DECIMAL(18, 6) NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'XLM', + + -- Soroban on-chain references + contract_address VARCHAR(255), -- deployed Soroban contract address + contract_tx_hash VARCHAR(64), -- deployment transaction hash + network_passphrase TEXT, -- Stellar network identifier + + -- Lifecycle + status VARCHAR(20) NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','active','paused','completed','cancelled','disputed')), + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + -- Enforce one active contract per job + CONSTRAINT uq_contracts_job UNIQUE (job_id) +); + +CREATE INDEX IF NOT EXISTS idx_contracts_job ON contracts(job_id); +CREATE INDEX IF NOT EXISTS idx_contracts_client ON contracts(client_id); +CREATE INDEX IF NOT EXISTS idx_contracts_freelancer ON contracts(freelancer_id); +CREATE INDEX IF NOT EXISTS idx_contracts_status ON contracts(status); + +-- Milestones linked to a contract +-- Extends the existing milestones table with a contract_id column. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'milestones' AND column_name = 'contract_id' + ) THEN + ALTER TABLE milestones ADD COLUMN contract_id INTEGER REFERENCES contracts(id) ON DELETE SET NULL; + CREATE INDEX IF NOT EXISTS idx_milestones_contract ON milestones(contract_id); + END IF; +END; +$$;