A blockchain escrow platform built on Arc Testnet. Users create fixed-payment jobs, apply to other users' jobs, and send or receive payments through a smart contract. Each user's role — client or freelancer — is determined by their relationship to a specific job, not permanently assigned to their account.
User connects wallet (MetaMask)
↓
Signs a nonce to prove wallet ownership
↓
Creates a job with a fixed USDC payment
↓
Funds the escrow through the ArcMilestone smart contract
↓
Another user applies to the job
↓
Job creator selects an applicant
↓
Selected user performs and submits the work
↓
Job creator approves the submission
↓
Smart contract releases the escrowed USDC to the freelancer
Refund path:
Funded job → deadline passes without approval → client requests refund → smart contract returns USDC
A user is not permanently a client or freelancer. The same wallet can create a job (acting as client) and apply to a different job (acting as freelancer) at the same time.
Alice creates Job #1 → Alice = client, Bob = freelancer
Bob creates Job #2 → Bob = client, Alice = freelancer
The role is always determined by the relationship between a user and a specific job.
arcmilestone/
├── backend/ # Go API — handlers, services, repositories, auth
├── frontend/ # React UI — wallet connection, job lifecycle, dashboard
└── contracts/ # Solidity smart contract — Hardhat + Hardhat Ignition
File: contracts/contracts/ArcMilestone.sol
Network: Arc Testnet
Chain ID: 5042002
Deployed address: 0x8137d832836cd5B8214C07186ab97f9401db2D8D
Explorer: https://testnet.arcscan.app/address/0x8137d832836cd5B8214C07186ab97f9401db2D8D
The contract is the financial authority. It is responsible for:
- Receiving and locking USDC in escrow
- Tracking the total amount currently locked (
totalLocked) - Enforcing job state transitions (
Funded → WorkSubmitted → CompletedorFunded → Refunded) - Releasing payment to the freelancer on approval
- Refunding the client when the deadline passes without submitted work
- Emitting events (
JobCreatedAndFunded,WorkSubmitted,PaymentReleased,JobRefunded) - Reentrancy protection on all fund-moving functions
The database never claims funds are escrowed solely because the frontend says so. Blockchain confirmation is required.
cd contracts
npm install
# Compile
npx hardhat compile
# Run tests (42 tests covering the full lifecycle)
npx hardhat testnpx hardhat ignition deploy ignition/modules/ArcMilestone.ts --network arcTestnetDeployment records live in contracts/ignition/deployments/chain-5042002/ and contracts/DEPLOYMENTS.md.
Language: Go 1.22+
Database: MySQL 8.0+
Key dependency: github.com/decred/dcrd/dcrec/secp256k1 for EIP-191 signature recovery
The backend manages all offchain application state and enforces business rules. The smart contract remains authoritative for escrow funds.
HTTP request
↓
routes/routes.go — CORS + logging applied globally
↓
handlers/ — decode JSON, validate input, call service, write response
↓
services/ — business rules, domain errors, orchestrate repositories
↓
repositories/ — parameterised SQL only, no business logic
↓
MySQL (arcmilestone database)
- Users and wallet identities
- Authentication nonces (hashed, single-use, expiring)
- Jobs (marketplace metadata, status, deadlines)
- Applications (cover letter, estimated days, status)
- Submissions (delivery URL, notes)
- Notifications
- Indexed copies of blockchain events (for display/search)
- Private keys, seed phrases, or wallet passwords
- The authoritative escrow balance (that is the contract)
Copy backend/.env.example to backend/.env and fill in:
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
HTTP server port |
DB_HOST |
127.0.0.1 |
MySQL host |
DB_PORT |
3306 |
MySQL port |
DB_USER |
(required) | MySQL username |
DB_PASSWORD |
(required) | MySQL password |
DB_NAME |
arcmilestone |
MySQL database name |
DB_SSL_MODE |
disable |
disable, preferred, or required |
MYSQL_DSN |
(empty) | Full DSN override — takes precedence over DB_* |
FRONTEND_URL |
http://localhost:5173 |
Allowed CORS origin |
AUTH_SECRET |
(required) | Secret for HMAC-SHA512 session tokens — never commit this |
ARC_RPC_URL |
(empty) | Arc network RPC endpoint |
ARC_CONTRACT_ADDRESS |
(empty) | Deployed ArcMilestone contract address |
# Start MySQL
sudo systemctl start mysql
# Create the database once
mysql -u <user> -p -e "CREATE DATABASE arcmilestone CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
# Apply migrations in order
mysql -u <user> -p arcmilestone < backend/database/migrations/001_initial_schema.sql
mysql -u <user> -p arcmilestone < backend/database/migrations/002_remove_proposed_amount.sqlImportant: MySQL's session timezone must be UTC, or use UTC_TIMESTAMP() in queries that compare stored UTC datetimes. The migrations target the selected database — do not run them without selecting arcmilestone first.
cd backend
go run ./cmd/apiThe server starts on http://localhost:8080. Check it:
curl http://localhost:8080/api/health
# {"service":"ArcMilestone API","status":"ok"}cd backend
go test ./...
go vet ./...
go build ./...
gofmt -l . # should print nothingTests do not require a live database.
All JSON requests require Content-Type: application/json. Protected routes require Authorization: Bearer <token> obtained from POST /api/auth/verify.
| Group | Endpoints |
|---|---|
| Health | GET /api/health |
| Auth | POST /api/auth/nonce, POST /api/auth/verify, GET /api/auth/me, POST /api/auth/logout |
| Users | GET /api/users/me, PATCH /api/users/me |
| Jobs | POST /api/jobs, GET /api/jobs, GET /api/jobs/{id}, PATCH /api/jobs/{id}, POST /api/jobs/{id}/publish, POST /api/jobs/{id}/cancel |
| Applications | POST /api/jobs/{id}/applications, GET /api/jobs/{id}/applications, POST /api/jobs/{id}/applications/{applicationId}/accept, POST /api/jobs/{id}/applications/{applicationId}/reject, GET /api/applications/me, POST /api/applications/{id}/withdraw |
| Submissions | POST /api/jobs/{id}/submission, GET /api/jobs/{id}/submission |
| Notifications | GET /api/notifications, GET /api/notifications/unread, PATCH /api/notifications/{id}/read |
The backend uses EIP-191 personal_sign wallet authentication — no passwords, no private key storage.
- Client calls
POST /api/auth/noncewith the wallet address. - Backend generates a random 32-byte nonce, stores its SHA-256 hash, returns the plaintext. The nonce expires after 10 minutes.
- Client passes the plaintext nonce to MetaMask, which signs it and returns a 65-byte signature.
- Client calls
POST /api/auth/verifywith the address, plaintext nonce, and signature. - Backend verifies the hash matches a valid unused nonce, recovers the signer address from the EIP-191 signature using secp256k1, and checks it matches the claimed address.
- Nonce is marked used (prevents replay). A session token is issued.
- Session token format:
base64url(payload) + "." + base64url(HMAC-SHA512(payload, AUTH_SECRET)). Expires after 24 hours.
draft → open → reviewing_applications → awaiting_funding → in_progress → completed
↘ cancelled
escrow_status is a separate nullable field (funded | work_submitted | completed | refunded) that mirrors onchain state via the blockchain event indexer. The contract is always the authority.
Framework: React 19 + Vite Wallet library: viem v2 Styling: Tailwind CSS v4
Copy frontend/.env.example to frontend/.env:
| Variable | Description |
|---|---|
VITE_API_URL |
Backend base URL (default: http://localhost:8080) |
VITE_CONTRACT_ADDRESS |
Deployed ArcMilestone contract address |
VITE_CHAIN_ID |
Arc Testnet chain ID (5042002) |
cd frontend
npm install
npm run devOpens at http://localhost:5173.
src/
├── context/AppContext.jsx — global auth, jobs, applications, notifications state
├── services/
│ ├── api.js — all backend HTTP calls
│ └── blockchain.js — viem wallet + contract interactions
├── pages/ — one file per route
├── components/ — shared UI components
└── utils/
├── permissions.js — resource-level authorization helpers
└── format.js — date, USDC, address formatting
Authorization is always resource-based, not role-based. permissions.js checks a user's relationship to each specific job:
isJobCreator(job, walletAddress) // job.creator_wallet === walletAddress
isSelectedFreelancer(job, walletAddress) // job.selected_freelancer_wallet === walletAddress
canApply(job, walletAddress, applications)
canFundEscrow(job, walletAddress)
canSubmitWork(job, walletAddress)
canApproveWork(job, walletAddress)There are no permanent client or freelancer roles on the user object.
- Go 1.22+
- Node.js 18+
- MySQL 8.0+
- MetaMask (or compatible EVM wallet)
- Arc Testnet USDC in your test wallet
# 1. Clone and install
git clone <repo>
cd arcmilestone
# 2. Smart contract
cd contracts && npm install && npx hardhat compile
# 3. Backend
cd ../backend
cp .env.example .env
# Fill in DB_USER, DB_PASSWORD, AUTH_SECRET, ARC_CONTRACT_ADDRESS
sudo systemctl start mysql
mysql -u <user> -p -e "CREATE DATABASE arcmilestone CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mysql -u <user> -p arcmilestone < database/migrations/001_initial_schema.sql
mysql -u <user> -p arcmilestone < database/migrations/002_remove_proposed_amount.sql
go run ./cmd/api
# 4. Frontend (new terminal)
cd ../frontend
cp .env.example .env
# Set VITE_CONTRACT_ADDRESS to the deployed contract address
npm install
npm run devOpen http://localhost:5173, click Connect Wallet, approve the MetaMask sign-in prompt, and you are authenticated.
Never store in the database:
- Private keys, seed phrases, or wallet passwords
proposed_amounton applications — the payment is fixed by the job- Permanent
rolefields on users — roles are contextual per job
Never trust the frontend for financial state:
- Do not mark a job as funded because the frontend says so
- Blockchain event confirmation via the indexer is required to update
escrow_status
Authorization always checks the resource:
- "Can this user edit job #10?" → check
job.creator_user_id == caller - "Can this user submit work on job #10?" → check
job.selected_freelancer_wallet == caller - Never check a permanent role field
- Blockchain event listener —
services/arc_listener.gois a stub. Needs to poll or subscribe to Arc contract logs, decode events, and updateescrow_statuson jobs. - Submission update endpoint —
SubmissionRepository.Updateexists but no HTTP endpoint exposes it for recordingdeliverable_hashandsubmission_transaction_hashafter onchain confirmation. - Pagination — all list endpoints return all rows.
- Token revocation — logout is client-side only; no server-side blocklist.
- Rate limiting — no rate limiting on public endpoints.
- Production hardening — TLS termination, structured logging, metrics, health-check DB probe.
# Backend
cd backend
go test ./...
go vet ./...
gofmt -l .
# Frontend
cd frontend
npx tsc --noEmit # type-check (if tsconfig present)
# Smart contract
cd contracts
npx hardhat compile
npx hardhat testAUTH_SECRETandDB_PASSWORDmust never be committed. Both are in.gitignore.- Nonces are stored as SHA-256 hashes only — the plaintext is never persisted.
- Session token MAC uses constant-time comparison (
hmac.Equal) to prevent timing attacks. - The smart contract uses the checks-effects-interactions pattern and a reentrancy guard on all fund-moving functions.
- The contract rejects direct ETH/native-asset payments that arrive outside the expected function calls.