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:
2026-07-16 20:31:29 -07:00
co-authored by Hanzo Dev
commit 436a88e5f6
26 changed files with 3020 additions and 0 deletions
+24
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
*.local
.DS_Store
+22
View File
@@ -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}"]
+50
View File
@@ -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
View File
@@ -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>
+45
View File
@@ -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
+26
View File
@@ -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
+14
View File
@@ -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
+1948
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+93
View File
@@ -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 },
};
});
}
+103
View File
@@ -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>
);
}
+23
View File
@@ -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>
);
}
+24
View File
@@ -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;
+27
View File
@@ -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 },
},
];
+57
View File
@@ -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`;
}
+21
View File
@@ -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;
}
+13
View File
@@ -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>,
);
+44
View File
@@ -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',
},
};
+66
View File
@@ -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',
};
+56
View File
@@ -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;
}
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_RPC_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+23
View File
@@ -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"]
}
+44
View File
@@ -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'),
},
},
},
});