Back to blog
WEB3 · ·9 min

Non-custodial escrow as a marketplace primitive: design notes from TaskMarket

A 4-state Solidity contract on Base, a binary arbiter call, and no third destination address. How TaskMarket settles in USDC without ever touching user funds — and what each invariant prevents.

  • #solidity
  • #web3
  • #marketplace
  • #escrow
  • #base
  • #engineering-discipline

Most “campus task” apps handle money in the place where money handling actually fails: a Venmo handle in a profile, an e-Transfer note in DMs, and a five-page support flow when something goes wrong. The mechanism is unenforced. The platform’s role is brand and discoverability, not settlement.

When I scoped TaskMarket, the question wasn’t “should we add escrow.” It was “if we can’t enforce settlement, are we actually a marketplace?” The answer was no. So the design goal flipped: build the smallest piece of on-chain code that can hold a USDC deposit for a single task and route it to exactly one of two parties on resolution — and let everything else (UI, search, chat, ratings) sit around that core.

This post is the design notes for that contract. Specifically:

  1. What the four states are and why there are only four
  2. Why resolveByOwner has a binary signature, not a free-form one
  3. The invariants that survived several rounds of “what if we …”
  4. What the contract intentionally does NOT do

Four states, no upgrade path

TaskEscrow.sol exposes a state machine with exactly four states per task: None / Funded / Released / Refunded. Tasks are keyed by bytes32 taskId (the backend uses keccak256(orderId)).

None
 │ deposit(taskId, payee, amount)

Funded ──┬─── release(taskId)        ──▶  Released
         ├─── refund(taskId)         ──▶  Refunded
         └─── resolveByOwner(...)    ──▶  Released | Refunded

assignPayee is the one transition that doesn’t change state — it just sets the payee field if the original deposit left it zero (used for “post task without a winner picked yet”). After that, every other transition is terminal: Released and Refunded are end states, no recall.

Why not more states? Because each state is a place where money can sit. Each transition out is an attack surface. I wanted exactly two ways for funds to leave the contract — to the payer, or to the payee — and exactly two roles that can initiate that movement: the payer (who can release or refund) and the arbiter (who can resolve a dispute). Everything else is the same problem in a different costume.

There’s no upgrade path. The contract is pragma solidity 0.8.20, no proxy, no admin slot, no selfdestruct. If a bug is found, the response is “deploy a new contract, drain via refunds, migrate the front-end.” That’s a hard constraint, and it shapes the rest of the design — every decision has to be defensible forever, because there’s no patching once it’s live.

The arbiter signature is binary by design

The interesting function is resolveByOwner(bytes32 taskId, bool favorPayee). Anyone reading the signature for the first time asks the same question: “Why isn’t it resolveByOwner(taskId, address recipient, uint256 amount)?”

Because the second signature is the entire scam surface.

If the arbiter can pick the recipient address, the platform’s operator can collude with anyone to drain a deposit. If the arbiter can pick the amount, the platform can siphon a percentage. If the arbiter can pick BOTH, the platform is just a custodian with extra steps.

The binary signature collapses that surface:

  • favorPayee = true(amount - fee) to the assigned payee, fee to the platform treasury
  • favorPayee = false → full amount back to the payer

That’s it. Two outcomes per deposit, and the arbiter picks one. The arbiter cannot route funds to a third address, cannot change the amount, cannot change the fee on this transaction. Even if the arbiter key is compromised, the worst an attacker can do is misallocate existing in-flight deposits between two existing parties — they cannot extract funds to themselves.

This is the kind of property a smart contract should make easier to reason about, not harder. The contract enforces “no third destination” at the EVM level, so the front-end doesn’t need to second-guess what the arbiter wallet might do.

Invariants worth listing out loud

Every one of these is something I tried to break in code before I let the contract go to testnet:

  • Fee is capped by constant. FEE_BPS_MAX = 2000 (20%). The owner can change the live fee within [0, FEE_BPS_MAX], but the cap itself is constant — no setter exists for it. Even a compromised owner can’t take more than 20%.
  • Each taskId is single-shot. deposit(existingTaskId, ...) reverts with EscrowAlreadyExists. No accidental re-funding, no overwriting of an open escrow.
  • Self-deal blocked. deposit(taskId, payer, amount) where msg.sender == payee reverts with SelfDeal. The platform should never have to route a payer-to-themselves transaction through escrow.
  • Released and Refunded are terminal. Calling release or refund on a non-Funded task reverts. No double-release, no “refund after release.”
  • Arbiter has no token allowance. The contract holds the USDC; the arbiter key only triggers transitions. The arbiter wallet cannot move funds outside the contract.

Each invariant is one fewer thing the operator (me) has to be trusted to do correctly. That matters because I’m a single founder running this. Trust me less; trust the bytecode more.

What the contract intentionally does NOT do

A surprising amount of the design work was figuring out what to LEAVE out.

No tokens beyond USDC. The contract is hardcoded to one ERC-20 (USDC on Base). Multi-token would require an allowlist, which would require admin governance, which would require an upgrade path, which is the whole rabbit hole I just decided to avoid. If TaskMarket ever needs EUROe or CAD-pegged tokens, it deploys a sibling contract — not an admin call.

No partial release. release(taskId) always sends the full (amount - fee). The contract doesn’t know how to handle “the taker delivered 80% of the job.” That’s a UX problem. If the parties want partial settlement, they negotiate a new task ID; the original escrow refunds, the new one funds at the agreed amount.

No timeout-based auto-release on-chain. This was a real design choice. I could have built a “if X days pass without dispute, auto-release” mechanism into the contract. I didn’t, because that ties the contract to a notion of time it doesn’t have a reliable source for (block timestamps drift, validators have small grace windows). Auto-release lives off-chain in the escrowReleaser background worker. The worker is signed by the same arbiter key, but executes the SAME binary resolveByOwner — the on-chain code still only knows “favorPayee true or false.”

No batch operations. No batchRelease, no multiTransfer. Every release / refund / resolve is a single transaction for a single taskId. The cost is gas; the benefit is no accidental over-release because of a malformed array.

What this gets you that “trust the platform” doesn’t

The point of all this isn’t that smart contracts are good. The point is what the alternative actually looks like when something goes wrong.

In a Venmo / e-Transfer marketplace, when a poster claims “I never got the package,” the platform has three options: refund out of its own pocket (bleeding), refuse to refund (1-star reviews), or somehow recover the money from the taker (impossible after the fact). The platform’s incentive is to delay until the user gives up. This is why “campus task” apps die.

In a non-custodial escrow marketplace, when the same dispute happens, the funds are RIGHT THERE in TaskEscrow.sol, in state Funded. The arbiter — me — looks at the evidence the taker submitted (text + up to 9 photos, written to the database when the order was marked done) and the dispute the poster opened, then calls resolveByOwner(taskId, false) or resolveByOwner(taskId, true). The funds move in one block. The platform never had them and never could have taken them.

The interesting reframe: this is what people thought DAOs were going to do, except without the DAO. Single-arbiter dispute resolution is unromantic but it’s the part that actually works in production at this scale. The DAO version comes later, if at all.

— RELATED READING