Back to work

Forj

A freelance marketplace built around on-chain escrow, where agreements and payments are governed by smart contracts.

Category
Web3 Freelance Marketplace
Type
Personal Project
Year
2026
Status
Testnet
Role
Solo Developer
Technologies
Solidity, Base, Smart Wallets, Next.js, TypeScript, PostgreSQL
Index
01 / 05
The Forj client dashboard for a test account: sidebar navigation for jobs, contracts and messages, a profile-completion prompt, and stat tiles reading WorkScore 0, Jobs completed 0, and Total earned $0 USDC.

01Overview

Forj is a freelance marketplace where the agreement and the payment are governed by a smart contract rather than by platform policy. It is a personal project, built solo and currently running on Base Sepolia. The application handles matching, proposals, messaging and reviews off-chain; the money is held and released by an escrow contract that either party can read.

02Problem

Freelance transactions depend on both parties trusting the platform to manage payments and resolve disputes. That creates a layer of dependency between client and freelancer around exactly the moments that matter — funding, milestones, and settlement. The rule that decides who gets paid is a policy document rather than something either side can inspect.

03Approach

Treat escrow as a state machine, not a service. The release conditions are written into a contract neither party controls, and the platform's job narrows to the parts software is genuinely better at — finding people, describing work, and showing the state of an agreement clearly. This is an exploration of that model rather than a claim that it is the right answer for every case; it trades dispute flexibility for legibility, and the arbiter path exists precisely because that trade is not free.

04System

Two sources of truth, split on purpose. Postgres owns identity and content — users, jobs, proposals, messages, reviews. Base owns money: every escrow row in the database has a matching entry on-chain, and the row is reconciled against the chain on every state-changing call. The database is treated as a cache that can always be re-derived, so a disagreement between the two is resolved in the chain's favour. A single escrow service is the only code permitted to talk to the chain, and the server pulls transaction receipts itself rather than trusting what the client reports.

Two sources of truth: Postgres owns content, Base owns money. Hover a component for detail.
  1. 01

    Client

    • Next.js · React 19apps/web — RSC pages plus the dashboard shell. The only consumer of every other workspace package.
    • PrivySign-in by email, Google or wallet. Issues the session cookie and provisions an embedded wallet and a smart wallet.
    • wagmi · viemChain reads and writes from the browser, connected through Privy.
  2. 02

    Edge

    • /api/trpcThe tRPC route handler. Verifies the Privy token, resolves the Postgres user row, and becomes the auth boundary for everything below.
    • webhooks · uploads · jobs/api/webhooks/privy for user lifecycle, /api/uploadthing for file callbacks, /api/inngest for background jobs.
  3. 03

    Domain

    • tRPC routersEleven routers — user, job, proposal, contract, message, review, notification, service, savedJob, search, admin — rate-limited through Upstash Redis.
    • escrow serviceThe only code that talks to the chain. Validates each transition, submits it, verifies the receipt and event payload, then updates the database.
    • PimlicoBundler and paymaster. Batches approve + fund into one sponsored UserOperation, so funding costs the client no gas and one confirmation.
  4. 04

    State

    • Neon Postgres · DrizzleSource of truth for identity and content. Holds a mirror of each escrow that is reconciled against the chain on every state change.
    • Base Sepolia · ForjEscrowSource of truth for money. USDC held by ForjEscrow v2 — OpenZeppelin Ownable2Step, Pausable, ReentrancyGuard, SafeERC20.
    • Resend · UploadThingTransactional email rendered with React Email, and file storage for avatars and attachments.
ClientNext.js and React 19, with Privy for sign-in and wallets, wagmi and viem for chain reads and writes, and a tRPC client sharing types with the server.
EdgeNext.js route handlers: the tRPC endpoint, Privy user-lifecycle webhooks, UploadThing callbacks, and the Inngest background-job endpoint.
DomainEleven tRPC routers holding the business logic, rate-limited through Upstash Redis. The escrow service is the sole boundary to the chain.
StateNeon serverless Postgres via Drizzle for content, ForjEscrow and USDC on Base Sepolia for funds, and Resend, UploadThing and Inngest for everything outside both.
  1. Createdoff-chainOff-chain. A proposal was accepted.
  2. FundedClient deposited USDC. Locked in the contract.
  3. SubmittedFreelancer marked the work delivered.
  4. ReleasedterminalPaid to the freelancer. Terminal.
  5. RefundedterminalReturned to the client. Terminal.
  6. DisputedEither party escalated to the arbiter.
  7. ResolvedterminalArbiter split the funds. Terminal.

Transitions

  • CreatedFundedfund()
  • FundedSubmittedsubmitWork()
  • SubmittedFundedrequestRevision()
  • SubmittedReleasedrelease() · claimAfterTimeout()
  • FundedRefundedrefund()
  • FundedDisputedraiseDispute()
  • SubmittedDisputedraiseDispute()
  • DisputedResolvedresolveDispute()

ForjEscrow Status enum. Created is off-chain; every state below it is a slot on Base.

05Interface

The interface is built so that the chain is visible without being the subject. A contract reads as an ordinary project view — what was agreed, what is owed, what happens next — and the on-chain timeline sits alongside it as evidence rather than as the main event. Funding is a single confirmation with no gas prompt, because the approve and fund calls are batched into one sponsored operation.

06Current Status

Running on Base Sepolia with ForjEscrow v2 deployed and the full lifecycle exercised end to end: fund, submit, release, and the dispute path through the arbiter. The Base mainnet address in the contract registry is still empty — nothing is deployed to mainnet, and there are no users beyond my own test wallets. Milestones are tracked off-chain for now, so the contract still holds and releases the full amount in one transaction regardless of how a job is broken up.

07Links