@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,947 @@
|
|
|
1
|
+
# Worker Network Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
4
|
+
|
|
5
|
+
**Goal:** Let ClawRouter users opt in as worker nodes that execute HTTP health checks and earn USDC micropayments via x402.
|
|
6
|
+
|
|
7
|
+
**Architecture:** ClawRouter polls BlockRun for tasks every 30s, executes HTTP checks, signs results with its existing wallet key, and submits to BlockRun. BlockRun verifies the signature, accumulates credits per worker, and pays out via x402 (TransferWithAuthorization with `payTo = worker address`) when credits hit a $0.01 threshold.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** viem (signing), x402 (payment), existing CDP facilitator (settlement), in-memory task queue (pilot)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Design Decisions (from architecture discussion)
|
|
14
|
+
|
|
15
|
+
### Verification: Trust-based (no consensus needed)
|
|
16
|
+
Workers are existing paying ClawRouter users. The actual work (one HTTP fetch) is cheaper than writing cheating code. Reward is $0.0001 — no rational incentive to fabricate. Simple signature proves identity; that's enough.
|
|
17
|
+
|
|
18
|
+
### Payment: x402 with reversed payTo
|
|
19
|
+
x402 is EIP-3009 TransferWithAuthorization. For worker payouts, BlockRun signs as the payer:
|
|
20
|
+
- `from: WORKER_PAYOUT_WALLET` (BlockRun treasury)
|
|
21
|
+
- `to: workerAddress`
|
|
22
|
+
- `value: accumulatedMicros`
|
|
23
|
+
|
|
24
|
+
BlockRun calls the existing CDP facilitator `/settle` endpoint. No new payment infrastructure needed.
|
|
25
|
+
|
|
26
|
+
### Payout batching (gas efficiency)
|
|
27
|
+
Do NOT pay $0.0001 per check immediately. Accumulate per worker, pay when ≥ $0.01 (100 checks). Base L2 gas ≈ $0.0001/tx → gas overhead = 1%. Acceptable.
|
|
28
|
+
|
|
29
|
+
### Task assignment
|
|
30
|
+
With 1000 workers and N tasks, each task is assigned to at most 1 worker per 30s cycle. Simple approach: return tasks that haven't been assigned to THIS worker in the last 5 minutes. Workers in different regions naturally get different results.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Cost Model
|
|
35
|
+
|
|
36
|
+
### Pilot (3 tasks, 30s interval)
|
|
37
|
+
```
|
|
38
|
+
Checks/day: 3 tasks × 2,880 cycles = 8,640 checks
|
|
39
|
+
Worker cost: 8,640 × $0.0001 = $0.864/day ≈ $26/month
|
|
40
|
+
Gas (batched): ~87 payouts × $0.0001 = $0.009/day ≈ $0.27/month
|
|
41
|
+
Total: ~$26/month from treasury
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Scale (1,000 tasks, buyers paying)
|
|
45
|
+
```
|
|
46
|
+
Worker cost: 1,000 × 2,880 × $0.0001 = $288/day
|
|
47
|
+
Buyer price: $0.00011/check (10% margin)
|
|
48
|
+
Revenue: 1,000 × 2,880 × $0.00011 = $316.8/day
|
|
49
|
+
Net margin: ~$28.8/day on worker network
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Worker earnings
|
|
53
|
+
```
|
|
54
|
+
1,000 workers, 1,000 tasks (equal distribution):
|
|
55
|
+
Each worker: 1 task/cycle avg
|
|
56
|
+
Daily: 2,880 × $0.0001 = $0.288/day
|
|
57
|
+
Monthly: ~$8.64/month passive income
|
|
58
|
+
|
|
59
|
+
Pilot (1,000 workers, 3 tasks):
|
|
60
|
+
Each worker: 0.003 tasks/cycle avg
|
|
61
|
+
Daily: ~$0.001/day per worker
|
|
62
|
+
→ Pilot is about proving the system, not earnings
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Files
|
|
68
|
+
|
|
69
|
+
### ClawRouter (new files)
|
|
70
|
+
| File | Action |
|
|
71
|
+
|------|--------|
|
|
72
|
+
| `src/worker/types.ts` | CREATE |
|
|
73
|
+
| `src/worker/checks.ts` | CREATE |
|
|
74
|
+
| `src/worker/index.ts` | CREATE |
|
|
75
|
+
| `src/index.ts` | MODIFY — add worker startup in `startProxyInBackground()` |
|
|
76
|
+
|
|
77
|
+
### BlockRun (new files)
|
|
78
|
+
| File | Action |
|
|
79
|
+
|------|--------|
|
|
80
|
+
| `src/lib/worker-tasks.ts` | CREATE |
|
|
81
|
+
| `src/lib/worker-payouts.ts` | CREATE |
|
|
82
|
+
| `src/app/api/v1/worker/tasks/route.ts` | CREATE |
|
|
83
|
+
| `src/app/api/v1/worker/results/route.ts` | CREATE |
|
|
84
|
+
|
|
85
|
+
### BlockRun: add health endpoint (needed for self-verification)
|
|
86
|
+
| File | Action |
|
|
87
|
+
|------|--------|
|
|
88
|
+
| `src/app/api/health/route.ts` | CHECK if exists — if not, CREATE |
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Task 1: ClawRouter — Worker Types
|
|
93
|
+
|
|
94
|
+
**Files:**
|
|
95
|
+
- Create: `src/worker/types.ts`
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
export interface WorkerTask {
|
|
99
|
+
id: string
|
|
100
|
+
type: "http_check"
|
|
101
|
+
url: string
|
|
102
|
+
expectedStatus: number
|
|
103
|
+
timeoutMs: number
|
|
104
|
+
rewardMicros: number
|
|
105
|
+
region?: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface WorkerResult {
|
|
109
|
+
taskId: string
|
|
110
|
+
workerAddress: string
|
|
111
|
+
timestamp: number
|
|
112
|
+
success: boolean
|
|
113
|
+
responseTimeMs: number
|
|
114
|
+
statusCode?: number
|
|
115
|
+
error?: string
|
|
116
|
+
// EIP-191 signature of JSON.stringify({ taskId, workerAddress, timestamp, success })
|
|
117
|
+
signature: string
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface WorkerStatus {
|
|
121
|
+
address: string
|
|
122
|
+
completedTasks: number
|
|
123
|
+
totalEarnedMicros: number
|
|
124
|
+
lastPollAt?: number
|
|
125
|
+
busy: boolean
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Step 1:** Create `src/worker/` directory and `types.ts` with content above.
|
|
130
|
+
|
|
131
|
+
**Step 2:** Commit
|
|
132
|
+
```bash
|
|
133
|
+
git add src/worker/types.ts
|
|
134
|
+
git commit -m "feat(worker): add worker network types"
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Task 2: ClawRouter — HTTP Check Executor
|
|
140
|
+
|
|
141
|
+
**Files:**
|
|
142
|
+
- Create: `src/worker/checks.ts`
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
import type { WorkerTask } from "./types.js"
|
|
146
|
+
|
|
147
|
+
export async function executeHttpCheck(task: WorkerTask): Promise<{
|
|
148
|
+
success: boolean
|
|
149
|
+
responseTimeMs: number
|
|
150
|
+
statusCode?: number
|
|
151
|
+
error?: string
|
|
152
|
+
}> {
|
|
153
|
+
const start = Date.now()
|
|
154
|
+
try {
|
|
155
|
+
const response = await fetch(task.url, {
|
|
156
|
+
method: "GET",
|
|
157
|
+
signal: AbortSignal.timeout(task.timeoutMs),
|
|
158
|
+
redirect: "follow",
|
|
159
|
+
headers: { "User-Agent": "BlockRun-Worker/1.0" },
|
|
160
|
+
})
|
|
161
|
+
return {
|
|
162
|
+
success: response.status === task.expectedStatus,
|
|
163
|
+
responseTimeMs: Date.now() - start,
|
|
164
|
+
statusCode: response.status,
|
|
165
|
+
}
|
|
166
|
+
} catch (err) {
|
|
167
|
+
const isTimeout = err instanceof Error &&
|
|
168
|
+
(err.name === "TimeoutError" || err.name === "AbortError")
|
|
169
|
+
return {
|
|
170
|
+
success: false,
|
|
171
|
+
responseTimeMs: Date.now() - start,
|
|
172
|
+
error: isTimeout ? `Timeout after ${task.timeoutMs}ms` : String(err instanceof Error ? err.message : err),
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Both sides must produce identical JSON for signature verification
|
|
178
|
+
export function buildSignableMessage(params: {
|
|
179
|
+
taskId: string
|
|
180
|
+
workerAddress: string
|
|
181
|
+
timestamp: number
|
|
182
|
+
success: boolean
|
|
183
|
+
}): string {
|
|
184
|
+
return JSON.stringify({
|
|
185
|
+
taskId: params.taskId,
|
|
186
|
+
workerAddress: params.workerAddress,
|
|
187
|
+
timestamp: params.timestamp,
|
|
188
|
+
success: params.success,
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**Step 1:** Create `src/worker/checks.ts` with content above.
|
|
194
|
+
|
|
195
|
+
**Step 2:** Commit
|
|
196
|
+
```bash
|
|
197
|
+
git add src/worker/checks.ts
|
|
198
|
+
git commit -m "feat(worker): add HTTP check executor"
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## Task 3: ClawRouter — WorkerNode Class
|
|
204
|
+
|
|
205
|
+
**Files:**
|
|
206
|
+
- Create: `src/worker/index.ts`
|
|
207
|
+
|
|
208
|
+
Key imports needed: `privateKeyToAccount` from `viem/accounts` (already a dependency).
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
import { privateKeyToAccount } from "viem/accounts"
|
|
212
|
+
import type { WorkerTask, WorkerResult, WorkerStatus } from "./types.js"
|
|
213
|
+
import { executeHttpCheck, buildSignableMessage } from "./checks.js"
|
|
214
|
+
|
|
215
|
+
const BLOCKRUN_API = "https://blockrun.ai/api"
|
|
216
|
+
const POLL_INTERVAL_MS = 30_000
|
|
217
|
+
const MAX_CONCURRENT_CHECKS = 10
|
|
218
|
+
const REGION = process.env.WORKER_REGION || "unknown"
|
|
219
|
+
|
|
220
|
+
export class WorkerNode {
|
|
221
|
+
private address: string
|
|
222
|
+
private privateKey: `0x${string}`
|
|
223
|
+
private apiBase: string
|
|
224
|
+
private status: WorkerStatus
|
|
225
|
+
private pollTimer?: ReturnType<typeof setInterval>
|
|
226
|
+
private busy = false
|
|
227
|
+
|
|
228
|
+
constructor(walletKey: string, walletAddress: string, apiBase = BLOCKRUN_API) {
|
|
229
|
+
this.privateKey = walletKey as `0x${string}`
|
|
230
|
+
this.address = walletAddress
|
|
231
|
+
this.apiBase = apiBase
|
|
232
|
+
this.status = {
|
|
233
|
+
address: walletAddress,
|
|
234
|
+
completedTasks: 0,
|
|
235
|
+
totalEarnedMicros: 0,
|
|
236
|
+
busy: false,
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
startPolling(): void {
|
|
241
|
+
console.log(`[Worker] Starting — address: ${this.address}, region: ${REGION}`)
|
|
242
|
+
// Run immediately, then on interval
|
|
243
|
+
this.poll().catch(console.error)
|
|
244
|
+
this.pollTimer = setInterval(() => {
|
|
245
|
+
this.poll().catch(console.error)
|
|
246
|
+
}, POLL_INTERVAL_MS)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
stopPolling(): void {
|
|
250
|
+
if (this.pollTimer) {
|
|
251
|
+
clearInterval(this.pollTimer)
|
|
252
|
+
this.pollTimer = undefined
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
getStatus(): WorkerStatus {
|
|
257
|
+
return { ...this.status, busy: this.busy }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private async poll(): Promise<void> {
|
|
261
|
+
if (this.busy) return // skip cycle if still executing
|
|
262
|
+
|
|
263
|
+
let tasks: WorkerTask[] = []
|
|
264
|
+
try {
|
|
265
|
+
tasks = await this.fetchTasks()
|
|
266
|
+
} catch (err) {
|
|
267
|
+
console.error(`[Worker] Failed to fetch tasks:`, err instanceof Error ? err.message : err)
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (tasks.length === 0) return
|
|
272
|
+
|
|
273
|
+
this.busy = true
|
|
274
|
+
this.status.lastPollAt = Date.now()
|
|
275
|
+
console.log(`[Worker] Executing ${tasks.length} task(s)`)
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
const results = await this.executeBatch(tasks)
|
|
279
|
+
await this.submitResults(results)
|
|
280
|
+
} finally {
|
|
281
|
+
this.busy = false
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private async fetchTasks(): Promise<WorkerTask[]> {
|
|
286
|
+
const url = `${this.apiBase}/v1/worker/tasks?address=${this.address}®ion=${REGION}`
|
|
287
|
+
const res = await fetch(url, {
|
|
288
|
+
signal: AbortSignal.timeout(10_000),
|
|
289
|
+
headers: { "User-Agent": "BlockRun-Worker/1.0" },
|
|
290
|
+
})
|
|
291
|
+
if (!res.ok) {
|
|
292
|
+
throw new Error(`Tasks endpoint returned ${res.status}`)
|
|
293
|
+
}
|
|
294
|
+
return res.json() as Promise<WorkerTask[]>
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private async executeBatch(tasks: WorkerTask[]): Promise<WorkerResult[]> {
|
|
298
|
+
// Cap concurrency at MAX_CONCURRENT_CHECKS
|
|
299
|
+
const chunks: WorkerTask[][] = []
|
|
300
|
+
for (let i = 0; i < tasks.length; i += MAX_CONCURRENT_CHECKS) {
|
|
301
|
+
chunks.push(tasks.slice(i, i + MAX_CONCURRENT_CHECKS))
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const results: WorkerResult[] = []
|
|
305
|
+
for (const chunk of chunks) {
|
|
306
|
+
const chunkResults = await Promise.all(chunk.map(task => this.executeAndSign(task)))
|
|
307
|
+
results.push(...chunkResults)
|
|
308
|
+
}
|
|
309
|
+
return results
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private async executeAndSign(task: WorkerTask): Promise<WorkerResult> {
|
|
313
|
+
const check = await executeHttpCheck(task)
|
|
314
|
+
const timestamp = Date.now()
|
|
315
|
+
|
|
316
|
+
const message = buildSignableMessage({
|
|
317
|
+
taskId: task.id,
|
|
318
|
+
workerAddress: this.address,
|
|
319
|
+
timestamp,
|
|
320
|
+
success: check.success,
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
const account = privateKeyToAccount(this.privateKey)
|
|
324
|
+
const signature = await account.signMessage({ message })
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
taskId: task.id,
|
|
328
|
+
workerAddress: this.address,
|
|
329
|
+
timestamp,
|
|
330
|
+
...check,
|
|
331
|
+
signature,
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private async submitResults(results: WorkerResult[]): Promise<void> {
|
|
336
|
+
try {
|
|
337
|
+
const res = await fetch(`${this.apiBase}/v1/worker/results`, {
|
|
338
|
+
method: "POST",
|
|
339
|
+
headers: {
|
|
340
|
+
"Content-Type": "application/json",
|
|
341
|
+
"User-Agent": "BlockRun-Worker/1.0",
|
|
342
|
+
},
|
|
343
|
+
body: JSON.stringify(results),
|
|
344
|
+
signal: AbortSignal.timeout(15_000),
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
if (!res.ok) {
|
|
348
|
+
console.error(`[Worker] Results submission failed: ${res.status}`)
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const data = await res.json() as { accepted: number; earned: string }
|
|
353
|
+
this.status.completedTasks += data.accepted
|
|
354
|
+
console.log(`[Worker] Submitted ${data.accepted} result(s), earned: $${data.earned} USDC`)
|
|
355
|
+
} catch (err) {
|
|
356
|
+
console.error(`[Worker] Failed to submit results:`, err instanceof Error ? err.message : err)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
**Step 1:** Create `src/worker/index.ts` with content above.
|
|
363
|
+
|
|
364
|
+
**Step 2:** Verify TypeScript compiles
|
|
365
|
+
```bash
|
|
366
|
+
cd /Users/vickyfu/Documents/blockrun-web/ClawRouter
|
|
367
|
+
npx tsc --noEmit
|
|
368
|
+
```
|
|
369
|
+
Expected: no errors in worker files.
|
|
370
|
+
|
|
371
|
+
**Step 3:** Commit
|
|
372
|
+
```bash
|
|
373
|
+
git add src/worker/index.ts
|
|
374
|
+
git commit -m "feat(worker): add WorkerNode class with polling and signing"
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
---
|
|
378
|
+
|
|
379
|
+
## Task 4: ClawRouter — Wire Worker Mode in index.ts
|
|
380
|
+
|
|
381
|
+
**Files:**
|
|
382
|
+
- Modify: `src/index.ts` — inside `startProxyInBackground()`, after `setActiveProxy(proxy)`
|
|
383
|
+
|
|
384
|
+
Find this line in `startProxyInBackground()` (~line 423):
|
|
385
|
+
```typescript
|
|
386
|
+
setActiveProxy(proxy);
|
|
387
|
+
activeProxyHandle = proxy;
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
Add immediately after:
|
|
391
|
+
```typescript
|
|
392
|
+
// Worker mode: opt-in via CLAWROUTER_WORKER=1 or --worker flag
|
|
393
|
+
const workerMode =
|
|
394
|
+
process.env.CLAWROUTER_WORKER === "1" ||
|
|
395
|
+
process.argv.includes("--worker")
|
|
396
|
+
|
|
397
|
+
if (workerMode) {
|
|
398
|
+
const { WorkerNode } = await import("./worker/index.js")
|
|
399
|
+
const worker = new WorkerNode(walletKey, address)
|
|
400
|
+
worker.startPolling()
|
|
401
|
+
api.logger.info(`[Worker] Mode active — polling for tasks every 30s`)
|
|
402
|
+
api.logger.info(`[Worker] Wallet: ${address}`)
|
|
403
|
+
}
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
**Step 1:** Apply the edit above to `src/index.ts`.
|
|
407
|
+
|
|
408
|
+
**Step 2:** Verify TypeScript compiles
|
|
409
|
+
```bash
|
|
410
|
+
npx tsc --noEmit
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
**Step 3:** Commit
|
|
414
|
+
```bash
|
|
415
|
+
git add src/index.ts
|
|
416
|
+
git commit -m "feat(worker): activate WorkerNode when CLAWROUTER_WORKER=1"
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
---
|
|
420
|
+
|
|
421
|
+
## Task 5: BlockRun — Worker Task Registry
|
|
422
|
+
|
|
423
|
+
**Files:**
|
|
424
|
+
- Create: `src/lib/worker-tasks.ts`
|
|
425
|
+
|
|
426
|
+
```typescript
|
|
427
|
+
import type { WorkerTask } from "./worker-types"
|
|
428
|
+
|
|
429
|
+
// Re-export types so routes can import from one place
|
|
430
|
+
export type { WorkerTask }
|
|
431
|
+
|
|
432
|
+
export interface WorkerTask {
|
|
433
|
+
id: string
|
|
434
|
+
type: "http_check"
|
|
435
|
+
url: string
|
|
436
|
+
expectedStatus: number
|
|
437
|
+
timeoutMs: number
|
|
438
|
+
rewardMicros: number
|
|
439
|
+
region?: string
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Pilot seed tasks — BlockRun-owned endpoints, verifiable and real
|
|
443
|
+
export const PILOT_TASKS: WorkerTask[] = [
|
|
444
|
+
{
|
|
445
|
+
id: "task_br_health",
|
|
446
|
+
type: "http_check",
|
|
447
|
+
url: "https://blockrun.ai/api/health",
|
|
448
|
+
expectedStatus: 200,
|
|
449
|
+
timeoutMs: 10_000,
|
|
450
|
+
rewardMicros: 100, // $0.0001
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
id: "task_br_models",
|
|
454
|
+
type: "http_check",
|
|
455
|
+
url: "https://blockrun.ai/api/v1/models",
|
|
456
|
+
expectedStatus: 200,
|
|
457
|
+
timeoutMs: 10_000,
|
|
458
|
+
rewardMicros: 100,
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
id: "task_base_rpc",
|
|
462
|
+
type: "http_check",
|
|
463
|
+
url: "https://mainnet.base.org",
|
|
464
|
+
expectedStatus: 200,
|
|
465
|
+
timeoutMs: 10_000,
|
|
466
|
+
rewardMicros: 150, // slightly higher for third-party
|
|
467
|
+
},
|
|
468
|
+
]
|
|
469
|
+
|
|
470
|
+
// Track last assignment: taskId → { workerAddress, assignedAt }
|
|
471
|
+
const lastAssignments = new Map<string, { workerAddress: string; assignedAt: number }>()
|
|
472
|
+
|
|
473
|
+
// How long before a task can be reassigned to the same worker
|
|
474
|
+
const REASSIGN_COOLDOWN_MS = 5 * 60 * 1000 // 5 minutes
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Return tasks that haven't been assigned to this worker recently.
|
|
478
|
+
* This ensures different workers get different tasks, providing geographic diversity.
|
|
479
|
+
*/
|
|
480
|
+
export function getTasksForWorker(workerAddress: string, _region?: string): WorkerTask[] {
|
|
481
|
+
const now = Date.now()
|
|
482
|
+
const address = workerAddress.toLowerCase()
|
|
483
|
+
|
|
484
|
+
return PILOT_TASKS.filter(task => {
|
|
485
|
+
const last = lastAssignments.get(task.id)
|
|
486
|
+
if (!last) return true // never assigned
|
|
487
|
+
// Reassign if cooldown expired OR it was assigned to a different worker
|
|
488
|
+
if (now - last.assignedAt > REASSIGN_COOLDOWN_MS) return true
|
|
489
|
+
if (last.workerAddress !== address) return true
|
|
490
|
+
return false
|
|
491
|
+
})
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Mark tasks as assigned to a worker (called when GET /tasks responds).
|
|
496
|
+
*/
|
|
497
|
+
export function markTasksAssigned(taskIds: string[], workerAddress: string): void {
|
|
498
|
+
const now = Date.now()
|
|
499
|
+
const address = workerAddress.toLowerCase()
|
|
500
|
+
for (const id of taskIds) {
|
|
501
|
+
lastAssignments.set(id, { workerAddress: address, assignedAt: now })
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Look up a task by ID.
|
|
507
|
+
*/
|
|
508
|
+
export function getTaskById(taskId: string): WorkerTask | undefined {
|
|
509
|
+
return PILOT_TASKS.find(t => t.id === taskId)
|
|
510
|
+
}
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
**Step 1:** Create `src/lib/worker-tasks.ts` with content above.
|
|
514
|
+
|
|
515
|
+
**Step 2:** Commit
|
|
516
|
+
```bash
|
|
517
|
+
cd /Users/vickyfu/Documents/blockrun-web/blockrun
|
|
518
|
+
git add src/lib/worker-tasks.ts
|
|
519
|
+
git commit -m "feat(worker): add task registry with pilot seed tasks"
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
---
|
|
523
|
+
|
|
524
|
+
## Task 6: BlockRun — Worker Payout Module
|
|
525
|
+
|
|
526
|
+
**Files:**
|
|
527
|
+
- Create: `src/lib/worker-payouts.ts`
|
|
528
|
+
|
|
529
|
+
This module accumulates credits per worker and triggers x402-style USDC payouts when threshold is reached. Uses the same EIP-3009 signing infrastructure as the rest of BlockRun.
|
|
530
|
+
|
|
531
|
+
```typescript
|
|
532
|
+
import { signTypedData, privateKeyToAccount } from "viem/accounts"
|
|
533
|
+
import { getCurrentNetworkConfig } from "./network-config"
|
|
534
|
+
import { settlePaymentWithRetry } from "./x402"
|
|
535
|
+
|
|
536
|
+
// Minimum payout threshold — accumulate before paying to save gas
|
|
537
|
+
const PAYOUT_THRESHOLD_MICROS = 10_000 // $0.01
|
|
538
|
+
|
|
539
|
+
// In-memory credit ledger: workerAddress → accumulatedMicros
|
|
540
|
+
const credits = new Map<string, number>()
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Add earned micros for a worker. Triggers payout if threshold reached.
|
|
544
|
+
* Returns the amount paid out (0 if threshold not yet reached).
|
|
545
|
+
*/
|
|
546
|
+
export async function creditWorker(
|
|
547
|
+
workerAddress: string,
|
|
548
|
+
earnedMicros: number
|
|
549
|
+
): Promise<{ paid: number; txHash?: string }> {
|
|
550
|
+
const address = workerAddress.toLowerCase()
|
|
551
|
+
const current = credits.get(address) ?? 0
|
|
552
|
+
const newTotal = current + earnedMicros
|
|
553
|
+
credits.set(address, newTotal)
|
|
554
|
+
|
|
555
|
+
if (newTotal < PAYOUT_THRESHOLD_MICROS) {
|
|
556
|
+
return { paid: 0 }
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Threshold reached — trigger payout
|
|
560
|
+
credits.set(address, 0) // reset before async to avoid double-pay
|
|
561
|
+
|
|
562
|
+
try {
|
|
563
|
+
const result = await sendUsdcToWorker(workerAddress as `0x${string}`, newTotal)
|
|
564
|
+
console.log(`[Worker Payout] Sent ${newTotal} micros ($${(newTotal / 1_000_000).toFixed(6)}) to ${workerAddress}`)
|
|
565
|
+
return { paid: newTotal, txHash: result.txHash }
|
|
566
|
+
} catch (err) {
|
|
567
|
+
// Restore credits on failure — worker doesn't lose their earnings
|
|
568
|
+
credits.set(address, newTotal)
|
|
569
|
+
console.error(`[Worker Payout] Failed to pay ${workerAddress}:`, err)
|
|
570
|
+
throw err
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Get pending (not yet paid) credits for a worker.
|
|
576
|
+
*/
|
|
577
|
+
export function getPendingCredits(workerAddress: string): number {
|
|
578
|
+
return credits.get(workerAddress.toLowerCase()) ?? 0
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Send USDC from BlockRun payout wallet to worker.
|
|
583
|
+
* Uses same EIP-3009 TransferWithAuthorization as x402, but BlockRun is the payer.
|
|
584
|
+
*/
|
|
585
|
+
async function sendUsdcToWorker(
|
|
586
|
+
workerAddress: `0x${string}`,
|
|
587
|
+
amountMicros: number
|
|
588
|
+
): Promise<{ txHash?: string }> {
|
|
589
|
+
const payoutKey = process.env.WORKER_PAYOUT_WALLET_KEY
|
|
590
|
+
|
|
591
|
+
if (!payoutKey || !payoutKey.startsWith("0x")) {
|
|
592
|
+
throw new Error("WORKER_PAYOUT_WALLET_KEY not configured")
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const networkConfig = getCurrentNetworkConfig()
|
|
596
|
+
const payoutAccount = privateKeyToAccount(payoutKey as `0x${string}`)
|
|
597
|
+
|
|
598
|
+
const validAfter = BigInt(0)
|
|
599
|
+
const validBefore = BigInt(Math.floor(Date.now() / 1000) + 3600) // 1 hour
|
|
600
|
+
const nonceBytes = crypto.getRandomValues(new Uint8Array(32))
|
|
601
|
+
const nonce = `0x${Buffer.from(nonceBytes).toString("hex")}` as `0x${string}`
|
|
602
|
+
|
|
603
|
+
// Sign TransferWithAuthorization: BlockRun treasury → worker
|
|
604
|
+
// Same scheme as ClawRouter's x402.ts but payTo = worker address
|
|
605
|
+
const signature = await signTypedData({
|
|
606
|
+
privateKey: payoutKey as `0x${string}`,
|
|
607
|
+
domain: {
|
|
608
|
+
name: networkConfig.usdcDomainName,
|
|
609
|
+
version: "2",
|
|
610
|
+
chainId: networkConfig.chainId,
|
|
611
|
+
verifyingContract: networkConfig.usdc,
|
|
612
|
+
},
|
|
613
|
+
types: {
|
|
614
|
+
TransferWithAuthorization: [
|
|
615
|
+
{ name: "from", type: "address" },
|
|
616
|
+
{ name: "to", type: "address" },
|
|
617
|
+
{ name: "value", type: "uint256" },
|
|
618
|
+
{ name: "validAfter", type: "uint256" },
|
|
619
|
+
{ name: "validBefore", type: "uint256" },
|
|
620
|
+
{ name: "nonce", type: "bytes32" },
|
|
621
|
+
],
|
|
622
|
+
},
|
|
623
|
+
primaryType: "TransferWithAuthorization",
|
|
624
|
+
message: {
|
|
625
|
+
from: payoutAccount.address,
|
|
626
|
+
to: workerAddress,
|
|
627
|
+
value: BigInt(amountMicros),
|
|
628
|
+
validAfter,
|
|
629
|
+
validBefore,
|
|
630
|
+
nonce,
|
|
631
|
+
},
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
// Build payment requirements for CDP facilitator (same shape as incoming payments)
|
|
635
|
+
const requirements = {
|
|
636
|
+
scheme: "exact",
|
|
637
|
+
network: networkConfig.network,
|
|
638
|
+
maxAmountRequired: String(amountMicros),
|
|
639
|
+
resource: `worker-payout:${workerAddress}`,
|
|
640
|
+
description: "Worker node payout",
|
|
641
|
+
mimeType: "application/json",
|
|
642
|
+
payTo: workerAddress,
|
|
643
|
+
maxTimeoutSeconds: 3600,
|
|
644
|
+
asset: networkConfig.usdc,
|
|
645
|
+
outputSchema: null,
|
|
646
|
+
extra: null,
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Build payment header in x402 format
|
|
650
|
+
const paymentPayload = {
|
|
651
|
+
x402Version: 1,
|
|
652
|
+
scheme: "exact",
|
|
653
|
+
network: networkConfig.network,
|
|
654
|
+
payload: {
|
|
655
|
+
signature,
|
|
656
|
+
authorization: {
|
|
657
|
+
from: payoutAccount.address,
|
|
658
|
+
to: workerAddress,
|
|
659
|
+
value: String(amountMicros),
|
|
660
|
+
validAfter: String(validAfter),
|
|
661
|
+
validBefore: String(validBefore),
|
|
662
|
+
nonce,
|
|
663
|
+
},
|
|
664
|
+
},
|
|
665
|
+
}
|
|
666
|
+
const paymentHeader = Buffer.from(JSON.stringify(paymentPayload)).toString("base64")
|
|
667
|
+
|
|
668
|
+
const result = await settlePaymentWithRetry(paymentHeader, requirements as never)
|
|
669
|
+
|
|
670
|
+
if (!result.success) {
|
|
671
|
+
throw new Error(`Settlement failed: ${result.error}`)
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return { txHash: result.txHash }
|
|
675
|
+
}
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
**Note on `networkConfig.chainId`:** Check `src/lib/network-config.ts` — if `chainId` is not in the `NetworkConfig` type, add it: mainnet = 8453, testnet = 84532.
|
|
679
|
+
|
|
680
|
+
**Step 1:** Check network-config.ts for `chainId` field. If missing, add it.
|
|
681
|
+
|
|
682
|
+
**Step 2:** Create `src/lib/worker-payouts.ts` with content above.
|
|
683
|
+
|
|
684
|
+
**Step 3:** Verify TypeScript
|
|
685
|
+
```bash
|
|
686
|
+
npx tsc --noEmit 2>&1 | grep worker
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
**Step 4:** Commit
|
|
690
|
+
```bash
|
|
691
|
+
git add src/lib/worker-payouts.ts src/lib/network-config.ts
|
|
692
|
+
git commit -m "feat(worker): add payout module with batched x402 USDC transfers"
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
---
|
|
696
|
+
|
|
697
|
+
## Task 7: BlockRun — GET /api/v1/worker/tasks
|
|
698
|
+
|
|
699
|
+
**Files:**
|
|
700
|
+
- Create: `src/app/api/v1/worker/tasks/route.ts`
|
|
701
|
+
|
|
702
|
+
```typescript
|
|
703
|
+
import { NextRequest, NextResponse } from "next/server"
|
|
704
|
+
import { getTasksForWorker, markTasksAssigned } from "@/lib/worker-tasks"
|
|
705
|
+
|
|
706
|
+
export const runtime = "nodejs"
|
|
707
|
+
|
|
708
|
+
export async function GET(request: NextRequest) {
|
|
709
|
+
const { searchParams } = new URL(request.url)
|
|
710
|
+
const address = searchParams.get("address")
|
|
711
|
+
const region = searchParams.get("region") ?? undefined
|
|
712
|
+
|
|
713
|
+
if (!address || !address.startsWith("0x")) {
|
|
714
|
+
return NextResponse.json({ error: "address required" }, { status: 400 })
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const tasks = getTasksForWorker(address, region)
|
|
718
|
+
markTasksAssigned(tasks.map(t => t.id), address)
|
|
719
|
+
|
|
720
|
+
return NextResponse.json(tasks)
|
|
721
|
+
}
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
**Step 1:** Create directory `src/app/api/v1/worker/tasks/` and `route.ts` with content above.
|
|
725
|
+
|
|
726
|
+
**Step 2:** Test manually
|
|
727
|
+
```bash
|
|
728
|
+
curl "http://localhost:3000/api/v1/worker/tasks?address=0x1234567890123456789012345678901234567890"
|
|
729
|
+
```
|
|
730
|
+
Expected: JSON array with 3 pilot tasks.
|
|
731
|
+
|
|
732
|
+
**Step 3:** Commit
|
|
733
|
+
```bash
|
|
734
|
+
git add src/app/api/v1/worker/tasks/route.ts
|
|
735
|
+
git commit -m "feat(worker): add GET /api/v1/worker/tasks endpoint"
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
---
|
|
739
|
+
|
|
740
|
+
## Task 8: BlockRun — POST /api/v1/worker/results
|
|
741
|
+
|
|
742
|
+
**Files:**
|
|
743
|
+
- Create: `src/app/api/v1/worker/results/route.ts`
|
|
744
|
+
|
|
745
|
+
```typescript
|
|
746
|
+
import { NextRequest, NextResponse } from "next/server"
|
|
747
|
+
import { recoverMessageAddress } from "viem"
|
|
748
|
+
import { getTaskById } from "@/lib/worker-tasks"
|
|
749
|
+
import { creditWorker } from "@/lib/worker-payouts"
|
|
750
|
+
import { logWorkerResult } from "@/lib/gcs-logger"
|
|
751
|
+
|
|
752
|
+
export const runtime = "nodejs"
|
|
753
|
+
|
|
754
|
+
interface WorkerResult {
|
|
755
|
+
taskId: string
|
|
756
|
+
workerAddress: string
|
|
757
|
+
timestamp: number
|
|
758
|
+
success: boolean
|
|
759
|
+
responseTimeMs: number
|
|
760
|
+
statusCode?: number
|
|
761
|
+
error?: string
|
|
762
|
+
signature: string
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
export async function POST(request: NextRequest) {
|
|
766
|
+
let results: WorkerResult[]
|
|
767
|
+
|
|
768
|
+
try {
|
|
769
|
+
results = await request.json()
|
|
770
|
+
} catch {
|
|
771
|
+
return NextResponse.json({ error: "invalid JSON" }, { status: 400 })
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (!Array.isArray(results) || results.length === 0) {
|
|
775
|
+
return NextResponse.json({ error: "results must be non-empty array" }, { status: 400 })
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Cap batch size
|
|
779
|
+
if (results.length > 50) {
|
|
780
|
+
return NextResponse.json({ error: "max 50 results per batch" }, { status: 400 })
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
let accepted = 0
|
|
784
|
+
let totalEarnedMicros = 0
|
|
785
|
+
const errors: string[] = []
|
|
786
|
+
|
|
787
|
+
for (const result of results) {
|
|
788
|
+
try {
|
|
789
|
+
// 1. Look up task
|
|
790
|
+
const task = getTaskById(result.taskId)
|
|
791
|
+
if (!task) {
|
|
792
|
+
errors.push(`${result.taskId}: unknown task`)
|
|
793
|
+
continue
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// 2. Verify EIP-191 signature
|
|
797
|
+
const message = JSON.stringify({
|
|
798
|
+
taskId: result.taskId,
|
|
799
|
+
workerAddress: result.workerAddress,
|
|
800
|
+
timestamp: result.timestamp,
|
|
801
|
+
success: result.success,
|
|
802
|
+
})
|
|
803
|
+
|
|
804
|
+
const recovered = await recoverMessageAddress({
|
|
805
|
+
message,
|
|
806
|
+
signature: result.signature as `0x${string}`,
|
|
807
|
+
})
|
|
808
|
+
|
|
809
|
+
if (recovered.toLowerCase() !== result.workerAddress.toLowerCase()) {
|
|
810
|
+
errors.push(`${result.taskId}: invalid signature`)
|
|
811
|
+
continue
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// 3. Sanity checks
|
|
815
|
+
const age = Date.now() - result.timestamp
|
|
816
|
+
if (age > 5 * 60 * 1000) {
|
|
817
|
+
errors.push(`${result.taskId}: result too old (${Math.round(age / 1000)}s)`)
|
|
818
|
+
continue
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// 4. Log to GCS (fire and forget)
|
|
822
|
+
logWorkerResult({
|
|
823
|
+
taskId: result.taskId,
|
|
824
|
+
workerAddress: result.workerAddress,
|
|
825
|
+
timestamp: result.timestamp,
|
|
826
|
+
success: result.success,
|
|
827
|
+
responseTimeMs: result.responseTimeMs,
|
|
828
|
+
statusCode: result.statusCode,
|
|
829
|
+
rewardMicros: task.rewardMicros,
|
|
830
|
+
}).catch(console.error)
|
|
831
|
+
|
|
832
|
+
// 5. Credit worker — triggers payout if threshold reached
|
|
833
|
+
await creditWorker(result.workerAddress, task.rewardMicros)
|
|
834
|
+
|
|
835
|
+
accepted++
|
|
836
|
+
totalEarnedMicros += task.rewardMicros
|
|
837
|
+
|
|
838
|
+
} catch (err) {
|
|
839
|
+
errors.push(`${result.taskId}: ${err instanceof Error ? err.message : String(err)}`)
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
return NextResponse.json({
|
|
844
|
+
accepted,
|
|
845
|
+
earned: (totalEarnedMicros / 1_000_000).toFixed(6), // in USDC
|
|
846
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
847
|
+
})
|
|
848
|
+
}
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
**Step 1:** Create `src/app/api/v1/worker/results/route.ts` with content above.
|
|
852
|
+
|
|
853
|
+
**Step 2:** Add `logWorkerResult` to `src/lib/gcs-logger.ts`:
|
|
854
|
+
```typescript
|
|
855
|
+
export async function logWorkerResult(result: {
|
|
856
|
+
taskId: string
|
|
857
|
+
workerAddress: string
|
|
858
|
+
timestamp: number
|
|
859
|
+
success: boolean
|
|
860
|
+
responseTimeMs: number
|
|
861
|
+
statusCode?: number
|
|
862
|
+
rewardMicros: number
|
|
863
|
+
}): Promise<void> {
|
|
864
|
+
const date = new Date(result.timestamp).toISOString().split("T")[0]
|
|
865
|
+
const fileName = `worker-results/${date}/${result.taskId}-${result.timestamp}.json`
|
|
866
|
+
// Use same GCS write pattern as logLLMCall
|
|
867
|
+
try {
|
|
868
|
+
const file = bucket.file(fileName)
|
|
869
|
+
await file.save(JSON.stringify(result), { contentType: "application/json" })
|
|
870
|
+
} catch {
|
|
871
|
+
// GCS failure should not block payment
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
**Step 3:** Verify TypeScript
|
|
877
|
+
```bash
|
|
878
|
+
npx tsc --noEmit
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
**Step 4:** Commit
|
|
882
|
+
```bash
|
|
883
|
+
git add src/app/api/v1/worker/results/route.ts src/lib/gcs-logger.ts
|
|
884
|
+
git commit -m "feat(worker): add POST /api/v1/worker/results with sig verify and payout"
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
---
|
|
888
|
+
|
|
889
|
+
## Task 9: Environment Variables
|
|
890
|
+
|
|
891
|
+
**ClawRouter** (no new env vars needed for basic mode):
|
|
892
|
+
```bash
|
|
893
|
+
CLAWROUTER_WORKER=1 # opt-in to worker mode
|
|
894
|
+
WORKER_REGION=US-West # optional geographic tag
|
|
895
|
+
```
|
|
896
|
+
|
|
897
|
+
**BlockRun** (add to `.env.local` and Cloud Run secrets):
|
|
898
|
+
```bash
|
|
899
|
+
WORKER_PAYOUT_WALLET_KEY=0x... # treasury key for paying workers
|
|
900
|
+
```
|
|
901
|
+
|
|
902
|
+
**Step 1:** Add `WORKER_PAYOUT_WALLET_KEY` to BlockRun's `.env.local.example` (never commit real key).
|
|
903
|
+
|
|
904
|
+
**Step 2:** Document in BlockRun's README or deployment notes.
|
|
905
|
+
|
|
906
|
+
---
|
|
907
|
+
|
|
908
|
+
## Testing the Pilot End-to-End
|
|
909
|
+
|
|
910
|
+
**Step 1:** Start BlockRun dev server
|
|
911
|
+
```bash
|
|
912
|
+
cd /Users/vickyfu/Documents/blockrun-web/blockrun
|
|
913
|
+
npm run dev
|
|
914
|
+
```
|
|
915
|
+
|
|
916
|
+
**Step 2:** Verify tasks endpoint
|
|
917
|
+
```bash
|
|
918
|
+
curl "http://localhost:3000/api/v1/worker/tasks?address=0x0000000000000000000000000000000000000001"
|
|
919
|
+
# Expected: [{id: "task_br_health", ...}, ...]
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
**Step 3:** Start ClawRouter in worker mode (pointing at localhost)
|
|
923
|
+
```bash
|
|
924
|
+
cd /Users/vickyfu/Documents/blockrun-web/ClawRouter
|
|
925
|
+
CLAWROUTER_WORKER=1 BLOCKRUN_API_BASE=http://localhost:3000/api npx openclaw gateway start
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
**Note:** `BLOCKRUN_API_BASE` override needs to be wired into `WorkerNode` constructor — add support for this env var.
|
|
929
|
+
|
|
930
|
+
**Step 4:** Watch logs for
|
|
931
|
+
```
|
|
932
|
+
[Worker] Starting — address: 0x...
|
|
933
|
+
[Worker] Executing 3 task(s)
|
|
934
|
+
[Worker] Submitted 3 result(s), earned: $0.000300 USDC
|
|
935
|
+
```
|
|
936
|
+
|
|
937
|
+
**Step 5:** Check BlockRun logs for incoming results and payout trigger at $0.01 threshold.
|
|
938
|
+
|
|
939
|
+
---
|
|
940
|
+
|
|
941
|
+
## Open Questions / V2
|
|
942
|
+
|
|
943
|
+
- Worker registration portal (let users explicitly opt in via UI)
|
|
944
|
+
- Buyer dashboard (custom endpoint monitoring)
|
|
945
|
+
- Geographic routing (assign tasks by region)
|
|
946
|
+
- Slash mechanism if needed at scale (currently not needed)
|
|
947
|
+
- `/wallet worker-status` command in ClawRouter to show earnings
|