mirror of
https://github.com/luxfi/work-board.git
synced 2026-09-24 00:24:57 +00:00
Zoo DAO work board: standalone repo + CI + k8s manifests
On-chain bounty kanban (Vite+React+viem static SPA) that reads the live BountyV1/EscrowV1/ReputationV1 work-market on Zoo (chain 200200) directly over the CORS-enabled JSON-RPC. Split out of luxfi/dao into its own repo so CI builds a clean image. - .github/workflows/docker.yml: canonical hanzoai/.github reusable build, bakes VITE_RPC_URL=https://api.zoo.network/v1/bc/C/rpc, pushes ghcr.io/luxfi/work-board:sha-<commit> on self-hosted arcd runners. - k8s/: Deployment (2 replicas) + Service (80->3000) + Ingress (hanzoai/ingress class, host work.zoo.network, letsencrypt-prod). Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
name: Docker
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [main, dev, test]
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
uses: hanzoai/.github/.github/workflows/docker-build.yml@main
|
||||||
|
with:
|
||||||
|
image: ghcr.io/luxfi/work-board
|
||||||
|
# Bake the live, CORS-enabled Zoo RPC into the static bundle at build time.
|
||||||
|
# The board reads the on-chain work-market directly from this URL.
|
||||||
|
build-args: |
|
||||||
|
VITE_RPC_URL=https://api.zoo.network/v1/bc/C/rpc
|
||||||
|
runner-amd64: '["self-hosted","linux","amd64"]'
|
||||||
|
runner-arm64: '["self-hosted","linux","arm64"]'
|
||||||
|
runner-deploy: '["self-hosted","linux","amd64"]'
|
||||||
|
secrets: inherit
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
# Multi-stage build for the Zoo DAO /work bounty board (static SPA).
|
||||||
|
# Built by CI (ARC + GHCR) — never `docker build` locally.
|
||||||
|
# VITE_RPC_URL is baked at build time; the board fetches the public, CORS-enabled
|
||||||
|
# Zoo RPC directly (access-control-allow-origin: * verified on api.zoo.network).
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --no-audit --no-fund
|
||||||
|
COPY . .
|
||||||
|
ARG VITE_RPC_URL=https://api.zoo.network/v1/bc/C/rpc
|
||||||
|
ENV VITE_RPC_URL=$VITE_RPC_URL
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Serve the static build with SPA fallback. No nginx/caddy — a minimal static server.
|
||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
RUN npm i -g serve@14 && npm cache clean --force
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
ENV PORT=3000
|
||||||
|
EXPOSE 3000
|
||||||
|
# -s: single-page-app rewrite to index.html; -l: listen port.
|
||||||
|
CMD ["sh", "-c", "serve -s dist -l ${PORT}"]
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Zoo DAO — Work Board
|
||||||
|
|
||||||
|
On-chain bounty kanban for the Zoo DAO — the DAO's "dework". It reads the live
|
||||||
|
BountyV1 / EscrowV1 / ReputationV1 work-market on Zoo (chain 200200) directly over
|
||||||
|
JSON-RPC (no subgraph) and renders it as a kanban board. The differentiator is the
|
||||||
|
**portable on-chain reputation** surfaced on every card: it lives in ReputationV1 and
|
||||||
|
travels with the worker's address, not the platform.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173 (proxies /rpc -> 127.0.0.1:9631 port-forward)
|
||||||
|
npm run build # tsc --noEmit && vite build -> dist/
|
||||||
|
npm run preview # serve the production build
|
||||||
|
|
||||||
|
Point the dev proxy at any node instead of the local port-forward:
|
||||||
|
|
||||||
|
RPC_TARGET=https://api.zoo.network npm run dev
|
||||||
|
|
||||||
|
If the RPC is unreachable, the board renders a faithful dev fixture of live bounty #0
|
||||||
|
(behind the same render path) so the UI stays verifiable; the source badge shows FIXTURE.
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
All chain / addresses / RPC live in one file: `src/config.ts`.
|
||||||
|
For production, build with the real RPC baked in:
|
||||||
|
|
||||||
|
VITE_RPC_URL=https://api.zoo.network/v1/bc/C/rpc npm run build
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
CI (`.github/workflows/docker.yml`) builds the image on every push to `main`
|
||||||
|
via the canonical `hanzoai/.github` reusable and pushes it to
|
||||||
|
`ghcr.io/luxfi/work-board:sha-<commit>` — never built locally. It runs on
|
||||||
|
zoo-k8s (namespace `zoo-mainnet`) behind hanzoai/ingress at
|
||||||
|
**https://work.zoo.network**. Manifests live in `k8s/` (Deployment + Service +
|
||||||
|
Ingress): pin `k8s/deployment.yaml` to the CI tag, then `kubectl apply -f k8s/`.
|
||||||
|
|
||||||
|
## Data source (Zoo 200200)
|
||||||
|
|
||||||
|
| Contract | Address |
|
||||||
|
| ------------ | -------------------------------------------- |
|
||||||
|
| BountyV1 | `0x3EDb4a0104614b4aC12D5babCE984291aE8BE8E7` |
|
||||||
|
| EscrowV1 | `0x095E68282aea751Cc70A2Be565270f1B6AB0229C` |
|
||||||
|
| ReputationV1 | `0xed976852e8c2b1283e4F475845046B679224460D` |
|
||||||
|
| owner | `0x229599f227231d8C90fcF1a78589F5DC4b7A6962` (Zoo DAO Safe) |
|
||||||
|
|
||||||
|
`bountyCount()` + `bounties(i)` give the current struct; `BountyProposed` /
|
||||||
|
`WorkSubmitted` logs give `issueRef` / `deliverableRef`; `ReputationV1.completedOf` /
|
||||||
|
`earnedOf` give the worker's score. ABIs are copied into `src/abi.ts` (no cross-repo import).
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2016%2016'%3E%3Crect%20width='16'%20height='16'%20rx='4'%20fill='%2310b981'/%3E%3Cpath%20d='M4%208l3%203%205-6'%20stroke='white'%20stroke-width='2'%20fill='none'/%3E%3C/svg%3E" />
|
||||||
|
<title>Zoo DAO — Work Board</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: work-board
|
||||||
|
namespace: zoo-mainnet
|
||||||
|
labels:
|
||||||
|
app: work-board
|
||||||
|
spec:
|
||||||
|
replicas: 2
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: work-board
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: work-board
|
||||||
|
spec:
|
||||||
|
# Pull the CI-built image from GHCR (ghcr.io/luxfi/*).
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: ghcr-luxfi
|
||||||
|
containers:
|
||||||
|
- name: work-board
|
||||||
|
# Pinned to the immutable CI tag (ghcr.io/luxfi/work-board:sha-<commit>).
|
||||||
|
# CI builds every push to main; bump this to roll a new build.
|
||||||
|
image: ghcr.io/luxfi/work-board:bootstrap
|
||||||
|
ports:
|
||||||
|
- containerPort: 3000
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 10m
|
||||||
|
memory: 32Mi
|
||||||
|
limits:
|
||||||
|
memory: 128Mi
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 3
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 20
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: work-zoo-network
|
||||||
|
namespace: zoo-mainnet
|
||||||
|
annotations:
|
||||||
|
# hanzoai/ingress (class "ingress") — NEVER nginx/caddy. Same class + issuer
|
||||||
|
# as the sibling api-zoo-network ingress in this namespace.
|
||||||
|
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||||
|
spec:
|
||||||
|
ingressClassName: ingress
|
||||||
|
rules:
|
||||||
|
- host: work.zoo.network
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: work-board
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- work.zoo.network
|
||||||
|
secretName: work-zoo-network-tls
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: work-board
|
||||||
|
namespace: zoo-mainnet
|
||||||
|
labels:
|
||||||
|
app: work-board
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: work-board
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 80
|
||||||
|
targetPort: 3000
|
||||||
Generated
+1948
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "work-board",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Zoo DAO on-chain bounty kanban — reads the live BountyV1/EscrowV1/ReputationV1 work-market over JSON-RPC.",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7",
|
||||||
|
"viem": "^2.55.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vite": "^8.1.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { ADDRESSES, CHAIN_ID, OWNER_LABEL, REFRESH_MS } from './config';
|
||||||
|
import { COLUMNS, State } from './types';
|
||||||
|
import { short, tokenLabel } from './format';
|
||||||
|
import { useBounties, type Source } from './useBounties';
|
||||||
|
import { Column } from './components/Column';
|
||||||
|
|
||||||
|
const REFRESH_S = Math.round(REFRESH_MS / 1000);
|
||||||
|
|
||||||
|
function SourceBadge({ source }: { source: Source | null }) {
|
||||||
|
if (source === null) return null;
|
||||||
|
const live = source === 'live';
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className={`h-1.5 w-1.5 rounded-full ${live ? 'bg-emerald-400 animate-pulse' : 'bg-amber-400'}`} />
|
||||||
|
<span className={`font-medium tracking-wide ${live ? 'text-emerald-300' : 'text-amber-300'}`}>
|
||||||
|
{live ? 'LIVE' : 'FIXTURE'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header() {
|
||||||
|
return (
|
||||||
|
<header className="px-5 pt-5 pb-4 border-b border-neutral-800">
|
||||||
|
<div className="flex items-baseline gap-x-3 gap-y-1 flex-wrap">
|
||||||
|
<h1 className="text-lg font-semibold tracking-tight text-neutral-100">
|
||||||
|
Zoo DAO <span className="text-neutral-500 font-normal">— Work Board</span>
|
||||||
|
</h1>
|
||||||
|
<span className="text-xs text-neutral-500">
|
||||||
|
chain {CHAIN_ID}
|
||||||
|
{' · '}BountyV1{' '}
|
||||||
|
<span className="font-mono text-neutral-400" title={ADDRESSES.bounty}>
|
||||||
|
{short(ADDRESSES.bounty, 6, 6)}
|
||||||
|
</span>
|
||||||
|
{' · '}owner{' '}
|
||||||
|
<span className="text-neutral-400" title={ADDRESSES.owner}>
|
||||||
|
{OWNER_LABEL}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-xs text-neutral-500 max-w-2xl">
|
||||||
|
A permissionless on-chain bounty board. Worker reputation is recorded in{' '}
|
||||||
|
<span className="text-emerald-400/90">ReputationV1</span> and travels with the address —
|
||||||
|
portable, not platform-locked.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Banner({ source, error }: { source: Source | null; error: string | null }) {
|
||||||
|
if (!error) return null;
|
||||||
|
const fixture = source === 'fixture';
|
||||||
|
const msg = fixture
|
||||||
|
? 'Live RPC unreachable — showing the dev fixture of bounty #0 so the board stays verifiable.'
|
||||||
|
: 'Refresh failed — showing the last good on-chain data.';
|
||||||
|
return (
|
||||||
|
<div className="mx-5 mt-3 rounded-md bg-amber-500/10 ring-1 ring-inset ring-amber-500/30 px-3 py-2 text-xs text-amber-200">
|
||||||
|
<span className="font-medium">{msg}</span>
|
||||||
|
<span className="text-amber-200/60 font-mono ml-2 break-all">{error.slice(0, 160)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Spinner() {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-neutral-500">
|
||||||
|
<span className="h-6 w-6 rounded-full border-2 border-neutral-700 border-t-emerald-400 animate-spin" />
|
||||||
|
<span className="text-sm">Reading the work-market…</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectClass =
|
||||||
|
'bg-neutral-900 ring-1 ring-inset ring-neutral-800 rounded px-2 py-1 text-neutral-200 text-xs focus:outline-none focus:ring-neutral-600';
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const { data, loading, error, source, updatedAt } = useBounties();
|
||||||
|
const [stateFilter, setStateFilter] = useState<State | 'all'>('all');
|
||||||
|
const [tokenFilter, setTokenFilter] = useState<string>('all');
|
||||||
|
|
||||||
|
const bounties = data ?? [];
|
||||||
|
|
||||||
|
// Distinct tokens present, for the token filter.
|
||||||
|
const tokens = useMemo(() => {
|
||||||
|
const seen = new Map<string, string>();
|
||||||
|
for (const b of bounties) seen.set(b.token.toLowerCase(), tokenLabel(b.token));
|
||||||
|
return Array.from(seen.entries());
|
||||||
|
}, [bounties]);
|
||||||
|
|
||||||
|
const filtered = useMemo(
|
||||||
|
() => bounties.filter((b) => tokenFilter === 'all' || b.token.toLowerCase() === tokenFilter),
|
||||||
|
[bounties, tokenFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = COLUMNS.filter((c) => stateFilter === 'all' || c.state === stateFilter);
|
||||||
|
const showEmpty = source === 'live' && bounties.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-full flex flex-col">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<div className="px-5 py-2 flex items-center gap-3 text-xs border-b border-neutral-900">
|
||||||
|
<SourceBadge source={source} />
|
||||||
|
<span className="text-neutral-500 tabular-nums">{bounties.length} bounties</span>
|
||||||
|
{updatedAt && (
|
||||||
|
<span className="text-neutral-600">updated {new Date(updatedAt).toLocaleTimeString()}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-neutral-600 ml-auto">auto-refresh {REFRESH_S}s</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 py-3 flex items-center gap-4 flex-wrap border-b border-neutral-900">
|
||||||
|
<label className="flex items-center gap-2 text-xs text-neutral-500">
|
||||||
|
state
|
||||||
|
<select
|
||||||
|
className={selectClass}
|
||||||
|
value={stateFilter === 'all' ? 'all' : String(stateFilter)}
|
||||||
|
onChange={(e) => setStateFilter(e.target.value === 'all' ? 'all' : (Number(e.target.value) as State))}
|
||||||
|
>
|
||||||
|
<option value="all">all</option>
|
||||||
|
{COLUMNS.map((c) => (
|
||||||
|
<option key={c.state} value={c.state}>
|
||||||
|
{c.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-neutral-500">
|
||||||
|
token
|
||||||
|
<select className={selectClass} value={tokenFilter} onChange={(e) => setTokenFilter(e.target.value)}>
|
||||||
|
<option value="all">all</option>
|
||||||
|
{tokens.map(([addr, label]) => (
|
||||||
|
<option key={addr} value={addr}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Banner source={source} error={error} />
|
||||||
|
|
||||||
|
{loading && data === null ? (
|
||||||
|
<Spinner />
|
||||||
|
) : showEmpty ? (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-neutral-500">
|
||||||
|
<span className="text-2xl">◇</span>
|
||||||
|
<span className="text-sm">No bounties on-chain yet.</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<main className="flex-1 overflow-x-auto p-5">
|
||||||
|
<div className="flex gap-4 min-w-max items-start">
|
||||||
|
{columns.map((c) => (
|
||||||
|
<Column key={c.state} def={c} bounties={filtered.filter((b) => b.state === c.state)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
// ABI fragments copied from the compiled Foundry artifacts
|
||||||
|
// contracts/out-foundry/BountyV1.sol/BountyV1.json
|
||||||
|
// contracts/out-foundry/ReputationV1.sol/ReputationV1.json
|
||||||
|
// Only the members this board actually reads are included (no cross-repo import).
|
||||||
|
|
||||||
|
export const bountyAbi = [
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
name: 'bountyCount',
|
||||||
|
inputs: [],
|
||||||
|
outputs: [{ name: '', type: 'uint256' }],
|
||||||
|
stateMutability: 'view',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
name: 'bounties',
|
||||||
|
inputs: [{ name: 'bountyId_', type: 'uint256' }],
|
||||||
|
outputs: [
|
||||||
|
{
|
||||||
|
name: '',
|
||||||
|
type: 'tuple',
|
||||||
|
components: [
|
||||||
|
{ name: 'state', type: 'uint8' },
|
||||||
|
{ name: 'token', type: 'address' },
|
||||||
|
{ name: 'funder', type: 'address' },
|
||||||
|
{ name: 'approver', type: 'address' },
|
||||||
|
{ name: 'arbiter', type: 'address' },
|
||||||
|
{ name: 'worker', type: 'address' },
|
||||||
|
{ name: 'reward', type: 'uint256' },
|
||||||
|
{ name: 'stake', type: 'uint256' },
|
||||||
|
{ name: 'claimDeadline', type: 'uint64' },
|
||||||
|
{ name: 'claimWindow', type: 'uint64' },
|
||||||
|
{ name: 'claimNonce', type: 'uint64' },
|
||||||
|
{ name: 'reviewWindow', type: 'uint64' },
|
||||||
|
{ name: 'reviewDeadline', type: 'uint64' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stateMutability: 'view',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const bountyProposedEvent = {
|
||||||
|
type: 'event',
|
||||||
|
name: 'BountyProposed',
|
||||||
|
anonymous: false,
|
||||||
|
inputs: [
|
||||||
|
{ name: 'bountyId', type: 'uint256', indexed: true },
|
||||||
|
{ name: 'funder', type: 'address', indexed: true },
|
||||||
|
{ name: 'approver', type: 'address', indexed: true },
|
||||||
|
{ name: 'token', type: 'address', indexed: false },
|
||||||
|
{ name: 'reward', type: 'uint256', indexed: false },
|
||||||
|
{ name: 'stake', type: 'uint256', indexed: false },
|
||||||
|
{ name: 'issueRef', type: 'string', indexed: false },
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const workSubmittedEvent = {
|
||||||
|
type: 'event',
|
||||||
|
name: 'WorkSubmitted',
|
||||||
|
anonymous: false,
|
||||||
|
inputs: [
|
||||||
|
{ name: 'bountyId', type: 'uint256', indexed: true },
|
||||||
|
{ name: 'worker', type: 'address', indexed: true },
|
||||||
|
{ name: 'deliverableRef', type: 'string', indexed: false },
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const reputationAbi = [
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
name: 'completedOf',
|
||||||
|
inputs: [{ name: 'worker_', type: 'address' }],
|
||||||
|
outputs: [{ name: '', type: 'uint64' }],
|
||||||
|
stateMutability: 'view',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
name: 'earnedOf',
|
||||||
|
inputs: [{ name: 'worker_', type: 'address' }],
|
||||||
|
outputs: [{ name: '', type: 'uint256' }],
|
||||||
|
stateMutability: 'view',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { createPublicClient, defineChain, http } from 'viem';
|
||||||
|
import { bountyAbi, bountyProposedEvent, reputationAbi, workSubmittedEvent } from './abi';
|
||||||
|
import { ADDRESSES, CHAIN_ID, CHAIN_NAME, NATIVE_SYMBOL, RPC_URL } from './config';
|
||||||
|
import { isZero } from './format';
|
||||||
|
import type { Bounty, BountyView, Reputation } from './types';
|
||||||
|
|
||||||
|
// Resolve a root-relative RPC ('/rpc', dev proxy) to an absolute URL for viem/fetch.
|
||||||
|
function resolveUrl(u: string): string {
|
||||||
|
return u.startsWith('/') && typeof window !== 'undefined' ? window.location.origin + u : u;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rpc = resolveUrl(RPC_URL);
|
||||||
|
|
||||||
|
export const zooChain = defineChain({
|
||||||
|
id: CHAIN_ID,
|
||||||
|
name: CHAIN_NAME,
|
||||||
|
nativeCurrency: { name: CHAIN_NAME, symbol: NATIVE_SYMBOL, decimals: 18 },
|
||||||
|
rpcUrls: { default: { http: [rpc] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = createPublicClient({
|
||||||
|
chain: zooChain,
|
||||||
|
transport: http(rpc, { batch: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read the entire work-market and assemble the board. This is the real path.
|
||||||
|
export async function loadBoard(): Promise<BountyView[]> {
|
||||||
|
const count = (await client.readContract({
|
||||||
|
address: ADDRESSES.bounty,
|
||||||
|
abi: bountyAbi,
|
||||||
|
functionName: 'bountyCount',
|
||||||
|
})) as bigint;
|
||||||
|
|
||||||
|
const n = Number(count);
|
||||||
|
if (n === 0) return [];
|
||||||
|
|
||||||
|
const ids = Array.from({ length: n }, (_, i) => i);
|
||||||
|
|
||||||
|
// Current struct per bounty (batched into one JSON-RPC request).
|
||||||
|
const bounties = (await Promise.all(
|
||||||
|
ids.map((id) =>
|
||||||
|
client.readContract({
|
||||||
|
address: ADDRESSES.bounty,
|
||||||
|
abi: bountyAbi,
|
||||||
|
functionName: 'bounties',
|
||||||
|
args: [BigInt(id)],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)) as unknown as Bounty[];
|
||||||
|
|
||||||
|
// issueRef / deliverableRef live only in events, not storage.
|
||||||
|
const [proposed, submitted] = await Promise.all([
|
||||||
|
client.getLogs({ address: ADDRESSES.bounty, event: bountyProposedEvent, fromBlock: 0n, toBlock: 'latest' }),
|
||||||
|
client.getLogs({ address: ADDRESSES.bounty, event: workSubmittedEvent, fromBlock: 0n, toBlock: 'latest' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const issueRefById = new Map<number, string>();
|
||||||
|
for (const log of proposed) {
|
||||||
|
const id = log.args.bountyId;
|
||||||
|
const ref = log.args.issueRef;
|
||||||
|
if (id !== undefined && ref !== undefined) issueRefById.set(Number(id), ref);
|
||||||
|
}
|
||||||
|
const deliverableById = new Map<number, string>();
|
||||||
|
for (const log of submitted) {
|
||||||
|
const id = log.args.bountyId;
|
||||||
|
const ref = log.args.deliverableRef;
|
||||||
|
if (id !== undefined && ref !== undefined) deliverableById.set(Number(id), ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Portable on-chain reputation, one lookup per distinct non-zero worker.
|
||||||
|
const workers = Array.from(new Set(bounties.filter((b) => !isZero(b.worker)).map((b) => b.worker)));
|
||||||
|
const repEntries = await Promise.all(
|
||||||
|
workers.map(async (w) => {
|
||||||
|
const [completed, earned] = await Promise.all([
|
||||||
|
client.readContract({ address: ADDRESSES.reputation, abi: reputationAbi, functionName: 'completedOf', args: [w] }),
|
||||||
|
client.readContract({ address: ADDRESSES.reputation, abi: reputationAbi, functionName: 'earnedOf', args: [w] }),
|
||||||
|
]);
|
||||||
|
return [w.toLowerCase(), { completed: completed as bigint, earned: earned as bigint }] as const;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const repByWorker = new Map<string, Reputation>(repEntries);
|
||||||
|
|
||||||
|
return ids.map((id) => {
|
||||||
|
const b = bounties[id];
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
...b,
|
||||||
|
issueRef: issueRefById.get(id),
|
||||||
|
deliverableRef: deliverableById.get(id),
|
||||||
|
reputation: repByWorker.get(b.worker.toLowerCase()) ?? { completed: 0n, earned: 0n },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { BountyView } from '../types';
|
||||||
|
import { STATE_TITLE } from '../types';
|
||||||
|
import { ACCENT } from '../theme';
|
||||||
|
import { formatAmount, isZero, refToLink, relTime, short, tokenLabel } from '../format';
|
||||||
|
|
||||||
|
function AddrRow({ label, addr }: { label: string; addr: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-neutral-500">{label}</span>
|
||||||
|
<span className="font-mono text-neutral-300" title={addr}>
|
||||||
|
{short(addr)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RefRow({ label, value }: { label: string; value: string }) {
|
||||||
|
const { href, text } = refToLink(value);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span className="text-neutral-500 shrink-0">{label}</span>
|
||||||
|
{href ? (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
className="truncate text-sky-400 hover:text-sky-300 underline decoration-dotted underline-offset-2"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="truncate text-neutral-300">{text}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BountyCard({ b }: { b: BountyView }) {
|
||||||
|
const accent = ACCENT[b.state as keyof typeof ACCENT];
|
||||||
|
const claimed = !isZero(b.worker);
|
||||||
|
const hasDeadline = b.claimDeadline > 0n || b.reviewDeadline > 0n;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="rounded-lg bg-neutral-900 ring-1 ring-inset ring-neutral-800 hover:ring-neutral-700 transition-colors p-3 flex flex-col gap-2.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-mono text-sm text-neutral-400">#{b.id}</span>
|
||||||
|
<span className={`text-[11px] px-2 py-0.5 rounded-full ${accent.badge}`}>{STATE_TITLE[b.state as keyof typeof STATE_TITLE]}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-baseline gap-1.5">
|
||||||
|
<span className="text-xl font-semibold text-neutral-100 tabular-nums">{formatAmount(b.reward)}</span>
|
||||||
|
<span className="text-xs text-neutral-500">{tokenLabel(b.token)}</span>
|
||||||
|
<span className="ml-auto text-xs text-neutral-500 tabular-nums">stake {formatAmount(b.stake)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1 text-xs">
|
||||||
|
<AddrRow label="funder" addr={b.funder} />
|
||||||
|
<AddrRow label="approver" addr={b.approver} />
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-neutral-500">worker</span>
|
||||||
|
{claimed ? (
|
||||||
|
<span className="font-mono text-neutral-300" title={b.worker}>
|
||||||
|
{short(b.worker)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-neutral-600 italic">unclaimed</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{claimed && (
|
||||||
|
<div
|
||||||
|
className="rounded-md bg-emerald-500/10 ring-1 ring-inset ring-emerald-500/30 px-2.5 py-1.5"
|
||||||
|
title="Portable on-chain reputation (ReputationV1) — travels with the worker's address, not the platform"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-emerald-400">✓</span>
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-emerald-500/80 font-semibold">
|
||||||
|
on-chain rep
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto text-xs text-emerald-200 tabular-nums">
|
||||||
|
{b.reputation.completed.toString()} completed · {formatAmount(b.reputation.earned)} earned
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(b.issueRef || b.deliverableRef) && (
|
||||||
|
<div className="flex flex-col gap-1 text-xs">
|
||||||
|
{b.issueRef && <RefRow label="issue" value={b.issueRef} />}
|
||||||
|
{b.deliverableRef && <RefRow label="PR" value={b.deliverableRef} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasDeadline && (
|
||||||
|
<div className="flex items-center gap-3 text-[11px] text-neutral-500 pt-1.5 border-t border-neutral-800/70">
|
||||||
|
{b.claimDeadline > 0n && <span>claim {relTime(b.claimDeadline)}</span>}
|
||||||
|
{b.reviewDeadline > 0n && <span>review {relTime(b.reviewDeadline)}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { BountyView, ColumnDef } from '../types';
|
||||||
|
import { ACCENT } from '../theme';
|
||||||
|
import { BountyCard } from './BountyCard';
|
||||||
|
|
||||||
|
export function Column({ def, bounties }: { def: ColumnDef; bounties: BountyView[] }) {
|
||||||
|
const accent = ACCENT[def.state];
|
||||||
|
return (
|
||||||
|
<section className="w-80 shrink-0 flex flex-col">
|
||||||
|
<div className="flex items-center gap-2 px-1 pb-2">
|
||||||
|
<span className={`h-2 w-2 rounded-full ${accent.dot}`} />
|
||||||
|
<h2 className="text-sm font-medium text-neutral-200">{def.title}</h2>
|
||||||
|
<span className="ml-auto text-xs text-neutral-500 tabular-nums">{bounties.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 rounded-lg bg-neutral-900/30 ring-1 ring-inset ring-neutral-800/70 p-2 min-h-24">
|
||||||
|
{bounties.length === 0 ? (
|
||||||
|
<div className="text-xs text-neutral-600 px-2 py-6 text-center select-none">—</div>
|
||||||
|
) : (
|
||||||
|
bounties.map((b) => <BountyCard key={b.id} b={b} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Address } from 'viem';
|
||||||
|
|
||||||
|
// The one place chain / addresses / RPC live.
|
||||||
|
|
||||||
|
export const CHAIN_ID = 200200;
|
||||||
|
export const CHAIN_NAME = 'Zoo';
|
||||||
|
export const NATIVE_SYMBOL = 'ZOO';
|
||||||
|
|
||||||
|
// Dev: '/rpc' is proxied by Vite to the port-forwarded node (see vite.config.ts).
|
||||||
|
// Prod: build with VITE_RPC_URL=https://api.zoo.network/v1/bc/C/rpc
|
||||||
|
export const RPC_URL: string = import.meta.env.VITE_RPC_URL ?? '/rpc';
|
||||||
|
|
||||||
|
export const ADDRESSES = {
|
||||||
|
bounty: '0x3EDb4a0104614b4aC12D5babCE984291aE8BE8E7',
|
||||||
|
escrow: '0x095E68282aea751Cc70A2Be565270f1B6AB0229C',
|
||||||
|
reputation: '0xed976852e8c2b1283e4F475845046B679224460D',
|
||||||
|
owner: '0x229599f227231d8C90fcF1a78589F5DC4b7A6962',
|
||||||
|
} as const satisfies Record<string, Address>;
|
||||||
|
|
||||||
|
export const OWNER_LABEL = 'Zoo DAO Safe';
|
||||||
|
|
||||||
|
export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
|
||||||
|
|
||||||
|
export const REFRESH_MS = 15_000;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { State } from './types';
|
||||||
|
import type { BountyView } from './types';
|
||||||
|
|
||||||
|
// Faithful mirror of LIVE bounty #0 on Zoo 200200 (read from BountyV1 /
|
||||||
|
// ReputationV1 at build time). Rendered — behind the same path as live data —
|
||||||
|
// only when the RPC is unreachable, so the UI stays verifiable offline.
|
||||||
|
export const FIXTURE: BountyView[] = [
|
||||||
|
{
|
||||||
|
id: 0,
|
||||||
|
state: State.Paid,
|
||||||
|
token: '0x0000000000000000000000000000000000000000',
|
||||||
|
funder: '0x9011E888251AB053B7bD1cdB598Db4f9DEd94714',
|
||||||
|
approver: '0xB4e242f9417872A843B2D0b92FCf89055349ABb5',
|
||||||
|
arbiter: '0xB4e242f9417872A843B2D0b92FCf89055349ABb5',
|
||||||
|
worker: '0x9011E888251AB053B7bD1cdB598Db4f9DEd94714',
|
||||||
|
reward: 200000000000000n,
|
||||||
|
stake: 100000000000000n,
|
||||||
|
claimDeadline: 1784247266n,
|
||||||
|
claimWindow: 3600n,
|
||||||
|
claimNonce: 0n,
|
||||||
|
reviewWindow: 3600n,
|
||||||
|
reviewDeadline: 1784247266n,
|
||||||
|
issueRef: 'smoke',
|
||||||
|
deliverableRef: 'smoke-deliverable',
|
||||||
|
reputation: { completed: 1n, earned: 200000000000000n },
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { formatEther } from 'viem';
|
||||||
|
import type { Address } from 'viem';
|
||||||
|
import { ZERO_ADDRESS, NATIVE_SYMBOL } from './config';
|
||||||
|
|
||||||
|
export function isZero(addr: string): boolean {
|
||||||
|
return addr.toLowerCase() === ZERO_ADDRESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate an address: 0x1234…abcd
|
||||||
|
export function short(addr: string, head = 6, tail = 4): string {
|
||||||
|
if (!addr || addr.length <= head + tail) return addr;
|
||||||
|
return `${addr.slice(0, head)}…${addr.slice(-tail)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reward/stake are stored in wei; render with the native symbol or a short token addr.
|
||||||
|
export function tokenLabel(token: Address): string {
|
||||||
|
return isZero(token) ? NATIVE_SYMBOL.toLowerCase() : short(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAmount(value: bigint): string {
|
||||||
|
return formatEther(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// owner/repo#n -> GitHub issue link; a bare URL -> itself; anything else -> plain text.
|
||||||
|
const GH_REF = /^([A-Za-z0-9._-]+\/[A-Za-z0-9._-]+)#(\d+)$/;
|
||||||
|
|
||||||
|
export function refToLink(ref: string): { href?: string; text: string } {
|
||||||
|
const t = ref.trim();
|
||||||
|
if (!t) return { text: '' };
|
||||||
|
if (/^https?:\/\//i.test(t)) return { href: t, text: t };
|
||||||
|
const m = t.match(GH_REF);
|
||||||
|
if (m) return { href: `https://github.com/${m[1]}/issues/${m[2]}`, text: t };
|
||||||
|
return { text: t };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact relative time from a unix-seconds deadline. 0 => not set => ''.
|
||||||
|
export function relTime(unixSec: bigint): string {
|
||||||
|
if (unixSec === 0n) return '';
|
||||||
|
const diff = Number(unixSec) - Date.now() / 1000; // >0 future, <0 past
|
||||||
|
const abs = Math.abs(diff);
|
||||||
|
const steps: [limit: number, div: number, suffix: string][] = [
|
||||||
|
[60, 1, 's'],
|
||||||
|
[3600, 60, 'm'],
|
||||||
|
[86400, 3600, 'h'],
|
||||||
|
[2592000, 86400, 'd'],
|
||||||
|
[31536000, 2592000, 'mo'],
|
||||||
|
[Number.POSITIVE_INFINITY, 31536000, 'y'],
|
||||||
|
];
|
||||||
|
let out = '0s';
|
||||||
|
for (const [limit, div, suffix] of steps) {
|
||||||
|
if (abs < limit) {
|
||||||
|
out = `${Math.max(0, Math.floor(abs / div))}${suffix}`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return diff >= 0 ? `in ${out}` : `${out} ago`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background-color: #0a0a0a;
|
||||||
|
color: #e5e5e5;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import './index.css';
|
||||||
|
import { App } from './App';
|
||||||
|
|
||||||
|
const root = document.getElementById('root');
|
||||||
|
if (!root) throw new Error('#root not found');
|
||||||
|
|
||||||
|
createRoot(root).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { State } from './types';
|
||||||
|
|
||||||
|
// Per-state accent classes. Written out as literal strings so Tailwind's
|
||||||
|
// content scanner can see (and emit) every one — never build these dynamically.
|
||||||
|
type Accent = { dot: string; badge: string };
|
||||||
|
|
||||||
|
export const ACCENT: Record<State, Accent> = {
|
||||||
|
[State.None]: {
|
||||||
|
dot: 'bg-neutral-500',
|
||||||
|
badge: 'bg-neutral-500/10 text-neutral-400 ring-1 ring-inset ring-neutral-500/30',
|
||||||
|
},
|
||||||
|
[State.Open]: {
|
||||||
|
dot: 'bg-sky-400',
|
||||||
|
badge: 'bg-sky-500/10 text-sky-300 ring-1 ring-inset ring-sky-500/30',
|
||||||
|
},
|
||||||
|
[State.Funded]: {
|
||||||
|
dot: 'bg-violet-400',
|
||||||
|
badge: 'bg-violet-500/10 text-violet-300 ring-1 ring-inset ring-violet-500/30',
|
||||||
|
},
|
||||||
|
[State.Claimed]: {
|
||||||
|
dot: 'bg-amber-400',
|
||||||
|
badge: 'bg-amber-500/10 text-amber-300 ring-1 ring-inset ring-amber-500/30',
|
||||||
|
},
|
||||||
|
[State.Submitted]: {
|
||||||
|
dot: 'bg-blue-400',
|
||||||
|
badge: 'bg-blue-500/10 text-blue-300 ring-1 ring-inset ring-blue-500/30',
|
||||||
|
},
|
||||||
|
[State.Accepted]: {
|
||||||
|
dot: 'bg-teal-400',
|
||||||
|
badge: 'bg-teal-500/10 text-teal-300 ring-1 ring-inset ring-teal-500/30',
|
||||||
|
},
|
||||||
|
[State.Paid]: {
|
||||||
|
dot: 'bg-emerald-400',
|
||||||
|
badge: 'bg-emerald-500/10 text-emerald-300 ring-1 ring-inset ring-emerald-500/30',
|
||||||
|
},
|
||||||
|
[State.Disputed]: {
|
||||||
|
dot: 'bg-rose-400',
|
||||||
|
badge: 'bg-rose-500/10 text-rose-300 ring-1 ring-inset ring-rose-500/30',
|
||||||
|
},
|
||||||
|
[State.Cancelled]: {
|
||||||
|
dot: 'bg-neutral-500',
|
||||||
|
badge: 'bg-neutral-500/10 text-neutral-400 ring-1 ring-inset ring-neutral-500/30',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { Address } from 'viem';
|
||||||
|
|
||||||
|
// BountyV1 lifecycle. uint8 on-chain; Accepted (5) is unused by the contract.
|
||||||
|
export enum State {
|
||||||
|
None = 0,
|
||||||
|
Open = 1,
|
||||||
|
Funded = 2,
|
||||||
|
Claimed = 3,
|
||||||
|
Submitted = 4,
|
||||||
|
Accepted = 5,
|
||||||
|
Paid = 6,
|
||||||
|
Disputed = 7,
|
||||||
|
Cancelled = 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
// The current bounties(i) struct, decoded.
|
||||||
|
export type Bounty = {
|
||||||
|
state: number;
|
||||||
|
token: Address;
|
||||||
|
funder: Address;
|
||||||
|
approver: Address;
|
||||||
|
arbiter: Address;
|
||||||
|
worker: Address;
|
||||||
|
reward: bigint;
|
||||||
|
stake: bigint;
|
||||||
|
claimDeadline: bigint;
|
||||||
|
claimWindow: bigint;
|
||||||
|
claimNonce: bigint;
|
||||||
|
reviewWindow: bigint;
|
||||||
|
reviewDeadline: bigint;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Reputation = { completed: bigint; earned: bigint };
|
||||||
|
|
||||||
|
// A bounty assembled for the board: struct + event-only refs + worker reputation.
|
||||||
|
export type BountyView = Bounty & {
|
||||||
|
id: number;
|
||||||
|
issueRef?: string;
|
||||||
|
deliverableRef?: string;
|
||||||
|
reputation: Reputation;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ColumnDef = { state: State; title: string };
|
||||||
|
|
||||||
|
// Board columns, left to right.
|
||||||
|
export const COLUMNS: ColumnDef[] = [
|
||||||
|
{ state: State.Open, title: 'Open' },
|
||||||
|
{ state: State.Funded, title: 'Funded' },
|
||||||
|
{ state: State.Claimed, title: 'Claimed' },
|
||||||
|
{ state: State.Submitted, title: 'In Review' },
|
||||||
|
{ state: State.Paid, title: 'Paid' },
|
||||||
|
{ state: State.Disputed, title: 'Disputed' },
|
||||||
|
{ state: State.Cancelled, title: 'Cancelled' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const STATE_TITLE: Record<State, string> = {
|
||||||
|
[State.None]: 'None',
|
||||||
|
[State.Open]: 'Open',
|
||||||
|
[State.Funded]: 'Funded',
|
||||||
|
[State.Claimed]: 'Claimed',
|
||||||
|
[State.Submitted]: 'In Review',
|
||||||
|
[State.Accepted]: 'Accepted',
|
||||||
|
[State.Paid]: 'Paid',
|
||||||
|
[State.Disputed]: 'Disputed',
|
||||||
|
[State.Cancelled]: 'Cancelled',
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { REFRESH_MS } from './config';
|
||||||
|
import { loadBoard } from './chain';
|
||||||
|
import { FIXTURE } from './fixture';
|
||||||
|
import type { BountyView } from './types';
|
||||||
|
|
||||||
|
export type Source = 'live' | 'fixture';
|
||||||
|
|
||||||
|
export type BoardState = {
|
||||||
|
data: BountyView[] | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
source: Source | null;
|
||||||
|
updatedAt: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Polls the live board every REFRESH_MS. On failure: keep the last good live
|
||||||
|
// data if we had it (stale), otherwise fall back to the fixture so the UI renders.
|
||||||
|
export function useBounties(): BoardState {
|
||||||
|
const [state, setState] = useState<BoardState>({
|
||||||
|
data: null,
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
source: null,
|
||||||
|
updatedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
try {
|
||||||
|
const data = await loadBoard();
|
||||||
|
if (!alive) return;
|
||||||
|
setState({ data, loading: false, error: null, source: 'live', updatedAt: Date.now() });
|
||||||
|
} catch (err) {
|
||||||
|
if (!alive) return;
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
setState((prev) =>
|
||||||
|
prev.source === 'live' && prev.data
|
||||||
|
? { ...prev, loading: false, error: message }
|
||||||
|
: { data: FIXTURE, loading: false, error: message, source: 'fixture', updatedAt: Date.now() },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void tick();
|
||||||
|
const timer = setInterval(() => void tick(), REFRESH_MS);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_RPC_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { defineConfig, type Plugin, type IndexHtmlTransformContext } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
|
||||||
|
// Dev proxy target. Defaults to the kubectl port-forward; override to read a
|
||||||
|
// remote node, e.g. `RPC_TARGET=https://api.zoo.network npm run dev`.
|
||||||
|
const RPC_TARGET = process.env.RPC_TARGET || 'http://127.0.0.1:9631';
|
||||||
|
|
||||||
|
// Inject a Content-Security-Policy meta tag. Strict in production (no external
|
||||||
|
// hosts; only the Zoo RPC for data); relaxed in dev so Vite HMR works. The app
|
||||||
|
// ships zero external CDNs/fonts/scripts, so the strict policy is a tight fit.
|
||||||
|
function csp(): Plugin {
|
||||||
|
const prod =
|
||||||
|
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
|
||||||
|
"img-src 'self' data:; font-src 'self'; connect-src 'self' https://api.zoo.network; " +
|
||||||
|
"base-uri 'self'; frame-ancestors 'none'; object-src 'none'";
|
||||||
|
const dev =
|
||||||
|
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
|
||||||
|
"style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; " +
|
||||||
|
"connect-src 'self' ws: http: https:; base-uri 'self'; object-src 'none'";
|
||||||
|
return {
|
||||||
|
name: 'csp-meta',
|
||||||
|
transformIndexHtml(html: string, ctx: IndexHtmlTransformContext) {
|
||||||
|
const policy = ctx.server ? dev : prod;
|
||||||
|
return html.replace(
|
||||||
|
'</title>',
|
||||||
|
`</title>\n <meta http-equiv="Content-Security-Policy" content="${policy}" />`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss(), csp()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/rpc': {
|
||||||
|
target: RPC_TARGET,
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/rpc/, '/v1/bc/C/rpc'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user