HOW ARBIQ WORKS
Payment locked in escrow at post time, released by AI consensus at delivery time — no intermediary needed.
Arbiq is built on GenLayer — a blockchain that runs Intelligent Contracts: Python smart contracts that can call LLMs and reach non-deterministic consensus across independent validators. Arbiq uses this to have AI evaluate freelance work and automatically settle payment without a trusted third party.
When a client posts a job, their GEN tokens lock into the contract immediately. When the freelancer submits evidence of completed work, the client triggers an AI evaluation — multiple GenLayer validators independently read the job spec and evidence URL, compare results, and if they agree, execute payment atomically. No cut, no human arbiter, no payment delays.
Trustless Escrow
Funds locked at post time. Released only on AI approval or manual client sign-off.
AI Enforcement
GenLayer validator consensus runs LLMs to evaluate evidence. No single point of control.
Zero Platform Fee
100% of escrowed GEN goes to the freelancer on approval. No middleman percentage.
QUICK START
From zero to posting your first job in under 5 minutes.
Install a wallet
Install MetaMask or any WalletConnect-compatible wallet. Arbiq uses RainbowKit and supports injected wallets, WalletConnect, and Coinbase Wallet.
Add GenLayer Bradbury Testnet
Arbiq will prompt you to switch networks automatically when you connect. Accept the network switch.
Network name: GenLayer Bradbury Testnet
Chain ID: 4221
RPC URL: https://rpc-bradbury.genlayer.com
Symbol: GEN
Explorer: https://explorer-bradbury.genlayer.comGet testnet GEN tokens
Visit the GenLayer Studio faucet to receive testnet GEN. You need GEN to post jobs (budget is held in escrow).
Post a job
Go to /jobs/new, fill in the title, description, deadline, and budget. Click "Post Job" — your wallet will prompt you to sign a transaction. The budget is locked in the contract immediately.
Wait for a freelancer
Your job appears on /jobs. Any wallet can browse and accept it. You'll receive a notification when someone takes the job.
ARCHITECTURE
How the browser, genlayer-js, and the GenLayer validator network fit together.
Browser (Next.js 16 · React 19 · Tailwind CSS v4)
│
├─ wagmi v2 + RainbowKit ──── wallet signing (MetaMask, WalletConnect…)
├─ TanStack Query v5 ──── caches chain reads, auto-refetches
└─ genlayer-js v1.1.8 ──── encodes calls, polls consensus status
│
│ readContract() → gen_call(type:"read") ─── view methods
│ writeContract() → eth_sendTransaction ─── state mutations
│ getTransaction()→ poll every 1s ─── consensus tracking
│
▼
GenLayer Bradbury Testnet (Chain ID 4221)
│
├─ Consensus Main Contract ←── all transactions land here first
│ PROPOSING → COMMITTING → REVEALING → ACCEPTED
│
└─ arbiq.py (Intelligent Contract · Python)
├─ job_count : u256
├─ jobs : TreeMap[u256, str] ← JSON job objects
└─ messages : TreeMap[u256, str] ← JSON message arraysRead calls go directly to an RPC node via gen_call(type:"read") — no wallet needed, instant response. Write calls are msgpack-encoded and sent as eth_sendTransaction to the consensus contract. The frontend then polls getTransaction() every second until the transaction reaches a decided state (ACCEPTED or FINALIZED_ERROR).
waitForTransactionReceipt sleeps 3s between polls with no intermediate status. Custom polling surfaces live consensus phases (PROPOSING, COMMITTING, REVEALING) and reaches finality ~3× faster.CONTRACT REFERENCE
All methods on arbiq.py — the GenLayer Intelligent Contract.
Current — proposals, cancel & refunds, deadlines, disputes, profiles, ratings, security hardening
Superseded — escrow, AI evaluation, milestones, chat
GenLayer contracts are immutable — an upgrade means deploying a new contract and repointing the app. Jobs on a previous deployment stay readable at its address.
The contract is a Python class inheriting from gl.Contract. State is stored in TreeMapfields — GenLayer's on-chain key-value store — with values serialized as JSON strings.
class Arbiq(gl.Contract):
job_count : u256 # auto-incrementing ID counter
jobs : TreeMap[u256, str] # job_id → JSON job object
messages : TreeMap[u256, str] # job_id → JSON message array
proposals : TreeMap[u256, str] # job_id → JSON proposal array
profiles : TreeMap[str, str] # address → JSON profile objectWrite Methods
post_job(title, description, deadline)payable writetitle: str · description: str · deadline: str · value: GEN (required)
Creates a new job. The GEN sent with the transaction is locked in escrow. Title must be ≥ 3 chars, description ≥ 20 chars. Job starts in OPEN status.
post_job_milestones(title, description, deadline, milestone_titles)payable write… · milestone_titles: list (2–5) · value: GEN (required)
Creates a milestone job. The escrow is split evenly across 2–5 milestones, each delivered and approved (and paid) independently.
apply_to_job(job_id, note, bid)writejob_id: int · note: str (required) · bid: int (0 = at budget)
Freelancer submits a proposal for an OPEN job. One proposal per address. The bid is informational — escrow stays at the posted budget.
accept_proposal(job_id, freelancer)writejob_id: int · freelancer: str
Client assigns one of the applicants. The address must have applied. Transitions status to ACTIVE. This is the primary assignment flow.
take_job(job_id)writejob_id: int
Direct self-assignment (no-proposal fallback). Job must be OPEN; the caller cannot be the client. Transitions status to ACTIVE.
cancel_job(job_id)writejob_id: int
Client cancels an OPEN (unassigned) job and the escrow is refunded. Status becomes CANCELLED.
submit_delivery(job_id, evidence_url, evidence_note)writejob_id: int · evidence_url: str (required) · evidence_note: str
Freelancer marks the job as delivered with a URL pointing to the work (GitHub, live site, Figma, Loom, etc.). Transitions status to DELIVERED.
auto_evaluate(job_id)non-deterministic writejob_id: int · client-only
Client triggers AI consensus evaluation. Validators independently fetch the evidence URL and run an LLM rubric. Untrusted submission content is fenced against prompt injection. If approved, funds transfer automatically. Status becomes COMPLETED or DISPUTED.
release_manually(job_id)writejob_id: int
Client bypasses AI and approves payment directly. Job must be DELIVERED. Only the client can call this. Funds transfer immediately. Status becomes COMPLETED.
resubmit_delivery(job_id, evidence_url, evidence_note)writejob_id: int · evidence_url: str (required) · evidence_note: str
Freelancer resubmits stronger evidence on a DISPUTED job (max 2 resubmits). Status returns to DELIVERED for re-evaluation.
reclaim_expired(job_id)writejob_id: int
Client reclaims escrow when an assigned freelancer never delivered and the deadline has passed (ghost protection). Status becomes CANCELLED.
reclaim_disputed(job_id)writejob_id: int
Client reclaims escrow on a DISPUTED job once the freelancer has exhausted all resubmits. Status becomes REFUNDED — funds are never permanently locked.
rate_freelancer(job_id, stars, review)writejob_id: int · stars: int (1–5) · review: str
Client rates the freelancer on a COMPLETED job (one rating per job). The average is aggregated into the freelancer's on-chain profile.
set_profile(display_name, bio, skills)writedisplay_name: str · bio: str · skills: list
Any address sets its own public profile. Reputation, earnings, and ratings are computed by the contract and can never be forged here.
send_message(job_id, content)writejob_id: int · content: str (max 500 chars)
Appends a message to the job's chat thread. Only the client or assigned freelancer can message. Job must not be OPEN (messaging starts after a freelancer is assigned).
View Methods
get_job(job_id)viewjob_id: int → str (JSON)
Returns a single job as a JSON string. Returns empty string if the job does not exist.
get_all_jobs()view→ str (JSON array)
Returns all jobs ever posted as a JSON array. Used by the browse page for client-side filtering and sorting.
get_jobs_page(offset, limit)viewoffset: int · limit: int (clamped ≤ 50) → str (JSON)
Paginated, newest-first job read. Returns { total, offset, limit, jobs }. Keeps reads bounded as the job count grows.
get_proposals(job_id)viewjob_id: int → str (JSON array)
Returns the proposals submitted for a job. Returns "[]" if none.
get_profile(address)viewaddress: str → str (JSON)
Returns the address's profile: display name, bio, skills, reputation score, completed/disputed counts, total earned, and average star rating.
get_jobs_by_client(address)viewclient: str → str (JSON array)
Returns all jobs posted by the given wallet address. Case-insensitive match.
get_jobs_by_freelancer(address)viewfreelancer: str → str (JSON array)
Returns all jobs taken by the given wallet address. Used by the notification system.
get_messages(job_id)viewjob_id: int → str (JSON array)
Returns the chat message array for a job as a JSON string. Returns "[]" if no messages exist.
get_job_count()view→ int
Returns the total number of jobs ever posted (includes all statuses). Used by the landing page stats.
JOB LIFECYCLE
How a job moves through states from creation to completion.
post_job()
│
▼
┌────────┐ cancel_job() ┌───────────┐
│ OPEN │ ─────────────────────────────────►│ CANCELLED │ (escrow refunded)
└────────┘ └───────────┘
│
│ apply_to_job() → accept_proposal() (or take_job)
▼
┌────────┐ reclaim_expired() (deadline missed)
│ ACTIVE │ ──────────────────────────────────► CANCELLED (escrow refunded)
└────────┘
│ submit_delivery()
▼
┌───────────┐
│ DELIVERED │
└───────────┘
│
├─ auto_evaluate() ─ approved ─► COMPLETED (freelancer paid)
│ └ rejected ─► DISPUTED
│
└─ release_manually() ─► COMPLETED (freelancer paid)
COMPLETED ── rate_freelancer() ──► ★ on-chain rating
DISPUTED ── resubmit_delivery() (≤ 2×) ──► back to DELIVERED
└─ reclaim_disputed() (resubmits used) ──► REFUNDEDapply_to_job() → accept_proposal()by Freelancer / Clientactivecancel_job()by Clientcancelled (refund)submit_delivery()by Assigned freelancerdeliveredreclaim_expired()by Client (past deadline)cancelled (refund)auto_evaluate()by Clientcompleted or disputedrelease_manually()by Clientcompletedresubmit_delivery()by Freelancer (≤ 2×)deliveredreclaim_disputed()by Client (resubmits used)refundedrate_freelancer()by Clientcompleted (★ rated)resubmit_delivery() with stronger evidence up to twice. If all resubmits are exhausted without approval, the client can reclaim_disputed() to refund the escrow — funds are never permanently locked.AI EVALUATION
How GenLayer validators reach consensus on freelance work quality.
auto_evaluate is the only non-deterministiccontract method. It uses GenLayer's gl.eq_principle.strict_eq to ensure all validator nodes must produce the exact same result before the transaction finalizes.
def evaluate() -> str:
prompt = f"""You are an impartial AI judge evaluating freelance work.
JOB: {title}
DESCRIPTION: {description}
BUDGET: {budget} GEN
SUBMISSION:
- Evidence URL: {evidence_url}
- Freelancer note: {evidence_note}
Respond ONLY with this JSON:
{{"approved": bool, "reasoning": str}}"""
result = gl.nondet.exec_prompt(prompt)
return result
result_str = gl.eq_principle.strict_eq(evaluate)
# All validators must agree on this string before tx finalizesWhat AI checks
- Is the evidence URL relevant to the job spec?
- Does the freelancer note describe completing the work?
- Is there a reasonable basis to believe the work is done?
What AI does NOT check
- Code quality or test coverage
- Whether the URL is currently live
- Long-term maintenance or edge cases
Consensus phases during AI evaluation
PROPOSINGLeader validator runs the LLM prompt against your evidenceCOMMITTINGEach other validator independently runs the same prompt and hashes the resultREVEALINGValidators reveal their hashes — all must match (strict_eq)ACCEPTEDSupermajority agreed — payment transfer executes atomicallyTESTING
105 unit tests using the real GenVM SDK — no testnet needed.
The contract ships with a full test suite using gltest direct mode — the official GenLayer test runner that loads the real genlayer-py-std SDK locally from a GitHub release cache. Tests run in ~2 seconds with no wallet and no RPC.
# Run everything: lint + full test suite
npm run check
# Fast AST lint only (~0.1s)
npm run lint
# Full test suite only (~2s)
npm testTest Coverage — 105 tests across 13 classes
TestPostJob11Validation, field defaults, whitespace strippingTestPostJobMilestones9Budget split, remainder, min/max milestone guardsTestTakeJob6Happy path, self-take, double-take, nonexistent jobTestSubmitDelivery7Auth, open-job guard, blank/whitespace URLTestSubmitMilestoneDelivery9Index bounds, non-milestone job, already-delivered guardTestApproveMilestone9Payment per milestone, completion, double-approve guardTestAutoEvaluate15Score avg logic, avg=6 boundary, AI JSON parsing, profilesTestReleaseManually6Auth, reasoning field, profile updateTestResubmitDelivery6Field clearing, max-2 resubmit capTestMessaging8Accumulation, 500-char truncation, open-job guardTestGetProfile5Defaults, reputation score formula (completed / total)TestViewMethods9All view helpers, case-insensitive address filterTestFullLifecycle6End-to-end: AI, manual, dispute→resubmit, milestonesMocking AI & web calls
# Mock any LLM prompt — regex pattern, JSON string response
direct_vm.mock_llm(".*", json.dumps({
"approved": True,
"reasoning": "Work looks good.",
"scores": {
"relevance": 8, "completeness": 8,
"quality": 8, "meets_spec": 8, "professional": 8,
},
"confidence": "high",
}))
# Mock evidence URL fetch — regex pattern, response dict
direct_vm.mock_web(".*github.*", {
"method": "GET", "status": 200, "body": "# Project\nAll done."
})
# Clear between phases when a test needs different responses
direct_vm.clear_mocks()Score-based approval logic
The contract ignores the "approved" field from the LLM and recomputes it from the 5 numeric scores. Tests cover all three boundary cases:
avg=4.6→DISPUTEDrelevance:4 completeness:5 quality:5 meets_spec:4 professional:5avg=6.0→COMPLETEDall scores exactly 6 — boundary passavg=8.0→COMPLETEDall scores 8 — normal approvalavg = sum(scores.values()) / 5 # 5 criteria, each 0–10
approved = avg >= 6.0 # strict ≥ 6 thresholdnpm run lint standalone for a fast pre-commit check.WALLET & NETWORK
Connecting, signing, and staying on the right chain.
Arbiq uses RainbowKit v2 with wagmi v2. Any EIP-1193 compatible wallet works — MetaMask, Coinbase Wallet, WalletConnect-compatible mobile wallets. The app is locked to Bradbury Testnet only.
Network name: GenLayer Bradbury Testnet
Chain ID: 4221
RPC URL: https://rpc-bradbury.genlayer.com
Currency: GEN (18 decimals)
Explorer: https://explorer-bradbury.genlayer.comAutomatic network switch
When you connect your wallet, Arbiq checks the active chain. If it's not Bradbury (4221), it silently calls switchChain(). A banner appears if you decline the switch.
Transaction signing
All write transactions are signed by your connected wallet provider — MetaMask, WalletConnect, etc. The app never has access to your private key. The PRIVATE_KEY env var in the deploy script is only used for server-side contract deployment, not the frontend.
Read-only mode
You can browse jobs and view job details without connecting a wallet. A wallet is only required to post jobs, take jobs, submit deliveries, evaluate, or chat.
NOTIFICATIONS
Real-time job status alerts without WebSockets — pure polling.
Every 15 seconds, the notification system fetches get_jobs_by_client(address) and get_jobs_by_freelancer(address), then diffs the results against a snapshot stored in a React ref. Status transitions trigger notifications.
Notification Types
approved· Job transitions DELIVERED → COMPLETED"Your delivery was AI approved! Funds released for "Job Title"."
disputed· Job transitions DELIVERED → DISPUTED"AI disputed your delivery on Job #12. Re-evaluate?"
delivered· Job transitions ACTIVE → DELIVERED"Freelancer submitted delivery on your Job #5."
taken· Job transitions OPEN → ACTIVE"A freelancer accepted your Job #8."
stale· Open job older than 48 hours (client)"Your job #3 has had no activity for 48+ hours."
localStorage under the key arbiq:notifications (latest 100 kept). The first poll on page load silently seeds the state snapshot — no false positives on login.ON-CHAIN CHAT
Immutable client ↔ freelancer messages stored directly in the contract.
Every message is written on-chain via send_message(job_id, content). Messages are stored as a JSON array in messages: TreeMap[u256, str] keyed by job ID. Only the client and assigned freelancer can send or read messages for a given job.
{
"sender": "0xabc123...",
"content": "I've pushed the initial commit to the repo.",
"role": "freelancer",
"timestamp": 1716000000
}HOOKS REFERENCE
All React hooks exported from useArbiqContract.ts and the utility hooks.
Read Hooks
useGetAllJobs()→ { data: Job[], isLoading, isError }Fetches all jobs on-chain. Refetches every 15 seconds. Used by Browse Jobs and Dashboard pages.
useGetJob(id: number | undefined)→ { data: Job | null, isLoading, refetch }Fetches a single job by ID. Refetches every 10 seconds. Disabled when id is undefined.
useGetMyJobs(address: string | undefined)→ { postedJobs: Job[], activeJobs: Job[] }Derived hook — filters useGetAllJobs() locally by client and freelancer address. No extra contract call.
useGetJobCount()→ { data: number | unknown }Returns total job count. Refetches every 30 seconds. Used by landing page stats.
useGetMessages(jobId: number | undefined)→ { data: ChatMessage[], isLoading }Fetches chat messages for a job. Refetches every 10 seconds. Returns [] if jobId is undefined.
Write Hooks
usePostJob()→ { postJob(params), simulatePostJob(params), txState, reset, isLoading }Sends a payable post_job transaction. params: { title, description, deadline, budgetEth }. budgetEth is converted to wei via parseEther(). AI evaluation timeout: 90s.
usePostJobMilestones()→ { postJobMilestones(params), txState, reset, isLoading }Payable post_job_milestones transaction. params includes milestoneTitles: string[] (2–5). Escrow is split evenly across the milestones.
useApplyToJob()→ { applyToJob(jobId, note, bid?), txState, reset, isLoading }Freelancer submits a proposal for an OPEN job. bid is optional and informational (defaults to the posted budget).
useAcceptProposal()→ { acceptProposal(jobId, freelancer), txState, reset, isLoading }Client assigns one of the applicants. The address must have applied. Transitions the job to ACTIVE.
useGetProposals()→ useQuery → Proposal[]Reads the proposals submitted for a job. Refetches every 30s and after any write.
useTakeJob()→ { takeJob(jobId), txState, reset, isLoading }Direct self-assignment fallback. Fails if job is not OPEN or caller is the client. The proposals flow is preferred.
useCancelJob()→ { cancelJob(jobId), txState, reset, isLoading }Client cancels an OPEN job and reclaims the escrow.
useSubmitDelivery()→ { submitDelivery(jobId, url, note), txState, reset, isLoading }Marks a job as DELIVERED with an evidence URL and optional note. Only the assigned freelancer can call this.
useAutoEvaluate()→ { autoEvaluate(jobId), txState, reset, isLoading }Client-only. Triggers AI consensus evaluation. Uses a 300s retry timeout because LLM inference across validators takes 1–5 minutes.
useRelease()→ { release(jobId), txState, reset, isLoading }Client manually approves and pays the freelancer. Bypasses AI. Only callable by the client when status is DELIVERED.
useResubmitDelivery()→ { resubmitDelivery(jobId, url, note), txState, reset, isLoading }Freelancer resubmits on a DISPUTED job (max 2×), returning it to DELIVERED for re-evaluation.
useReclaimExpired()→ { reclaimExpired(jobId), txState, reset, isLoading }Client reclaims escrow on an ACTIVE job whose deadline passed without a delivery.
useReclaimDisputed()→ { reclaimDisputed(jobId), txState, reset, isLoading }Client reclaims escrow on a DISPUTED job after the freelancer has exhausted all resubmits.
useRateFreelancer()→ { rateFreelancer(jobId, stars, review), txState, reset, isLoading }Client rates the freelancer (1–5★) on a COMPLETED job. One rating per job.
useSetProfile()→ { setProfile(name, bio, skills), txState, reset, isLoading }Updates the connected wallet's public profile (display name, bio, skill tags).
useGetProfile()→ useQuery → FreelancerProfileReads an address's profile: reputation, completed/disputed counts, total earned, and average star rating.
useGetJobsPage()→ useQuery → { total, offset, limit, jobs }Paginated, newest-first job read backed by the contract's get_jobs_page view.
useSendMessage()→ { sendMessage(jobId, content), txState, reset, isLoading }Appends a message to the job's on-chain chat thread. Content is truncated to 500 chars on-chain.
Utility Hooks
useNotifications()→ { notifications, unreadCount, newToast, markAllRead, dismissToast }Polls chain every 15s, diffs job state, fires typed notifications. Persists to localStorage. First poll is silent (no false positives on page load).
useLocalFavorites()→ { favorites: Set<number>, toggle(id), isFavorite(id) }localStorage-backed job favorites under key arbiq:favorites. Hydrated in useEffect to avoid SSR mismatch.
useScrollReveal(staggerMs = 80)→ RefObject<T>Attach the returned ref to a container. All .reveal children animate in with staggered delay when they enter the viewport via IntersectionObserver.
useCountUp(target, duration = 900, trigger = true)→ numberAnimates a number from 0 to target using easeOutExpo over duration ms. Only starts when trigger is true — pair with a scroll reveal intersection flag.
SECURITY MODEL
What Arbiq guarantees and what it does not.
Funds are safe from the client
Once a job is posted, the client cannot withdraw the escrow. The contract has no admin function that lets the client reclaim funds outside of the status machine.
Funds are safe from the platform
There is no admin key or owner address on the contract. No one can drain the escrow except via the defined state transitions (AI approval or manual release by client).
AI is tamper-resistant, not infallible
The AI evaluation uses gl.eq_principle.strict_eq — all validators must independently agree. A single malicious validator cannot alter the outcome. However, the AI can still misjudge a delivery based on insufficient evidence.
Messages are immutable
On-chain messages cannot be deleted or edited. Do not send sensitive personal data through the chat system.
No appeal in DISPUTED state
If AI rejects a delivery, funds remain locked. There is currently no mechanism to appeal or escalate. Write detailed job descriptions and delivery notes to reduce dispute risk.
Frontend is not the source of truth
Always verify transactions on the GenLayer Explorer. The frontend polls chain state and may lag by up to 15 seconds. Never rely solely on UI state for financial decisions.
ROADMAP
Planned features, in-progress work, and shipped milestones.
v0.1 — Shipped
v0.2 — Marketplace Core (Shipped)
v0.2.1 — In Progress
Security Hardening — Shipped
v0.3 — Planned
v1.0 — Future
Contributing
Arbiq is open source. Have an idea or found a bug? Open an issue or PR on GitHub ↗. Feature requests that align with the roadmap are prioritised.