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
Year
2026
Status
Testnet
Technologies
Solidity, Base, Smart Wallets, Next.js, TypeScript, tRPC, Drizzle, PostgreSQL, Node.js
Index
01 / 05
The Forj landing page: a public testnet badge for Base Sepolia above the headline “Work, forged in trust”, beside an example contract timeline running from job posted and proposal accepted through escrow funded, work delivered and approved to paid, with the client and freelancer fees listed beneath it.

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 ForjEscrowV3 — OpenZeppelin Ownable2Step, ReentrancyGuard, Pausable, SafeERC20. Pausing blocks new funding and nothing else: settlement, refunds, submissions and disputes always work.
    • 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. FundedDeposit held, waiting for the first delivery.
  2. SubmittedDelivery under client review.
  3. RevisionRequestedClient asked for changes, up to the revision cap.
  4. DisputedFrozen. Only the arbiter or the timeout settles it.
  5. ReleasedterminalPaid to the freelancer.
  6. RefundedterminalFull deposit back to the client. No fee kept.
  7. ResolvedterminalDispute settled by split.

Transitions

  • FundedSubmittedsubmitWork()
  • SubmittedRevisionRequestedrequestRevision()
  • RevisionRequestedSubmittedsubmitWork()
  • SubmittedReleasedrelease() · releaseAfterReview()
  • FundedRefundedcancelByFreelancer() · refundAfterDeadline()
  • SubmittedDisputedraiseDispute()
  • DisputedResolvedresolveDispute(shareBps) — arbiter, before deadline
  • DisputedResolvedresolveExpiredDispute() — anyone, 50/50, no fee

ForjEscrowV3. Every terminal state is reachable without the arbiter — the dispute path has a deadline, and anyone can settle an expired one.

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

Live on Base Sepolia as a public testnet, running ForjEscrowV3 — a fresh deployment rather than an upgrade, with v2 left immutable beside it. The full lifecycle is exercised end to end: fund, submit, revise, release, and both dispute paths. The Base mainnet entry in the contract registry is still an empty string, so nothing is on mainnet, nothing is audited, and there are no users beyond my own test wallets. Milestones are tracked off-chain, so the contract still holds and releases the full amount in one transaction however a job is broken up.

07Links