@blockrun/clawrouter 0.12.62 → 0.12.63
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/anthropic-cost-savings.md +349 -0
- package/docs/architecture.md +559 -0
- package/docs/assets/blockrun-248-day-cost-overrun-problem.png +0 -0
- package/docs/assets/blockrun-clawrouter-7-layer-token-compression-openclaw.png +0 -0
- package/docs/assets/blockrun-clawrouter-observation-compression-97-percent-token-savings.png +0 -0
- package/docs/assets/blockrun-clawrouter-openclaw-agentic-proxy-architecture.png +0 -0
- package/docs/assets/blockrun-clawrouter-openclaw-automatic-tier-routing-model-selection.png +0 -0
- package/docs/assets/blockrun-clawrouter-openclaw-error-classification-retry-storm-prevention.png +0 -0
- package/docs/assets/blockrun-clawrouter-openclaw-session-memory-journaling-vs-context-compounding.png +0 -0
- package/docs/assets/blockrun-clawrouter-vs-openclaw-standalone-comparison-production-safety.png +0 -0
- package/docs/assets/blockrun-clawrouter-x402-usdc-micropayment-wallet-budget-control.png +0 -0
- package/docs/assets/blockrun-openclaw-inference-layer-blind-spots.png +0 -0
- package/docs/blog-benchmark-2026-03.md +184 -0
- package/docs/blog-openclaw-cost-overruns.md +197 -0
- package/docs/clawrouter-savings.png +0 -0
- package/docs/configuration.md +512 -0
- package/docs/features.md +257 -0
- package/docs/image-generation.md +380 -0
- package/docs/plans/2026-02-03-smart-routing-design.md +267 -0
- package/docs/plans/2026-02-13-e2e-docker-deployment.md +1260 -0
- package/docs/plans/2026-02-28-worker-network.md +947 -0
- package/docs/plans/2026-03-18-error-classification.md +574 -0
- package/docs/plans/2026-03-19-exclude-models.md +538 -0
- package/docs/routing-profiles.md +81 -0
- package/docs/subscription-failover.md +320 -0
- package/docs/technical-routing-2026-03.md +322 -0
- package/docs/troubleshooting.md +159 -0
- package/docs/vision.md +49 -0
- package/docs/vs-openrouter.md +157 -0
- package/docs/worker-network.md +1241 -0
- package/package.json +2 -1
|
@@ -0,0 +1,1241 @@
|
|
|
1
|
+
# BlockRun Worker Network
|
|
2
|
+
|
|
3
|
+
> **For Claude implementing this:** Use `superpowers:executing-plans` to implement the tasks section task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** Let ClawRouter users opt in as worker nodes — poll tasks, execute HTTP checks, earn USDC via x402 micropayments.
|
|
6
|
+
|
|
7
|
+
**Architecture:** ClawRouter polls every 30s, signs results with existing wallet key. BlockRun verifies signature, writes to DB, triggers batched x402 payout at $0.01 threshold, simultaneously writes calldata log tx to Base for immutable audit trail.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** viem (signing + calldata tx), x402 reversed payTo (worker payout), DB (credits ledger), GCS (result logs + reputation source), Base calldata (audit trail)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Overview
|
|
14
|
+
|
|
15
|
+
ClawRouter Worker Mode transforms any ClawRouter installation into a node in a decentralized uptime monitoring network. Workers earn USDC by executing HTTP health checks assigned by BlockRun. Buyers purchase monitoring with tamper-proof, multi-node uptime proof — a stronger signal than self-reported metrics.
|
|
16
|
+
|
|
17
|
+
**Current supply-side advantage:** ~1,000 paying ClawRouter users already have wallets and geographic distribution. Turning them into workers requires zero additional setup.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Target Customers
|
|
22
|
+
|
|
23
|
+
### Primary: Web3 Protocols (Phase 1)
|
|
24
|
+
Blockchain protocols, L1/L2 chains, DeFi applications, RPC providers.
|
|
25
|
+
|
|
26
|
+
**Why they buy:**
|
|
27
|
+
- CEX listing requirements mandate uptime SLA proof
|
|
28
|
+
- Institutional investors require auditable availability records
|
|
29
|
+
- Decentralized proof (multi-node, on-chain payment trail) is more credible than self-reported metrics
|
|
30
|
+
- Already comfortable with USDC payments — no payment education needed
|
|
31
|
+
|
|
32
|
+
**Example customers:** New L2 chains seeking Binance/Coinbase listing, DeFi protocols pitching institutional LPs, bridge protocols, oracle networks
|
|
33
|
+
|
|
34
|
+
### Secondary: AI API Providers (Phase 2)
|
|
35
|
+
OpenAI, Anthropic, and the long tail of AI API businesses.
|
|
36
|
+
|
|
37
|
+
### Tertiary: SaaS & Fintech (Phase 2+)
|
|
38
|
+
Any B2B company that sells to enterprises or operates under financial regulation.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Pricing Model
|
|
43
|
+
|
|
44
|
+
### For Buyers
|
|
45
|
+
|
|
46
|
+
| Tier | SLA | Price | BlockRun margin |
|
|
47
|
+
|------|-----|-------|----------------|
|
|
48
|
+
| **Best Effort** | Checks run when workers online (~90% coverage) | $0.0003/check | 67% |
|
|
49
|
+
| **Standard** | ≥1 check/min guaranteed (BlockRun fills gaps) | $0.001/check | 90% |
|
|
50
|
+
| **Premium** | 30s guaranteed + multi-region report | $0.003/check | 97% |
|
|
51
|
+
|
|
52
|
+
Monthly equivalent per endpoint (30s Standard):
|
|
53
|
+
- 2,880 checks/day × 30 × $0.001 = **$86.40/month**
|
|
54
|
+
- Worker cost: 2,880 × $0.0001 = **$8.64/month**
|
|
55
|
+
- **BlockRun margin: $77.76/endpoint/month**
|
|
56
|
+
|
|
57
|
+
### For Workers
|
|
58
|
+
|
|
59
|
+
Base rate: **$0.0001/check** (100 USDC micros)
|
|
60
|
+
|
|
61
|
+
Multiplied by reputation tier (see below). Payouts trigger at **$0.01 threshold** to minimize gas.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Reputation Flywheel
|
|
66
|
+
|
|
67
|
+
BlockRun already has all payment data from LLM inference. **No third-party needed.**
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
用户付钱买 LLM → 积累 reputation
|
|
71
|
+
高 reputation → 拿到更多/更好 worker 任务
|
|
72
|
+
赚到更多 USDC → 继续买 LLM
|
|
73
|
+
→ 循环
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Reputation Tiers (based on lifetime USDC paid to BlockRun)
|
|
77
|
+
|
|
78
|
+
| Tier | Condition | Worker reward | Task priority |
|
|
79
|
+
|------|-----------|--------------|---------------|
|
|
80
|
+
| **Bronze** | New user | $0.0001/check (1x) | Standard |
|
|
81
|
+
| **Silver** | ≥ $10 paid | $0.00012/check (1.2x) | Priority assignment |
|
|
82
|
+
| **Gold** | ≥ $50 paid | $0.00015/check (1.5x) | High-value tasks |
|
|
83
|
+
| **Platinum** | ≥ $200 paid | $0.0002/check (2x) | Enterprise tasks, first pick |
|
|
84
|
+
|
|
85
|
+
Reputation is computed from BlockRun's own GCS logs (LLM call history per wallet), refreshed daily. Cached in DB per wallet — not queried on every request.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Worker Availability Reality
|
|
90
|
+
|
|
91
|
+
ClawRouter users are developers on their own machines, not 24/7 server operators.
|
|
92
|
+
|
|
93
|
+
**Estimated concurrent online workers:**
|
|
94
|
+
```
|
|
95
|
+
Peak (US + EU working hours): 200–300
|
|
96
|
+
Average (any time): 100–150
|
|
97
|
+
Off-peak (US overnight): 30–50
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Task Redundancy (not consensus)
|
|
101
|
+
|
|
102
|
+
**Each task is assigned to 3 workers per cycle.** First valid submission wins and gets paid. The other 2 are discarded. This is purely for redundancy — not to verify each other's work. Workers have no incentive to cheat (work is trivially cheap, reward is tiny).
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
task_br_health sent to:
|
|
106
|
+
worker_042 (US-West) → submits 200, 45ms ✅ WINS, gets paid
|
|
107
|
+
worker_731 (EU) → submits 200, 120ms → discarded
|
|
108
|
+
worker_209 (US-East) → submits 200, 52ms → discarded
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Task queue logic:** Return tasks where `now - lastSuccessfulCheck > targetInterval`. Workers naturally fill gaps. No orphaned assignments.
|
|
112
|
+
|
|
113
|
+
**Standard/Premium tiers:** BlockRun runs always-on backup workers to guarantee baseline coverage.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Payment Architecture
|
|
118
|
+
|
|
119
|
+
### Full Money Flow
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
Buyer wallet
|
|
123
|
+
──$0.001/check──▶ BlockRun (x402, payTo = BlockRun address)
|
|
124
|
+
↓
|
|
125
|
+
DB: worker_credits[address] += rewardMicros
|
|
126
|
+
↓ (when credits ≥ $0.01)
|
|
127
|
+
BlockRun treasury
|
|
128
|
+
──$0.01──▶ Worker wallet
|
|
129
|
+
x402 (payTo = worker address)
|
|
130
|
+
+ 0 ETH calldata log tx on Base
|
|
131
|
+
↓
|
|
132
|
+
BlockRun keeps the spread ($0.009 per $0.01 payout)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Why x402 Both Directions
|
|
136
|
+
|
|
137
|
+
x402 is EIP-3009 TransferWithAuthorization. The `payTo` field is just an address — change it to the worker's wallet:
|
|
138
|
+
|
|
139
|
+
- **Buyer → BlockRun:** `from: buyer, to: blockrunWallet`
|
|
140
|
+
- **BlockRun → Worker:** `from: treasury, to: workerWallet`
|
|
141
|
+
|
|
142
|
+
Same CDP facilitator `/settle` endpoint. No new payment infrastructure.
|
|
143
|
+
|
|
144
|
+
### Payout Batching
|
|
145
|
+
|
|
146
|
+
Do NOT pay $0.0001 per check immediately:
|
|
147
|
+
- Accumulate credits in DB per worker
|
|
148
|
+
- Pay when worker reaches **$0.01 threshold** (~100 checks)
|
|
149
|
+
- Base L2 gas ≈ $0.0001/tx → gas overhead = **1%** of payout
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Storage Architecture (Dual-Write)
|
|
154
|
+
|
|
155
|
+
Every payout writes to **two places simultaneously**:
|
|
156
|
+
|
|
157
|
+
| Layer | Purpose | Data |
|
|
158
|
+
|-------|---------|------|
|
|
159
|
+
| **DB** | Fast reads, operational queries, pending credits | All tables below |
|
|
160
|
+
| **Base calldata** | Immutable audit trail, independent verification | Payout receipts only |
|
|
161
|
+
|
|
162
|
+
### DB Schema
|
|
163
|
+
|
|
164
|
+
```sql
|
|
165
|
+
CREATE TABLE worker_credits (
|
|
166
|
+
address TEXT PRIMARY KEY,
|
|
167
|
+
pending_micros BIGINT NOT NULL DEFAULT 0,
|
|
168
|
+
total_earned BIGINT NOT NULL DEFAULT 0,
|
|
169
|
+
total_paid BIGINT NOT NULL DEFAULT 0,
|
|
170
|
+
last_payout_at TIMESTAMPTZ,
|
|
171
|
+
last_payout_tx TEXT,
|
|
172
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
CREATE TABLE worker_results (
|
|
176
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
177
|
+
task_id TEXT NOT NULL,
|
|
178
|
+
worker_address TEXT NOT NULL,
|
|
179
|
+
timestamp BIGINT NOT NULL,
|
|
180
|
+
success BOOLEAN NOT NULL,
|
|
181
|
+
response_time_ms INTEGER,
|
|
182
|
+
status_code INTEGER,
|
|
183
|
+
reward_micros INTEGER NOT NULL,
|
|
184
|
+
paid BOOLEAN NOT NULL DEFAULT FALSE,
|
|
185
|
+
payout_id UUID,
|
|
186
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
187
|
+
);
|
|
188
|
+
CREATE INDEX ON worker_results (worker_address, paid);
|
|
189
|
+
CREATE INDEX ON worker_results (created_at);
|
|
190
|
+
|
|
191
|
+
CREATE TABLE worker_payouts (
|
|
192
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
193
|
+
worker_address TEXT NOT NULL,
|
|
194
|
+
amount_micros BIGINT NOT NULL,
|
|
195
|
+
result_count INTEGER NOT NULL,
|
|
196
|
+
results_hash TEXT NOT NULL, -- SHA256 of result IDs (also written to calldata)
|
|
197
|
+
tx_hash TEXT, -- USDC transfer tx on Base
|
|
198
|
+
log_tx_hash TEXT, -- 0 ETH calldata log tx on Base
|
|
199
|
+
status TEXT NOT NULL DEFAULT 'pending', -- pending/confirmed/failed
|
|
200
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
CREATE TABLE wallet_reputation (
|
|
204
|
+
address TEXT PRIMARY KEY,
|
|
205
|
+
total_paid_usd NUMERIC(12,6) NOT NULL DEFAULT 0,
|
|
206
|
+
tier TEXT NOT NULL DEFAULT 'bronze', -- bronze/silver/gold/platinum
|
|
207
|
+
multiplier NUMERIC(4,2) NOT NULL DEFAULT 1.0,
|
|
208
|
+
refreshed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
209
|
+
);
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Blockchain Calldata (on every payout)
|
|
213
|
+
|
|
214
|
+
A separate 0 ETH transaction broadcast alongside the USDC transfer:
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
// to: BLOCKRUN_LOG_ADDRESS (BlockRun's own address)
|
|
218
|
+
// value: 0 ETH
|
|
219
|
+
// calldata: encoded payout receipt
|
|
220
|
+
{
|
|
221
|
+
v: 1,
|
|
222
|
+
type: "worker_payout",
|
|
223
|
+
worker: "0x...",
|
|
224
|
+
amountMicros: 10000,
|
|
225
|
+
resultCount: 100,
|
|
226
|
+
resultsHash: "0xabc...", // SHA256 of result IDs
|
|
227
|
+
payoutId: "uuid",
|
|
228
|
+
payoutTxHash: "0x...",
|
|
229
|
+
ts: 1234567890
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**Independent verification:** Anyone can scan Base for txs to `BLOCKRUN_LOG_ADDRESS`, decode calldata, and verify all worker payouts without trusting BlockRun's DB.
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## Trust & Verification Model
|
|
238
|
+
|
|
239
|
+
Workers are **existing paying ClawRouter users**. The work is trivially cheap:
|
|
240
|
+
|
|
241
|
+
```javascript
|
|
242
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(10000) })
|
|
243
|
+
return { status: res.status, latency: Date.now() - start }
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Cost to do the work: ~10ms, $0.
|
|
247
|
+
Cost to fake: write cheating code, risk ban.
|
|
248
|
+
Reward either way: $0.0001.
|
|
249
|
+
|
|
250
|
+
**No rational incentive to cheat.** Simple EIP-191 signature proves identity. That's sufficient.
|
|
251
|
+
|
|
252
|
+
Future (V2): nonce injection for BlockRun-owned endpoints, spot-check verification for third-party.
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## All Design Decisions
|
|
257
|
+
|
|
258
|
+
| Question | Decision |
|
|
259
|
+
|----------|---------|
|
|
260
|
+
| 3-worker consensus needed? | No — redundancy only, not verification |
|
|
261
|
+
| How to pay workers? | x402 reversed payTo, same CDP facilitator |
|
|
262
|
+
| Workers always online? | No — 100-150 avg, 3x redundancy compensates |
|
|
263
|
+
| Verify work authenticity? | Trust-based (paying users, no incentive to cheat) |
|
|
264
|
+
| Track credits per worker? | DB (primary) + Base calldata (audit) |
|
|
265
|
+
| Pay per check on-chain? | No — batch at $0.01 threshold, 1% gas overhead |
|
|
266
|
+
| Calldata mechanism? | Separate 0 ETH tx to BLOCKRUN_LOG_ADDRESS |
|
|
267
|
+
| Reputation source? | BlockRun's own GCS logs, no third-party |
|
|
268
|
+
| DB choice? | TBD — any Postgres-compatible works |
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## Go-to-Market
|
|
273
|
+
|
|
274
|
+
### Phase 1: Supply Side (Month 1–2)
|
|
275
|
+
- Ship `CLAWROUTER_WORKER=1` to 1,000 existing users
|
|
276
|
+
- Pilot: 3 hardcoded tasks monitoring BlockRun's own endpoints
|
|
277
|
+
- Target: 50+ active workers, end-to-end payment verified on-chain
|
|
278
|
+
|
|
279
|
+
### Phase 2: First Buyers (Month 2–3)
|
|
280
|
+
- Buyer dashboard — register any endpoint, choose SLA tier
|
|
281
|
+
- First 10 customers: 30-day free trial
|
|
282
|
+
- Publish node map (marketing)
|
|
283
|
+
- Target: 5 paying customers, $2,500 MRR
|
|
284
|
+
|
|
285
|
+
### Phase 3: Scale (Month 3–6)
|
|
286
|
+
- Standard/Premium tiers with BlockRun-backed SLA
|
|
287
|
+
- "State of Web3 Uptime" report from aggregated data
|
|
288
|
+
- Coinbase/Base ecosystem partnership
|
|
289
|
+
- Target: $15,000 MRR
|
|
290
|
+
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
## Success Metrics
|
|
294
|
+
|
|
295
|
+
| Metric | Month 3 | Month 6 |
|
|
296
|
+
|--------|---------|---------|
|
|
297
|
+
| Active workers | 50 | 200 |
|
|
298
|
+
| Monitored endpoints | 25 | 150 |
|
|
299
|
+
| Paying customers | 5 | 30 |
|
|
300
|
+
| MRR | $2,500 | $15,000 |
|
|
301
|
+
| USDC to workers/month | $250 | $1,500 |
|
|
302
|
+
| On-chain payout txs | verifiable | verifiable |
|
|
303
|
+
|
|
304
|
+
---
|
|
305
|
+
|
|
306
|
+
## Open Questions (V2)
|
|
307
|
+
|
|
308
|
+
1. Geographic routing — assign tasks by region
|
|
309
|
+
2. Buyer dashboard — web UI for endpoint config
|
|
310
|
+
3. Nonce injection — cryptographic proof for owned endpoints
|
|
311
|
+
4. Worker reputation UI — let workers see their tier and earnings
|
|
312
|
+
5. Legal — liability for uptime certificates in regulatory filings
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
# Implementation Plan
|
|
319
|
+
|
|
320
|
+
## Files to Touch
|
|
321
|
+
|
|
322
|
+
### ClawRouter
|
|
323
|
+
| File | Action |
|
|
324
|
+
|------|--------|
|
|
325
|
+
| `src/worker/types.ts` | CREATE |
|
|
326
|
+
| `src/worker/checks.ts` | CREATE |
|
|
327
|
+
| `src/worker/index.ts` | CREATE |
|
|
328
|
+
| `src/index.ts` | MODIFY |
|
|
329
|
+
|
|
330
|
+
### BlockRun
|
|
331
|
+
| File | Action |
|
|
332
|
+
|------|--------|
|
|
333
|
+
| `src/lib/worker-tasks.ts` | CREATE |
|
|
334
|
+
| `src/lib/worker-credits.ts` | CREATE |
|
|
335
|
+
| `src/lib/worker-payouts.ts` | CREATE |
|
|
336
|
+
| `src/lib/worker-reputation.ts` | CREATE |
|
|
337
|
+
| `src/app/api/v1/worker/tasks/route.ts` | CREATE |
|
|
338
|
+
| `src/app/api/v1/worker/results/route.ts` | CREATE |
|
|
339
|
+
|
|
340
|
+
### Environment Variables
|
|
341
|
+
|
|
342
|
+
**ClawRouter `.env` / shell:**
|
|
343
|
+
```bash
|
|
344
|
+
CLAWROUTER_WORKER=1
|
|
345
|
+
WORKER_REGION=US-West # optional
|
|
346
|
+
BLOCKRUN_API_BASE=https://blockrun.ai/api # override for local dev
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
**BlockRun `.env.local`:**
|
|
350
|
+
```bash
|
|
351
|
+
WORKER_PAYOUT_WALLET_KEY=0x... # treasury signing key — never commit
|
|
352
|
+
BLOCKRUN_LOG_ADDRESS=0x... # BlockRun's own address for calldata logs
|
|
353
|
+
DATABASE_URL=postgres://... # your DB
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
## Task 1: ClawRouter — Types
|
|
359
|
+
|
|
360
|
+
**File:** `src/worker/types.ts`
|
|
361
|
+
|
|
362
|
+
```typescript
|
|
363
|
+
export interface WorkerTask {
|
|
364
|
+
id: string
|
|
365
|
+
type: "http_check"
|
|
366
|
+
url: string
|
|
367
|
+
expectedStatus: number
|
|
368
|
+
timeoutMs: number
|
|
369
|
+
rewardMicros: number
|
|
370
|
+
region?: string
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export interface WorkerResult {
|
|
374
|
+
taskId: string
|
|
375
|
+
workerAddress: string
|
|
376
|
+
timestamp: number
|
|
377
|
+
success: boolean
|
|
378
|
+
responseTimeMs: number
|
|
379
|
+
statusCode?: number
|
|
380
|
+
error?: string
|
|
381
|
+
// EIP-191 signature of JSON.stringify({ taskId, workerAddress, timestamp, success })
|
|
382
|
+
signature: string
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export interface WorkerStatus {
|
|
386
|
+
address: string
|
|
387
|
+
completedTasks: number
|
|
388
|
+
totalEarnedMicros: number
|
|
389
|
+
lastPollAt?: number
|
|
390
|
+
busy: boolean
|
|
391
|
+
}
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
**Steps:**
|
|
395
|
+
1. `mkdir src/worker && touch src/worker/types.ts` — paste content above
|
|
396
|
+
2. `npx tsc --noEmit` — expect no errors
|
|
397
|
+
3. `git add src/worker/types.ts && git commit -m "feat(worker): add types"`
|
|
398
|
+
|
|
399
|
+
---
|
|
400
|
+
|
|
401
|
+
## Task 2: ClawRouter — HTTP Check Executor
|
|
402
|
+
|
|
403
|
+
**File:** `src/worker/checks.ts`
|
|
404
|
+
|
|
405
|
+
```typescript
|
|
406
|
+
import type { WorkerTask } from "./types.js"
|
|
407
|
+
|
|
408
|
+
export async function executeHttpCheck(task: WorkerTask): Promise<{
|
|
409
|
+
success: boolean
|
|
410
|
+
responseTimeMs: number
|
|
411
|
+
statusCode?: number
|
|
412
|
+
error?: string
|
|
413
|
+
}> {
|
|
414
|
+
const start = Date.now()
|
|
415
|
+
try {
|
|
416
|
+
const res = await fetch(task.url, {
|
|
417
|
+
method: "GET",
|
|
418
|
+
signal: AbortSignal.timeout(task.timeoutMs),
|
|
419
|
+
redirect: "follow",
|
|
420
|
+
headers: { "User-Agent": "BlockRun-Worker/1.0" },
|
|
421
|
+
})
|
|
422
|
+
return {
|
|
423
|
+
success: res.status === task.expectedStatus,
|
|
424
|
+
responseTimeMs: Date.now() - start,
|
|
425
|
+
statusCode: res.status,
|
|
426
|
+
}
|
|
427
|
+
} catch (err) {
|
|
428
|
+
const isTimeout = err instanceof Error &&
|
|
429
|
+
(err.name === "TimeoutError" || err.name === "AbortError")
|
|
430
|
+
return {
|
|
431
|
+
success: false,
|
|
432
|
+
responseTimeMs: Date.now() - start,
|
|
433
|
+
error: isTimeout
|
|
434
|
+
? `Timeout after ${task.timeoutMs}ms`
|
|
435
|
+
: err instanceof Error ? err.message : String(err),
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Must produce identical JSON on both sides for signature verification
|
|
441
|
+
export function buildSignableMessage(params: {
|
|
442
|
+
taskId: string
|
|
443
|
+
workerAddress: string
|
|
444
|
+
timestamp: number
|
|
445
|
+
success: boolean
|
|
446
|
+
}): string {
|
|
447
|
+
return JSON.stringify({
|
|
448
|
+
taskId: params.taskId,
|
|
449
|
+
workerAddress: params.workerAddress,
|
|
450
|
+
timestamp: params.timestamp,
|
|
451
|
+
success: params.success,
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
**Steps:**
|
|
457
|
+
1. Create `src/worker/checks.ts` — paste above
|
|
458
|
+
2. `npx tsc --noEmit`
|
|
459
|
+
3. `git add src/worker/checks.ts && git commit -m "feat(worker): add HTTP check executor"`
|
|
460
|
+
|
|
461
|
+
---
|
|
462
|
+
|
|
463
|
+
## Task 3: ClawRouter — WorkerNode Class
|
|
464
|
+
|
|
465
|
+
**File:** `src/worker/index.ts`
|
|
466
|
+
|
|
467
|
+
```typescript
|
|
468
|
+
import { privateKeyToAccount } from "viem/accounts"
|
|
469
|
+
import type { WorkerTask, WorkerResult, WorkerStatus } from "./types.js"
|
|
470
|
+
import { executeHttpCheck, buildSignableMessage } from "./checks.js"
|
|
471
|
+
|
|
472
|
+
const BLOCKRUN_API = process.env.BLOCKRUN_API_BASE ?? "https://blockrun.ai/api"
|
|
473
|
+
const POLL_INTERVAL_MS = 30_000
|
|
474
|
+
const MAX_CONCURRENT = 10
|
|
475
|
+
const REGION = process.env.WORKER_REGION ?? "unknown"
|
|
476
|
+
|
|
477
|
+
export class WorkerNode {
|
|
478
|
+
private privateKey: `0x${string}`
|
|
479
|
+
private address: string
|
|
480
|
+
private apiBase: string
|
|
481
|
+
private busy = false
|
|
482
|
+
private status: WorkerStatus
|
|
483
|
+
|
|
484
|
+
constructor(walletKey: string, walletAddress: string, apiBase = BLOCKRUN_API) {
|
|
485
|
+
this.privateKey = walletKey as `0x${string}`
|
|
486
|
+
this.address = walletAddress
|
|
487
|
+
this.apiBase = apiBase
|
|
488
|
+
this.status = { address: walletAddress, completedTasks: 0, totalEarnedMicros: 0, busy: false }
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
startPolling(): void {
|
|
492
|
+
console.log(`[Worker] Starting — ${this.address} region=${REGION}`)
|
|
493
|
+
this.poll().catch(console.error)
|
|
494
|
+
setInterval(() => this.poll().catch(console.error), POLL_INTERVAL_MS)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
getStatus(): WorkerStatus {
|
|
498
|
+
return { ...this.status, busy: this.busy }
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
private async poll(): Promise<void> {
|
|
502
|
+
if (this.busy) return
|
|
503
|
+
let tasks: WorkerTask[] = []
|
|
504
|
+
try {
|
|
505
|
+
tasks = await this.fetchTasks()
|
|
506
|
+
} catch (err) {
|
|
507
|
+
console.error(`[Worker] fetch tasks failed:`, err instanceof Error ? err.message : err)
|
|
508
|
+
return
|
|
509
|
+
}
|
|
510
|
+
if (tasks.length === 0) return
|
|
511
|
+
|
|
512
|
+
this.busy = true
|
|
513
|
+
this.status.lastPollAt = Date.now()
|
|
514
|
+
console.log(`[Worker] Executing ${tasks.length} task(s)`)
|
|
515
|
+
try {
|
|
516
|
+
const results = await this.executeBatch(tasks)
|
|
517
|
+
await this.submitResults(results)
|
|
518
|
+
} finally {
|
|
519
|
+
this.busy = false
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private async fetchTasks(): Promise<WorkerTask[]> {
|
|
524
|
+
const url = `${this.apiBase}/v1/worker/tasks?address=${this.address}®ion=${REGION}`
|
|
525
|
+
const res = await fetch(url, {
|
|
526
|
+
signal: AbortSignal.timeout(10_000),
|
|
527
|
+
headers: { "User-Agent": "BlockRun-Worker/1.0" },
|
|
528
|
+
})
|
|
529
|
+
if (!res.ok) throw new Error(`tasks endpoint ${res.status}`)
|
|
530
|
+
return res.json() as Promise<WorkerTask[]>
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
private async executeBatch(tasks: WorkerTask[]): Promise<WorkerResult[]> {
|
|
534
|
+
const results: WorkerResult[] = []
|
|
535
|
+
for (let i = 0; i < tasks.length; i += MAX_CONCURRENT) {
|
|
536
|
+
const chunk = tasks.slice(i, i + MAX_CONCURRENT)
|
|
537
|
+
const done = await Promise.all(chunk.map(t => this.executeAndSign(t)))
|
|
538
|
+
results.push(...done)
|
|
539
|
+
}
|
|
540
|
+
return results
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private async executeAndSign(task: WorkerTask): Promise<WorkerResult> {
|
|
544
|
+
const check = await executeHttpCheck(task)
|
|
545
|
+
const timestamp = Date.now()
|
|
546
|
+
const message = buildSignableMessage({
|
|
547
|
+
taskId: task.id,
|
|
548
|
+
workerAddress: this.address,
|
|
549
|
+
timestamp,
|
|
550
|
+
success: check.success,
|
|
551
|
+
})
|
|
552
|
+
const account = privateKeyToAccount(this.privateKey)
|
|
553
|
+
const signature = await account.signMessage({ message })
|
|
554
|
+
return { taskId: task.id, workerAddress: this.address, timestamp, ...check, signature }
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private async submitResults(results: WorkerResult[]): Promise<void> {
|
|
558
|
+
try {
|
|
559
|
+
const res = await fetch(`${this.apiBase}/v1/worker/results`, {
|
|
560
|
+
method: "POST",
|
|
561
|
+
headers: { "Content-Type": "application/json", "User-Agent": "BlockRun-Worker/1.0" },
|
|
562
|
+
body: JSON.stringify(results),
|
|
563
|
+
signal: AbortSignal.timeout(15_000),
|
|
564
|
+
})
|
|
565
|
+
if (!res.ok) { console.error(`[Worker] submit failed ${res.status}`); return }
|
|
566
|
+
const data = await res.json() as { accepted: number; earned: string }
|
|
567
|
+
this.status.completedTasks += data.accepted
|
|
568
|
+
console.log(`[Worker] ✓ ${data.accepted} result(s) accepted, earned $${data.earned} USDC`)
|
|
569
|
+
} catch (err) {
|
|
570
|
+
console.error(`[Worker] submit error:`, err instanceof Error ? err.message : err)
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
**Steps:**
|
|
577
|
+
1. Create `src/worker/index.ts` — paste above
|
|
578
|
+
2. `npx tsc --noEmit`
|
|
579
|
+
3. `git add src/worker/index.ts && git commit -m "feat(worker): add WorkerNode class"`
|
|
580
|
+
|
|
581
|
+
---
|
|
582
|
+
|
|
583
|
+
## Task 4: ClawRouter — Wire Worker Mode
|
|
584
|
+
|
|
585
|
+
**File:** `src/index.ts` — modify `startProxyInBackground()`.
|
|
586
|
+
|
|
587
|
+
Find this block (~line 423):
|
|
588
|
+
```typescript
|
|
589
|
+
setActiveProxy(proxy);
|
|
590
|
+
activeProxyHandle = proxy;
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
Add immediately after:
|
|
594
|
+
```typescript
|
|
595
|
+
const workerMode =
|
|
596
|
+
process.env.CLAWROUTER_WORKER === "1" ||
|
|
597
|
+
process.argv.includes("--worker")
|
|
598
|
+
|
|
599
|
+
if (workerMode) {
|
|
600
|
+
const { WorkerNode } = await import("./worker/index.js")
|
|
601
|
+
const worker = new WorkerNode(walletKey, address)
|
|
602
|
+
worker.startPolling()
|
|
603
|
+
api.logger.info(`[Worker] Mode active — polling every 30s, wallet: ${address}`)
|
|
604
|
+
}
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
**Steps:**
|
|
608
|
+
1. Edit `src/index.ts`
|
|
609
|
+
2. `npx tsc --noEmit`
|
|
610
|
+
3. `git add src/index.ts && git commit -m "feat(worker): activate WorkerNode on CLAWROUTER_WORKER=1"`
|
|
611
|
+
|
|
612
|
+
---
|
|
613
|
+
|
|
614
|
+
## Task 5: BlockRun — DB Schema
|
|
615
|
+
|
|
616
|
+
Run this migration against your DB (Postgres-compatible):
|
|
617
|
+
|
|
618
|
+
```sql
|
|
619
|
+
CREATE TABLE worker_credits (
|
|
620
|
+
address TEXT PRIMARY KEY,
|
|
621
|
+
pending_micros BIGINT NOT NULL DEFAULT 0,
|
|
622
|
+
total_earned BIGINT NOT NULL DEFAULT 0,
|
|
623
|
+
total_paid BIGINT NOT NULL DEFAULT 0,
|
|
624
|
+
last_payout_at TIMESTAMPTZ,
|
|
625
|
+
last_payout_tx TEXT,
|
|
626
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
CREATE TABLE worker_results (
|
|
630
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
631
|
+
task_id TEXT NOT NULL,
|
|
632
|
+
worker_address TEXT NOT NULL,
|
|
633
|
+
timestamp BIGINT NOT NULL,
|
|
634
|
+
success BOOLEAN NOT NULL,
|
|
635
|
+
response_time_ms INTEGER,
|
|
636
|
+
status_code INTEGER,
|
|
637
|
+
reward_micros INTEGER NOT NULL,
|
|
638
|
+
paid BOOLEAN NOT NULL DEFAULT FALSE,
|
|
639
|
+
payout_id UUID,
|
|
640
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
641
|
+
);
|
|
642
|
+
CREATE INDEX ON worker_results (worker_address, paid);
|
|
643
|
+
CREATE INDEX ON worker_results (created_at);
|
|
644
|
+
|
|
645
|
+
CREATE TABLE worker_payouts (
|
|
646
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
647
|
+
worker_address TEXT NOT NULL,
|
|
648
|
+
amount_micros BIGINT NOT NULL,
|
|
649
|
+
result_count INTEGER NOT NULL,
|
|
650
|
+
results_hash TEXT NOT NULL,
|
|
651
|
+
tx_hash TEXT,
|
|
652
|
+
log_tx_hash TEXT,
|
|
653
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
654
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
CREATE TABLE wallet_reputation (
|
|
658
|
+
address TEXT PRIMARY KEY,
|
|
659
|
+
total_paid_usd NUMERIC(12,6) NOT NULL DEFAULT 0,
|
|
660
|
+
tier TEXT NOT NULL DEFAULT 'bronze',
|
|
661
|
+
multiplier NUMERIC(4,2) NOT NULL DEFAULT 1.0,
|
|
662
|
+
refreshed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
663
|
+
);
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
**Steps:**
|
|
667
|
+
1. Run migration against dev DB
|
|
668
|
+
2. Verify all 4 tables exist
|
|
669
|
+
3. `git commit -m "feat(worker): add DB migration"`
|
|
670
|
+
|
|
671
|
+
---
|
|
672
|
+
|
|
673
|
+
## Task 6: BlockRun — Task Registry
|
|
674
|
+
|
|
675
|
+
**File:** `src/lib/worker-tasks.ts`
|
|
676
|
+
|
|
677
|
+
```typescript
|
|
678
|
+
export interface WorkerTask {
|
|
679
|
+
id: string
|
|
680
|
+
type: "http_check"
|
|
681
|
+
url: string
|
|
682
|
+
expectedStatus: number
|
|
683
|
+
timeoutMs: number
|
|
684
|
+
rewardMicros: number
|
|
685
|
+
region?: string
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
export const PILOT_TASKS: WorkerTask[] = [
|
|
689
|
+
{ id: "task_br_health", type: "http_check", url: "https://blockrun.ai/api/health", expectedStatus: 200, timeoutMs: 10_000, rewardMicros: 100 },
|
|
690
|
+
{ id: "task_br_models", type: "http_check", url: "https://blockrun.ai/api/v1/models", expectedStatus: 200, timeoutMs: 10_000, rewardMicros: 100 },
|
|
691
|
+
{ id: "task_base_rpc", type: "http_check", url: "https://mainnet.base.org", expectedStatus: 200, timeoutMs: 10_000, rewardMicros: 150 },
|
|
692
|
+
]
|
|
693
|
+
|
|
694
|
+
// Track recent assignments: taskId → [{ workerAddress, assignedAt }]
|
|
695
|
+
// Each task assigned to up to 3 workers per cycle. First to submit wins.
|
|
696
|
+
const assignments = new Map<string, Array<{ workerAddress: string; assignedAt: number }>>()
|
|
697
|
+
const CYCLE_MS = 30_000
|
|
698
|
+
const MAX_PER_TASK = 3
|
|
699
|
+
|
|
700
|
+
export function getTasksForWorker(workerAddress: string, _region?: string): WorkerTask[] {
|
|
701
|
+
const now = Date.now()
|
|
702
|
+
const addr = workerAddress.toLowerCase()
|
|
703
|
+
|
|
704
|
+
return PILOT_TASKS.filter(task => {
|
|
705
|
+
const list = assignments.get(task.id) ?? []
|
|
706
|
+
const fresh = list.filter(a => now - a.assignedAt < CYCLE_MS)
|
|
707
|
+
assignments.set(task.id, fresh)
|
|
708
|
+
|
|
709
|
+
if (fresh.some(a => a.workerAddress === addr)) return false
|
|
710
|
+
if (fresh.length >= MAX_PER_TASK) return false
|
|
711
|
+
return true
|
|
712
|
+
})
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
export function markAssigned(taskIds: string[], workerAddress: string): void {
|
|
716
|
+
const addr = workerAddress.toLowerCase()
|
|
717
|
+
const now = Date.now()
|
|
718
|
+
for (const id of taskIds) {
|
|
719
|
+
const list = assignments.get(id) ?? []
|
|
720
|
+
list.push({ workerAddress: addr, assignedAt: now })
|
|
721
|
+
assignments.set(id, list)
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
export function getTaskById(taskId: string): WorkerTask | undefined {
|
|
726
|
+
return PILOT_TASKS.find(t => t.id === taskId)
|
|
727
|
+
}
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
**Steps:**
|
|
731
|
+
1. Create `src/lib/worker-tasks.ts`
|
|
732
|
+
2. `npx tsc --noEmit`
|
|
733
|
+
3. `git commit -m "feat(worker): task registry with 3-worker redundancy"`
|
|
734
|
+
|
|
735
|
+
---
|
|
736
|
+
|
|
737
|
+
## Task 7: BlockRun — Credit Ledger
|
|
738
|
+
|
|
739
|
+
**File:** `src/lib/worker-credits.ts`
|
|
740
|
+
|
|
741
|
+
```typescript
|
|
742
|
+
import { db } from "@/lib/db" // your DB client — swap for actual import
|
|
743
|
+
import { getReputationMultiplier } from "./worker-reputation"
|
|
744
|
+
|
|
745
|
+
const PAYOUT_THRESHOLD_MICROS = 10_000 // $0.01
|
|
746
|
+
|
|
747
|
+
export async function creditWorker(
|
|
748
|
+
workerAddress: string,
|
|
749
|
+
baseRewardMicros: number,
|
|
750
|
+
): Promise<{ pendingMicros: number; thresholdReached: boolean }> {
|
|
751
|
+
const multiplier = await getReputationMultiplier(workerAddress)
|
|
752
|
+
const earned = Math.floor(baseRewardMicros * multiplier)
|
|
753
|
+
|
|
754
|
+
// Atomic upsert — safe for concurrent Cloud Run instances
|
|
755
|
+
const result = await db.query<{ pending_micros: number }>(`
|
|
756
|
+
INSERT INTO worker_credits (address, pending_micros, total_earned, updated_at)
|
|
757
|
+
VALUES ($1, $2, $2, NOW())
|
|
758
|
+
ON CONFLICT (address) DO UPDATE SET
|
|
759
|
+
pending_micros = worker_credits.pending_micros + $2,
|
|
760
|
+
total_earned = worker_credits.total_earned + $2,
|
|
761
|
+
updated_at = NOW()
|
|
762
|
+
RETURNING pending_micros
|
|
763
|
+
`, [workerAddress.toLowerCase(), earned])
|
|
764
|
+
|
|
765
|
+
const pendingMicros = result.rows[0].pending_micros
|
|
766
|
+
return {
|
|
767
|
+
pendingMicros,
|
|
768
|
+
thresholdReached: pendingMicros >= PAYOUT_THRESHOLD_MICROS,
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
export async function resetPendingCredits(
|
|
773
|
+
workerAddress: string,
|
|
774
|
+
payoutId: string,
|
|
775
|
+
amountMicros: number,
|
|
776
|
+
txHash: string,
|
|
777
|
+
): Promise<void> {
|
|
778
|
+
await db.query(`
|
|
779
|
+
UPDATE worker_credits SET
|
|
780
|
+
pending_micros = pending_micros - $2,
|
|
781
|
+
total_paid = total_paid + $2,
|
|
782
|
+
last_payout_at = NOW(),
|
|
783
|
+
last_payout_tx = $3,
|
|
784
|
+
updated_at = NOW()
|
|
785
|
+
WHERE address = $1
|
|
786
|
+
`, [workerAddress.toLowerCase(), amountMicros, txHash])
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
export async function getPendingMicros(workerAddress: string): Promise<number> {
|
|
790
|
+
const result = await db.query<{ pending_micros: number }>(
|
|
791
|
+
`SELECT pending_micros FROM worker_credits WHERE address = $1`,
|
|
792
|
+
[workerAddress.toLowerCase()]
|
|
793
|
+
)
|
|
794
|
+
return result.rows[0]?.pending_micros ?? 0
|
|
795
|
+
}
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
**Steps:**
|
|
799
|
+
1. Create `src/lib/worker-credits.ts`
|
|
800
|
+
2. Wire your actual DB client at `@/lib/db` (swap the import)
|
|
801
|
+
3. `npx tsc --noEmit`
|
|
802
|
+
4. `git commit -m "feat(worker): credit ledger with atomic upsert"`
|
|
803
|
+
|
|
804
|
+
---
|
|
805
|
+
|
|
806
|
+
## Task 8: BlockRun — Reputation Module
|
|
807
|
+
|
|
808
|
+
**File:** `src/lib/worker-reputation.ts`
|
|
809
|
+
|
|
810
|
+
```typescript
|
|
811
|
+
import { db } from "@/lib/db"
|
|
812
|
+
|
|
813
|
+
const TIERS = [
|
|
814
|
+
{ tier: "platinum", minPaid: 200, multiplier: 2.0 },
|
|
815
|
+
{ tier: "gold", minPaid: 50, multiplier: 1.5 },
|
|
816
|
+
{ tier: "silver", minPaid: 10, multiplier: 1.2 },
|
|
817
|
+
{ tier: "bronze", minPaid: 0, multiplier: 1.0 },
|
|
818
|
+
] as const
|
|
819
|
+
|
|
820
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // refresh daily
|
|
821
|
+
|
|
822
|
+
export async function getReputationMultiplier(workerAddress: string): Promise<number> {
|
|
823
|
+
const rep = await getReputation(workerAddress)
|
|
824
|
+
return rep.multiplier
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
export async function getReputation(workerAddress: string): Promise<{
|
|
828
|
+
tier: string
|
|
829
|
+
totalPaidUsd: number
|
|
830
|
+
multiplier: number
|
|
831
|
+
}> {
|
|
832
|
+
const addr = workerAddress.toLowerCase()
|
|
833
|
+
|
|
834
|
+
const cached = await db.query<{
|
|
835
|
+
tier: string; total_paid_usd: number; multiplier: number; refreshed_at: Date
|
|
836
|
+
}>(
|
|
837
|
+
`SELECT tier, total_paid_usd, multiplier, refreshed_at
|
|
838
|
+
FROM wallet_reputation WHERE address = $1`,
|
|
839
|
+
[addr]
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
if (cached.rows.length > 0) {
|
|
843
|
+
const row = cached.rows[0]
|
|
844
|
+
const age = Date.now() - new Date(row.refreshed_at).getTime()
|
|
845
|
+
if (age < CACHE_TTL_MS) {
|
|
846
|
+
return { tier: row.tier, totalPaidUsd: Number(row.total_paid_usd), multiplier: Number(row.multiplier) }
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Cache miss or stale — recompute from GCS logs
|
|
851
|
+
// NOTE: In production this should be a background job, not per-request
|
|
852
|
+
const totalPaidUsd = await computeTotalPaidFromGCS(addr)
|
|
853
|
+
const tierEntry = TIERS.find(t => totalPaidUsd >= t.minPaid) ?? TIERS[TIERS.length - 1]
|
|
854
|
+
|
|
855
|
+
await db.query(`
|
|
856
|
+
INSERT INTO wallet_reputation (address, total_paid_usd, tier, multiplier, refreshed_at)
|
|
857
|
+
VALUES ($1, $2, $3, $4, NOW())
|
|
858
|
+
ON CONFLICT (address) DO UPDATE SET
|
|
859
|
+
total_paid_usd = $2, tier = $3, multiplier = $4, refreshed_at = NOW()
|
|
860
|
+
`, [addr, totalPaidUsd, tierEntry.tier, tierEntry.multiplier])
|
|
861
|
+
|
|
862
|
+
return { tier: tierEntry.tier, totalPaidUsd, multiplier: tierEntry.multiplier }
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// Aggregate total USDC paid by this wallet from GCS LLM call logs
|
|
866
|
+
// This reads BlockRun's own data — no third-party reputation service needed
|
|
867
|
+
async function computeTotalPaidFromGCS(walletAddress: string): Promise<number> {
|
|
868
|
+
// TODO (V2): read gs://blockrun-prod-2026-logs/llm-calls/YYYY-MM-DD.jsonl
|
|
869
|
+
// Filter by wallet, sum cost field
|
|
870
|
+
// Pilot: return 0 (all workers start at Bronze)
|
|
871
|
+
return 0
|
|
872
|
+
}
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
**Steps:**
|
|
876
|
+
1. Create `src/lib/worker-reputation.ts`
|
|
877
|
+
2. `computeTotalPaidFromGCS` is a stub for pilot — implement GCS aggregation in V2
|
|
878
|
+
3. `git commit -m "feat(worker): reputation module with daily cache"`
|
|
879
|
+
|
|
880
|
+
---
|
|
881
|
+
|
|
882
|
+
## Task 9: BlockRun — Payout Module
|
|
883
|
+
|
|
884
|
+
**File:** `src/lib/worker-payouts.ts`
|
|
885
|
+
|
|
886
|
+
```typescript
|
|
887
|
+
import { createHash } from "crypto"
|
|
888
|
+
import { createWalletClient, http } from "viem"
|
|
889
|
+
import { base, baseSepolia } from "viem/chains"
|
|
890
|
+
import { privateKeyToAccount, signTypedData } from "viem/accounts"
|
|
891
|
+
import { getCurrentNetworkConfig } from "./network-config"
|
|
892
|
+
import { settlePaymentWithRetry } from "./x402"
|
|
893
|
+
import { db } from "@/lib/db"
|
|
894
|
+
import { resetPendingCredits } from "./worker-credits"
|
|
895
|
+
|
|
896
|
+
const PAYOUT_THRESHOLD_MICROS = 10_000
|
|
897
|
+
|
|
898
|
+
export async function tryPayout(
|
|
899
|
+
workerAddress: string,
|
|
900
|
+
pendingMicros: number,
|
|
901
|
+
resultIds: string[],
|
|
902
|
+
): Promise<{ paid: boolean; txHash?: string; logTxHash?: string }> {
|
|
903
|
+
if (pendingMicros < PAYOUT_THRESHOLD_MICROS) return { paid: false }
|
|
904
|
+
|
|
905
|
+
const payoutKey = process.env.WORKER_PAYOUT_WALLET_KEY
|
|
906
|
+
if (!payoutKey?.startsWith("0x")) throw new Error("WORKER_PAYOUT_WALLET_KEY not set")
|
|
907
|
+
|
|
908
|
+
const networkConfig = getCurrentNetworkConfig()
|
|
909
|
+
const payoutAccount = privateKeyToAccount(payoutKey as `0x${string}`)
|
|
910
|
+
|
|
911
|
+
// 1. Insert payout record (pending)
|
|
912
|
+
const resultsHash = createHash("sha256").update(resultIds.sort().join(",")).digest("hex")
|
|
913
|
+
const payoutResult = await db.query<{ id: string }>(`
|
|
914
|
+
INSERT INTO worker_payouts (worker_address, amount_micros, result_count, results_hash, status)
|
|
915
|
+
VALUES ($1, $2, $3, $4, 'pending')
|
|
916
|
+
RETURNING id
|
|
917
|
+
`, [workerAddress.toLowerCase(), pendingMicros, resultIds.length, resultsHash])
|
|
918
|
+
const payoutId = payoutResult.rows[0].id
|
|
919
|
+
|
|
920
|
+
// 2. Sign TransferWithAuthorization: BlockRun treasury → worker
|
|
921
|
+
const validAfter = BigInt(0)
|
|
922
|
+
const validBefore = BigInt(Math.floor(Date.now() / 1000) + 3600)
|
|
923
|
+
const nonceBytes = crypto.getRandomValues(new Uint8Array(32))
|
|
924
|
+
const nonce = `0x${Buffer.from(nonceBytes).toString("hex")}` as `0x${string}`
|
|
925
|
+
|
|
926
|
+
const signature = await signTypedData({
|
|
927
|
+
privateKey: payoutKey as `0x${string}`,
|
|
928
|
+
domain: {
|
|
929
|
+
name: networkConfig.usdcDomainName,
|
|
930
|
+
version: "2",
|
|
931
|
+
chainId: networkConfig.chainId,
|
|
932
|
+
verifyingContract: networkConfig.usdc,
|
|
933
|
+
},
|
|
934
|
+
types: {
|
|
935
|
+
TransferWithAuthorization: [
|
|
936
|
+
{ name: "from", type: "address" },
|
|
937
|
+
{ name: "to", type: "address" },
|
|
938
|
+
{ name: "value", type: "uint256" },
|
|
939
|
+
{ name: "validAfter", type: "uint256" },
|
|
940
|
+
{ name: "validBefore", type: "uint256" },
|
|
941
|
+
{ name: "nonce", type: "bytes32" },
|
|
942
|
+
],
|
|
943
|
+
},
|
|
944
|
+
primaryType: "TransferWithAuthorization",
|
|
945
|
+
message: {
|
|
946
|
+
from: payoutAccount.address,
|
|
947
|
+
to: workerAddress as `0x${string}`,
|
|
948
|
+
value: BigInt(pendingMicros),
|
|
949
|
+
validAfter,
|
|
950
|
+
validBefore,
|
|
951
|
+
nonce,
|
|
952
|
+
},
|
|
953
|
+
})
|
|
954
|
+
|
|
955
|
+
// 3. Build x402 payment payload (same format as incoming payments)
|
|
956
|
+
const paymentPayload = {
|
|
957
|
+
x402Version: 1,
|
|
958
|
+
scheme: "exact",
|
|
959
|
+
network: networkConfig.network,
|
|
960
|
+
payload: {
|
|
961
|
+
signature,
|
|
962
|
+
authorization: {
|
|
963
|
+
from: payoutAccount.address,
|
|
964
|
+
to: workerAddress,
|
|
965
|
+
value: String(pendingMicros),
|
|
966
|
+
validAfter: String(validAfter),
|
|
967
|
+
validBefore: String(validBefore),
|
|
968
|
+
nonce,
|
|
969
|
+
},
|
|
970
|
+
},
|
|
971
|
+
}
|
|
972
|
+
const paymentHeader = Buffer.from(JSON.stringify(paymentPayload)).toString("base64")
|
|
973
|
+
|
|
974
|
+
const requirements = {
|
|
975
|
+
scheme: "exact",
|
|
976
|
+
network: networkConfig.network,
|
|
977
|
+
maxAmountRequired: String(pendingMicros),
|
|
978
|
+
resource: `worker-payout:${workerAddress}`,
|
|
979
|
+
description: "Worker node payout",
|
|
980
|
+
mimeType: "application/json",
|
|
981
|
+
payTo: workerAddress,
|
|
982
|
+
maxTimeoutSeconds: 3600,
|
|
983
|
+
asset: networkConfig.usdc,
|
|
984
|
+
outputSchema: null,
|
|
985
|
+
extra: null,
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// 4. Settle USDC transfer via CDP facilitator
|
|
989
|
+
const settled = await settlePaymentWithRetry(paymentHeader, requirements as never)
|
|
990
|
+
if (!settled.success) throw new Error(`Settlement failed: ${settled.error}`)
|
|
991
|
+
|
|
992
|
+
const txHash = settled.txHash!
|
|
993
|
+
|
|
994
|
+
// 5. Write calldata log tx to Base (audit trail — failure does NOT block payout)
|
|
995
|
+
const logTxHash = await writeCalldataLog({
|
|
996
|
+
workerAddress, amountMicros: pendingMicros,
|
|
997
|
+
resultCount: resultIds.length, resultsHash, payoutId,
|
|
998
|
+
payoutTxHash: txHash, payoutKey, networkConfig,
|
|
999
|
+
})
|
|
1000
|
+
|
|
1001
|
+
// 6. Update DB: mark payout confirmed, reset credits, mark results paid
|
|
1002
|
+
await Promise.all([
|
|
1003
|
+
db.query(
|
|
1004
|
+
`UPDATE worker_payouts SET status='confirmed', tx_hash=$1, log_tx_hash=$2 WHERE id=$3`,
|
|
1005
|
+
[txHash, logTxHash, payoutId]
|
|
1006
|
+
),
|
|
1007
|
+
resetPendingCredits(workerAddress, payoutId, pendingMicros, txHash),
|
|
1008
|
+
db.query(
|
|
1009
|
+
`UPDATE worker_results SET paid=TRUE, payout_id=$1 WHERE id = ANY($2::uuid[])`,
|
|
1010
|
+
[payoutId, resultIds]
|
|
1011
|
+
),
|
|
1012
|
+
])
|
|
1013
|
+
|
|
1014
|
+
return { paid: true, txHash, logTxHash }
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
async function writeCalldataLog(params: {
|
|
1018
|
+
workerAddress: string
|
|
1019
|
+
amountMicros: number
|
|
1020
|
+
resultCount: number
|
|
1021
|
+
resultsHash: string
|
|
1022
|
+
payoutId: string
|
|
1023
|
+
payoutTxHash: string
|
|
1024
|
+
payoutKey: string
|
|
1025
|
+
networkConfig: ReturnType<typeof getCurrentNetworkConfig>
|
|
1026
|
+
}): Promise<string | undefined> {
|
|
1027
|
+
const logAddress = process.env.BLOCKRUN_LOG_ADDRESS as `0x${string}` | undefined
|
|
1028
|
+
if (!logAddress) return undefined
|
|
1029
|
+
|
|
1030
|
+
try {
|
|
1031
|
+
const chain = params.networkConfig.network === "eip155:8453" ? base : baseSepolia
|
|
1032
|
+
const account = privateKeyToAccount(params.payoutKey as `0x${string}`)
|
|
1033
|
+
|
|
1034
|
+
const walletClient = createWalletClient({ account, chain, transport: http() })
|
|
1035
|
+
|
|
1036
|
+
const data = Buffer.from(JSON.stringify({
|
|
1037
|
+
v: 1,
|
|
1038
|
+
type: "worker_payout",
|
|
1039
|
+
worker: params.workerAddress,
|
|
1040
|
+
amountMicros: params.amountMicros,
|
|
1041
|
+
resultCount: params.resultCount,
|
|
1042
|
+
resultsHash: params.resultsHash,
|
|
1043
|
+
payoutId: params.payoutId,
|
|
1044
|
+
payoutTxHash: params.payoutTxHash,
|
|
1045
|
+
ts: Date.now(),
|
|
1046
|
+
})).toString("hex")
|
|
1047
|
+
|
|
1048
|
+
return await walletClient.sendTransaction({
|
|
1049
|
+
to: logAddress,
|
|
1050
|
+
value: BigInt(0),
|
|
1051
|
+
data: `0x${data}` as `0x${string}`,
|
|
1052
|
+
})
|
|
1053
|
+
} catch (err) {
|
|
1054
|
+
console.error("[Worker Payout] calldata log tx failed:", err)
|
|
1055
|
+
return undefined
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
```
|
|
1059
|
+
|
|
1060
|
+
**Steps:**
|
|
1061
|
+
1. Create `src/lib/worker-payouts.ts`
|
|
1062
|
+
2. Check `network-config.ts` — add `chainId: 8453 / 84532` if missing
|
|
1063
|
+
3. `npx tsc --noEmit`
|
|
1064
|
+
4. `git commit -m "feat(worker): payout module with x402 + calldata audit log"`
|
|
1065
|
+
|
|
1066
|
+
---
|
|
1067
|
+
|
|
1068
|
+
## Task 10: BlockRun — GET /api/v1/worker/tasks
|
|
1069
|
+
|
|
1070
|
+
**File:** `src/app/api/v1/worker/tasks/route.ts`
|
|
1071
|
+
|
|
1072
|
+
```typescript
|
|
1073
|
+
import { NextRequest, NextResponse } from "next/server"
|
|
1074
|
+
import { getTasksForWorker, markAssigned } from "@/lib/worker-tasks"
|
|
1075
|
+
|
|
1076
|
+
export const runtime = "nodejs"
|
|
1077
|
+
|
|
1078
|
+
export async function GET(request: NextRequest) {
|
|
1079
|
+
const { searchParams } = new URL(request.url)
|
|
1080
|
+
const address = searchParams.get("address")
|
|
1081
|
+
const region = searchParams.get("region") ?? undefined
|
|
1082
|
+
|
|
1083
|
+
if (!address?.startsWith("0x")) {
|
|
1084
|
+
return NextResponse.json({ error: "address required" }, { status: 400 })
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
const tasks = getTasksForWorker(address, region)
|
|
1088
|
+
markAssigned(tasks.map(t => t.id), address)
|
|
1089
|
+
|
|
1090
|
+
return NextResponse.json(tasks)
|
|
1091
|
+
}
|
|
1092
|
+
```
|
|
1093
|
+
|
|
1094
|
+
**Steps:**
|
|
1095
|
+
1. `mkdir -p src/app/api/v1/worker/tasks && touch route.ts`
|
|
1096
|
+
2. Test: `curl "http://localhost:3000/api/v1/worker/tasks?address=0x000..."`
|
|
1097
|
+
3. Expect: JSON array with up to 3 pilot tasks
|
|
1098
|
+
4. `git commit -m "feat(worker): GET /api/v1/worker/tasks"`
|
|
1099
|
+
|
|
1100
|
+
---
|
|
1101
|
+
|
|
1102
|
+
## Task 11: BlockRun — POST /api/v1/worker/results
|
|
1103
|
+
|
|
1104
|
+
**File:** `src/app/api/v1/worker/results/route.ts`
|
|
1105
|
+
|
|
1106
|
+
```typescript
|
|
1107
|
+
import { NextRequest, NextResponse } from "next/server"
|
|
1108
|
+
import { recoverMessageAddress } from "viem"
|
|
1109
|
+
import { getTaskById } from "@/lib/worker-tasks"
|
|
1110
|
+
import { creditWorker } from "@/lib/worker-credits"
|
|
1111
|
+
import { tryPayout } from "@/lib/worker-payouts"
|
|
1112
|
+
import { db } from "@/lib/db"
|
|
1113
|
+
|
|
1114
|
+
export const runtime = "nodejs"
|
|
1115
|
+
|
|
1116
|
+
interface WorkerResult {
|
|
1117
|
+
taskId: string
|
|
1118
|
+
workerAddress: string
|
|
1119
|
+
timestamp: number
|
|
1120
|
+
success: boolean
|
|
1121
|
+
responseTimeMs: number
|
|
1122
|
+
statusCode?: number
|
|
1123
|
+
error?: string
|
|
1124
|
+
signature: string
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
export async function POST(request: NextRequest) {
|
|
1128
|
+
let results: WorkerResult[]
|
|
1129
|
+
try {
|
|
1130
|
+
results = await request.json()
|
|
1131
|
+
} catch {
|
|
1132
|
+
return NextResponse.json({ error: "invalid JSON" }, { status: 400 })
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
if (!Array.isArray(results) || results.length === 0 || results.length > 50) {
|
|
1136
|
+
return NextResponse.json({ error: "results must be array of 1–50" }, { status: 400 })
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
let accepted = 0
|
|
1140
|
+
let totalEarnedMicros = 0
|
|
1141
|
+
const errors: string[] = []
|
|
1142
|
+
const acceptedResultIds: string[] = []
|
|
1143
|
+
|
|
1144
|
+
for (const result of results) {
|
|
1145
|
+
try {
|
|
1146
|
+
const task = getTaskById(result.taskId)
|
|
1147
|
+
if (!task) { errors.push(`${result.taskId}: unknown task`); continue }
|
|
1148
|
+
|
|
1149
|
+
// Verify EIP-191 signature
|
|
1150
|
+
const message = JSON.stringify({
|
|
1151
|
+
taskId: result.taskId,
|
|
1152
|
+
workerAddress: result.workerAddress,
|
|
1153
|
+
timestamp: result.timestamp,
|
|
1154
|
+
success: result.success,
|
|
1155
|
+
})
|
|
1156
|
+
const recovered = await recoverMessageAddress({
|
|
1157
|
+
message,
|
|
1158
|
+
signature: result.signature as `0x${string}`,
|
|
1159
|
+
})
|
|
1160
|
+
if (recovered.toLowerCase() !== result.workerAddress.toLowerCase()) {
|
|
1161
|
+
errors.push(`${result.taskId}: invalid signature`); continue
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// Freshness check (5 min max)
|
|
1165
|
+
if (Date.now() - result.timestamp > 5 * 60 * 1000) {
|
|
1166
|
+
errors.push(`${result.taskId}: result too old`); continue
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// Write result to DB
|
|
1170
|
+
const insertResult = await db.query<{ id: string }>(`
|
|
1171
|
+
INSERT INTO worker_results
|
|
1172
|
+
(task_id, worker_address, timestamp, success, response_time_ms, status_code, reward_micros)
|
|
1173
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
1174
|
+
RETURNING id
|
|
1175
|
+
`, [
|
|
1176
|
+
result.taskId,
|
|
1177
|
+
result.workerAddress.toLowerCase(),
|
|
1178
|
+
result.timestamp,
|
|
1179
|
+
result.success,
|
|
1180
|
+
result.responseTimeMs,
|
|
1181
|
+
result.statusCode ?? null,
|
|
1182
|
+
task.rewardMicros,
|
|
1183
|
+
])
|
|
1184
|
+
acceptedResultIds.push(insertResult.rows[0].id)
|
|
1185
|
+
|
|
1186
|
+
// Credit worker (with reputation multiplier)
|
|
1187
|
+
const { pendingMicros, thresholdReached } = await creditWorker(result.workerAddress, task.rewardMicros)
|
|
1188
|
+
|
|
1189
|
+
// Trigger payout if threshold reached (fire and forget)
|
|
1190
|
+
if (thresholdReached) {
|
|
1191
|
+
tryPayout(result.workerAddress, pendingMicros, acceptedResultIds)
|
|
1192
|
+
.catch(err => console.error(`[Worker Payout] failed for ${result.workerAddress}:`, err))
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
accepted++
|
|
1196
|
+
totalEarnedMicros += task.rewardMicros
|
|
1197
|
+
|
|
1198
|
+
} catch (err) {
|
|
1199
|
+
errors.push(`${result.taskId}: ${err instanceof Error ? err.message : String(err)}`)
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
return NextResponse.json({
|
|
1204
|
+
accepted,
|
|
1205
|
+
earned: (totalEarnedMicros / 1_000_000).toFixed(6),
|
|
1206
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
1207
|
+
})
|
|
1208
|
+
}
|
|
1209
|
+
```
|
|
1210
|
+
|
|
1211
|
+
**Steps:**
|
|
1212
|
+
1. `mkdir -p src/app/api/v1/worker/results && touch route.ts`
|
|
1213
|
+
2. `npx tsc --noEmit`
|
|
1214
|
+
3. `git commit -m "feat(worker): POST /api/v1/worker/results with sig verify, DB write, payout trigger"`
|
|
1215
|
+
|
|
1216
|
+
---
|
|
1217
|
+
|
|
1218
|
+
## End-to-End Test
|
|
1219
|
+
|
|
1220
|
+
```bash
|
|
1221
|
+
# 1. Start BlockRun locally
|
|
1222
|
+
cd /Users/vickyfu/Documents/blockrun-web/blockrun
|
|
1223
|
+
pnpm dev
|
|
1224
|
+
|
|
1225
|
+
# 2. Verify tasks endpoint
|
|
1226
|
+
curl "http://localhost:3000/api/v1/worker/tasks?address=0x0000000000000000000000000000000000000001"
|
|
1227
|
+
# → JSON array with 3 tasks
|
|
1228
|
+
|
|
1229
|
+
# 3. Start ClawRouter in worker mode (pointed at localhost)
|
|
1230
|
+
cd /Users/vickyfu/Documents/blockrun-web/ClawRouter
|
|
1231
|
+
CLAWROUTER_WORKER=1 BLOCKRUN_API_BASE=http://localhost:3000/api npx openclaw gateway start
|
|
1232
|
+
|
|
1233
|
+
# 4. Watch for logs:
|
|
1234
|
+
# [Worker] Starting — 0x... region=unknown
|
|
1235
|
+
# [Worker] Executing 3 task(s)
|
|
1236
|
+
# [Worker] ✓ 3 result(s) accepted, earned $0.000300 USDC
|
|
1237
|
+
|
|
1238
|
+
# 5. Check DB: worker_results and worker_credits tables populated
|
|
1239
|
+
# 6. At $0.01 threshold (~100 checks): worker_payouts row created, USDC transferred
|
|
1240
|
+
# 7. Check Base explorer: calldata log tx visible on BLOCKRUN_LOG_ADDRESS
|
|
1241
|
+
```
|