Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 209 additions & 0 deletions app/api/contracts/deploy/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
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 }
)
})
141 changes: 141 additions & 0 deletions lib/contracts/store.ts
Original file line number Diff line number Diff line change
@@ -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<JobRow | null> {
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<ContractRow | null> {
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<UserRow | null> {
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<number | null> {
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<ContractRow> {
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<void> {
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<void> {
await sql`
UPDATE jobs
SET escrow_contract_id = ${contractAddress},
escrow_status = 'pending',
updated_at = CURRENT_TIMESTAMP
WHERE id = ${jobId}
`
}
Loading
Loading