deadman-fm 0.1.4 → 0.1.5
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/dist/commands/create.d.ts +4 -0
- package/dist/commands/create.js +13 -0
- package/dist/commands/interactive.js +152 -50
- package/dist/index.js +12 -0
- package/dist/node/daemon.d.ts +29 -0
- package/dist/node/daemon.js +648 -0
- package/dist/node/heartbeat.d.ts +51 -0
- package/dist/node/heartbeat.js +76 -0
- package/package.json +1 -1
|
@@ -12,6 +12,10 @@ interface CreateOptions {
|
|
|
12
12
|
genre: string;
|
|
13
13
|
music: string;
|
|
14
14
|
watch?: boolean;
|
|
15
|
+
/** On-chain station ID — required to ping the heartbeat contract. */
|
|
16
|
+
stationId?: number;
|
|
17
|
+
/** Hex private key (0x-prefixed) for the station creator wallet. */
|
|
18
|
+
privateKey?: string;
|
|
15
19
|
}
|
|
16
20
|
export declare function createStation(opts: CreateOptions): Promise<void>;
|
|
17
21
|
export {};
|
package/dist/commands/create.js
CHANGED
|
@@ -15,6 +15,7 @@ import { homedir } from "node:os";
|
|
|
15
15
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
16
16
|
import Hypercore from "hypercore";
|
|
17
17
|
import Hyperswarm from "hyperswarm";
|
|
18
|
+
import { pingHeartbeat } from "../node/heartbeat.js";
|
|
18
19
|
const AUDIO_EXTS = new Set([
|
|
19
20
|
".mp3",
|
|
20
21
|
".wav",
|
|
@@ -88,6 +89,14 @@ export async function createStation(opts) {
|
|
|
88
89
|
console.log(` Feed key: ${core.key.toString("hex")}`);
|
|
89
90
|
console.log(` ${totalSegments} segments on the network`);
|
|
90
91
|
console.log(`\n Press Ctrl+C to stop seeding`);
|
|
92
|
+
// Ping the on-chain heartbeat to reset the 7-day dead man's switch
|
|
93
|
+
if (opts.stationId !== undefined) {
|
|
94
|
+
await pingHeartbeat(opts.stationId, totalSegments, opts.privateKey);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
console.warn("\n ⚠️ Heartbeat NOT pinged — no stationId provided.");
|
|
98
|
+
console.warn(" Pass --station-id <ID> to auto-ping after syncing.");
|
|
99
|
+
}
|
|
91
100
|
// Watch mode
|
|
92
101
|
if (opts.watch) {
|
|
93
102
|
console.log(` 👁 Watching ${musicPath} for new files...`);
|
|
@@ -106,6 +115,10 @@ export async function createStation(opts) {
|
|
|
106
115
|
meta.totalSegments = totalSegments;
|
|
107
116
|
writeFileSync(join(stationDir, "station.json"), JSON.stringify(meta, null, 2));
|
|
108
117
|
console.log(` → ${segments} segments (total: ${totalSegments})`);
|
|
118
|
+
// Re-ping heartbeat whenever new content is synced
|
|
119
|
+
if (opts.stationId !== undefined) {
|
|
120
|
+
await pingHeartbeat(opts.stationId, totalSegments, opts.privateKey);
|
|
121
|
+
}
|
|
109
122
|
});
|
|
110
123
|
}
|
|
111
124
|
// Keep alive
|
|
@@ -110,6 +110,44 @@ function loadLocalStations() {
|
|
|
110
110
|
}
|
|
111
111
|
return results;
|
|
112
112
|
}
|
|
113
|
+
async function fetchMyStationsFromChain(walletAddress) {
|
|
114
|
+
const { createPublicClient, http, parseAbiItem } = await import("viem");
|
|
115
|
+
const { base } = await import("viem/chains");
|
|
116
|
+
const REGISTRY = "0x3b9b6c9BBa15173ba4EF544c17Cb2Ba969d8b585";
|
|
117
|
+
const DEPLOY_BLOCK = 43856547n;
|
|
118
|
+
const client = createPublicClient({
|
|
119
|
+
chain: base,
|
|
120
|
+
transport: http("https://mainnet.base.org"),
|
|
121
|
+
});
|
|
122
|
+
const event = parseAbiItem("event StationCreated(uint256 indexed stationId, address indexed creator, string name, bytes32 genre, bytes32 hypercorePubKey, address curve)");
|
|
123
|
+
const CHUNK = 9500n;
|
|
124
|
+
const currentBlock = await client.getBlockNumber();
|
|
125
|
+
const allLogs = [];
|
|
126
|
+
let from = DEPLOY_BLOCK;
|
|
127
|
+
while (from <= currentBlock) {
|
|
128
|
+
const to = from + CHUNK > currentBlock ? currentBlock : from + CHUNK;
|
|
129
|
+
const chunk = await client.getLogs({
|
|
130
|
+
address: REGISTRY,
|
|
131
|
+
event,
|
|
132
|
+
fromBlock: from,
|
|
133
|
+
toBlock: to,
|
|
134
|
+
});
|
|
135
|
+
allLogs.push(...chunk);
|
|
136
|
+
from = to + 1n;
|
|
137
|
+
}
|
|
138
|
+
return allLogs
|
|
139
|
+
.filter((log) => log.args.creator?.toLowerCase() === walletAddress.toLowerCase())
|
|
140
|
+
.map((log) => ({
|
|
141
|
+
id: log.args.stationId?.toString() ?? "0",
|
|
142
|
+
name: log.args.name ?? "Unnamed",
|
|
143
|
+
genre: log.args.genre
|
|
144
|
+
? Buffer.from(log.args.genre.replace(/^0x/, ""), "hex")
|
|
145
|
+
.toString("utf8")
|
|
146
|
+
.replace(/\0/g, "")
|
|
147
|
+
: "Other",
|
|
148
|
+
creator: log.args.creator ?? "0x0",
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
113
151
|
// ---------------------------------------------------------------------------
|
|
114
152
|
// Keychain helpers
|
|
115
153
|
// ---------------------------------------------------------------------------
|
|
@@ -206,6 +244,16 @@ function checkRelay() {
|
|
|
206
244
|
return false;
|
|
207
245
|
}
|
|
208
246
|
}
|
|
247
|
+
function checkDaemon() {
|
|
248
|
+
try {
|
|
249
|
+
const out = execSync("curl -sf http://localhost:9999/health --max-time 2 2>/dev/null", { encoding: "utf8" }).trim();
|
|
250
|
+
const data = JSON.parse(out);
|
|
251
|
+
return { up: data.ok, stations: data.stations, block: data.block };
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return { up: false };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
209
257
|
function checkLocalLink() {
|
|
210
258
|
try {
|
|
211
259
|
const out = execSync("pgrep -f 'deadman' 2>/dev/null || true", {
|
|
@@ -251,12 +299,12 @@ export async function interactive() {
|
|
|
251
299
|
{
|
|
252
300
|
value: "create",
|
|
253
301
|
label: "Start Mining",
|
|
254
|
-
hint: "
|
|
302
|
+
hint: "sync music to your station, start earning $FM",
|
|
255
303
|
},
|
|
256
304
|
{
|
|
257
|
-
value: "
|
|
258
|
-
label: "
|
|
259
|
-
hint: "
|
|
305
|
+
value: "node",
|
|
306
|
+
label: "Run Node",
|
|
307
|
+
hint: "serve the heartbeat API, earn $FM",
|
|
260
308
|
},
|
|
261
309
|
{
|
|
262
310
|
value: "connect",
|
|
@@ -267,7 +315,7 @@ export async function interactive() {
|
|
|
267
315
|
{
|
|
268
316
|
value: "status",
|
|
269
317
|
label: "Check status",
|
|
270
|
-
hint: "
|
|
318
|
+
hint: "node, speaker, wallet",
|
|
271
319
|
},
|
|
272
320
|
{ value: "exit", label: "Exit" },
|
|
273
321
|
],
|
|
@@ -280,8 +328,8 @@ export async function interactive() {
|
|
|
280
328
|
case "create":
|
|
281
329
|
await handleCreate();
|
|
282
330
|
break;
|
|
283
|
-
case "
|
|
284
|
-
await
|
|
331
|
+
case "node":
|
|
332
|
+
await handleNode();
|
|
285
333
|
break;
|
|
286
334
|
case "connect":
|
|
287
335
|
await handleConnect();
|
|
@@ -299,9 +347,50 @@ export async function interactive() {
|
|
|
299
347
|
// Option 1 — Create a station
|
|
300
348
|
// ---------------------------------------------------------------------------
|
|
301
349
|
async function handleCreate() {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
350
|
+
// First check: do we have a saved wallet?
|
|
351
|
+
let wallet = loadSavedWallet();
|
|
352
|
+
if (!wallet) {
|
|
353
|
+
const rawWallet = await p.text({
|
|
354
|
+
message: "Your wallet address (the one that created your stations)",
|
|
355
|
+
placeholder: "0x...",
|
|
356
|
+
validate: (val) => {
|
|
357
|
+
if (!val?.match(/^0x[a-fA-F0-9]{40}$/))
|
|
358
|
+
return "Must be a valid Ethereum address";
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
guardCancel(rawWallet);
|
|
362
|
+
wallet = rawWallet.trim();
|
|
363
|
+
// Save for next time
|
|
364
|
+
const configDir = join(homedir(), ".deadman-fm");
|
|
365
|
+
const configPath = join(configDir, "config.json");
|
|
366
|
+
try {
|
|
367
|
+
const { mkdirSync, writeFileSync } = await import("node:fs");
|
|
368
|
+
mkdirSync(configDir, { recursive: true });
|
|
369
|
+
const existing = existsSync(configPath)
|
|
370
|
+
? JSON.parse(readFileSync(configPath, "utf8"))
|
|
371
|
+
: {};
|
|
372
|
+
existing.walletAddress = wallet;
|
|
373
|
+
writeFileSync(configPath, JSON.stringify(existing, null, 2));
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
/* non-critical */
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
// Check chain for stations owned by this wallet
|
|
380
|
+
const chainSpinner = p.spinner();
|
|
381
|
+
chainSpinner.start("Checking Base chain for your stations...");
|
|
382
|
+
let chainStations = [];
|
|
383
|
+
try {
|
|
384
|
+
chainStations = await fetchMyStationsFromChain(wallet);
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
chainSpinner.stop("Chain query failed");
|
|
388
|
+
p.log.error(`Could not reach Base chain: ${err instanceof Error ? err.message : String(err)}`);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
chainSpinner.stop(`Found ${chainStations.length} station${chainStations.length === 1 ? "" : "s"} on chain`);
|
|
392
|
+
if (chainStations.length === 0) {
|
|
393
|
+
p.log.warn("No stations found for this wallet.");
|
|
305
394
|
p.log.info("Win one in the daily emoji auction at deadman.fm");
|
|
306
395
|
const openAuction = await p.confirm({
|
|
307
396
|
message: "Open the auction page?",
|
|
@@ -324,50 +413,36 @@ async function handleCreate() {
|
|
|
324
413
|
}
|
|
325
414
|
return;
|
|
326
415
|
}
|
|
327
|
-
// Has stations —
|
|
328
|
-
p.log.success(`You own ${stations.length} station${stations.length === 1 ? "" : "s"}. Let's sync music.`);
|
|
329
|
-
await handleSync();
|
|
330
|
-
}
|
|
331
|
-
// ---------------------------------------------------------------------------
|
|
332
|
-
// Option 2 — Sync music (standalone entry point)
|
|
333
|
-
// ---------------------------------------------------------------------------
|
|
334
|
-
async function handleSync() {
|
|
335
|
-
const stations = loadLocalStations();
|
|
336
|
-
if (stations.length === 0) {
|
|
337
|
-
p.log.warn("You don't own a station yet.");
|
|
338
|
-
p.log.info("Win one in the daily auction at deadman.fm");
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
416
|
+
// Has stations on chain — pick one and sync
|
|
341
417
|
let stationName;
|
|
342
|
-
let stationId
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
418
|
+
let stationId;
|
|
419
|
+
let genre;
|
|
420
|
+
if (chainStations.length === 1) {
|
|
421
|
+
stationName = chainStations[0].name;
|
|
422
|
+
stationId = parseInt(chainStations[0].id, 10);
|
|
423
|
+
genre = chainStations[0].genre;
|
|
424
|
+
p.log.info(`Using station: ${stationName} (#${stationId})`);
|
|
347
425
|
}
|
|
348
426
|
else {
|
|
349
427
|
const selected = await p.select({
|
|
350
428
|
message: "Which station?",
|
|
351
|
-
options:
|
|
352
|
-
value:
|
|
353
|
-
label:
|
|
354
|
-
hint:
|
|
355
|
-
? `#${meta.stationId} · ${meta.genre}`
|
|
356
|
-
: meta.genre,
|
|
429
|
+
options: chainStations.map((s) => ({
|
|
430
|
+
value: s.id,
|
|
431
|
+
label: s.name,
|
|
432
|
+
hint: `#${s.id} · ${s.genre}`,
|
|
357
433
|
})),
|
|
358
434
|
});
|
|
359
435
|
guardCancel(selected);
|
|
360
|
-
const found =
|
|
361
|
-
stationName = found
|
|
362
|
-
stationId = found
|
|
436
|
+
const found = chainStations.find((s) => s.id === selected);
|
|
437
|
+
stationName = found.name;
|
|
438
|
+
stationId = parseInt(found.id, 10);
|
|
439
|
+
genre = found.genre;
|
|
363
440
|
}
|
|
364
|
-
|
|
441
|
+
// Go straight to sync with the selected chain station
|
|
442
|
+
await handleSyncForStation(stationName, stationId, genre);
|
|
365
443
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
// ---------------------------------------------------------------------------
|
|
369
|
-
async function handleSyncForStation(stationName, stationId) {
|
|
370
|
-
const idLabel = stationId !== null ? `#${stationId}` : `"${stationName}"`;
|
|
444
|
+
async function handleSyncForStation(stationName, stationId, genre) {
|
|
445
|
+
const idLabel = `#${stationId}`;
|
|
371
446
|
const rawPath = await p.text({
|
|
372
447
|
message: "Path to music folder",
|
|
373
448
|
placeholder: "~/Music",
|
|
@@ -418,10 +493,6 @@ async function handleSyncForStation(stationName, stationId) {
|
|
|
418
493
|
const syncSpinner = p.spinner();
|
|
419
494
|
syncSpinner.start(`Syncing ${tracks.length} tracks to ${idLabel}...`);
|
|
420
495
|
const { createStation } = await import("./create.js");
|
|
421
|
-
const stations = loadLocalStations();
|
|
422
|
-
const found = stations.find((s) => s.meta.name === stationName ||
|
|
423
|
-
(stationId !== null && s.meta.stationId === stationId));
|
|
424
|
-
const genre = found?.meta.genre ?? "Electronic";
|
|
425
496
|
const originalLog = console.log;
|
|
426
497
|
let totalSegments = 0;
|
|
427
498
|
let currentTrackIdx = 0;
|
|
@@ -463,7 +534,34 @@ async function handleSyncForStation(stationName, stationId) {
|
|
|
463
534
|
await new Promise(() => { });
|
|
464
535
|
}
|
|
465
536
|
// ---------------------------------------------------------------------------
|
|
466
|
-
// Option 3 —
|
|
537
|
+
// Option 3 — Run Node (daemon with heartbeat API)
|
|
538
|
+
// ---------------------------------------------------------------------------
|
|
539
|
+
async function handleNode() {
|
|
540
|
+
p.note("Your machine becomes a deadman.fm node.\nIt indexes the chain, serves the heartbeat API,\nand shares content with the P2P network.\n\nMiners who run nodes earn bonus $FM.", "Run Node");
|
|
541
|
+
const portInput = await p.text({
|
|
542
|
+
message: "API port",
|
|
543
|
+
placeholder: "9999",
|
|
544
|
+
initialValue: "9999",
|
|
545
|
+
validate: (val) => {
|
|
546
|
+
const n = parseInt(val ?? "", 10);
|
|
547
|
+
if (isNaN(n) || n < 1024 || n > 65535)
|
|
548
|
+
return "Port must be 1024-65535";
|
|
549
|
+
},
|
|
550
|
+
});
|
|
551
|
+
guardCancel(portInput);
|
|
552
|
+
const servePublic = await p.confirm({
|
|
553
|
+
message: "Serve to the public network? (peers can query your node)",
|
|
554
|
+
initialValue: false,
|
|
555
|
+
});
|
|
556
|
+
guardCancel(servePublic);
|
|
557
|
+
const port = parseInt(portInput, 10);
|
|
558
|
+
const bind = servePublic ? "0.0.0.0" : "127.0.0.1";
|
|
559
|
+
p.log.info(`Starting node on ${bind}:${port}...`);
|
|
560
|
+
const { startDaemon } = await import("../node/daemon.js");
|
|
561
|
+
await startDaemon({ port, public: servePublic });
|
|
562
|
+
}
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
// Option 4 — Connect to speaker
|
|
467
565
|
// ---------------------------------------------------------------------------
|
|
468
566
|
async function handleConnect() {
|
|
469
567
|
p.note('This links your Mac to your OpenHome speaker over WebSocket.\nOnce connected, say "deadman radio" on your speaker to control stations.', "Connect speaker");
|
|
@@ -573,12 +671,16 @@ async function handleList() {
|
|
|
573
671
|
async function handleStatus() {
|
|
574
672
|
const statusSpinner = p.spinner();
|
|
575
673
|
statusSpinner.start("Checking status...");
|
|
674
|
+
const daemon = checkDaemon();
|
|
576
675
|
const relayUp = checkRelay();
|
|
577
676
|
const linkUp = checkLocalLink();
|
|
578
677
|
const stations = loadLocalStations();
|
|
579
678
|
const wallet = loadSavedWallet();
|
|
580
679
|
const apiKey = await loadApiKey();
|
|
581
680
|
statusSpinner.stop("Status");
|
|
681
|
+
const daemonLine = daemon.up
|
|
682
|
+
? `Node daemon (:9999) running (${daemon.stations ?? 0} stations, block ${daemon.block ?? 0})`
|
|
683
|
+
: "Node daemon (:9999) not running — run 'Run Node' to start";
|
|
582
684
|
const relayLine = `Relay (localhost:4040) ${relayUp ? "running" : "not running"}`;
|
|
583
685
|
const linkLine = `Speaker bridge ${linkUp ? "connected" : "not running"}`;
|
|
584
686
|
let stationLines;
|
|
@@ -598,5 +700,5 @@ async function handleStatus() {
|
|
|
598
700
|
? `Wallet ${wallet.slice(0, 6)}…${wallet.slice(-4)}`
|
|
599
701
|
: "Wallet not saved";
|
|
600
702
|
const apiLine = `OpenHome API key ${apiKey ? "saved" : "not saved"}`;
|
|
601
|
-
p.note([relayLine, linkLine, stationLines, walletLine, apiLine].join("\n"), "System status");
|
|
703
|
+
p.note([daemonLine, relayLine, linkLine, stationLines, walletLine, apiLine].join("\n"), "System status");
|
|
602
704
|
}
|
package/dist/index.js
CHANGED
|
@@ -47,6 +47,18 @@ program
|
|
|
47
47
|
rl.close();
|
|
48
48
|
await startLocalLink(key.trim());
|
|
49
49
|
});
|
|
50
|
+
program
|
|
51
|
+
.command("daemon")
|
|
52
|
+
.description("Run the deadman.fm node daemon (API + content serving)")
|
|
53
|
+
.option("--port <port>", "HTTP port", "9999")
|
|
54
|
+
.option("--public", "Bind to 0.0.0.0 (serve peers)")
|
|
55
|
+
.action(async (opts) => {
|
|
56
|
+
const { startDaemon } = await import("./node/daemon.js");
|
|
57
|
+
await startDaemon({
|
|
58
|
+
port: parseInt(opts.port, 10),
|
|
59
|
+
public: opts.public ?? false,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
50
62
|
// No arguments = interactive mode
|
|
51
63
|
if (process.argv.length <= 2) {
|
|
52
64
|
interactive();
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deadman.fm — Cross-platform Node Daemon
|
|
3
|
+
*
|
|
4
|
+
* The actual protocol participant. YOUR MACHINE is the network.
|
|
5
|
+
*
|
|
6
|
+
* Replaces background.py with a single Node.js process that:
|
|
7
|
+
* - Seeds content via Hypercore/Hyperswarm (P2P)
|
|
8
|
+
* - Indexes chain events (stations, heartbeats)
|
|
9
|
+
* - Serves the heartbeat API (decentralized, miner-hosted)
|
|
10
|
+
* - Serves segments to peers and the OpenHome speaker
|
|
11
|
+
* - Handles key exchange (on-chain verification)
|
|
12
|
+
*
|
|
13
|
+
* Cross-platform: Mac, Windows, Linux — anywhere Node.js runs.
|
|
14
|
+
* Zero Python dependency. Zero relay dependency.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* import { startDaemon } from "./node/daemon.js";
|
|
18
|
+
* await startDaemon({ port: 9999, stationId: "0" });
|
|
19
|
+
*
|
|
20
|
+
* Or via CLI:
|
|
21
|
+
* deadman-fm --daemon --port 9999
|
|
22
|
+
*/
|
|
23
|
+
interface DaemonOptions {
|
|
24
|
+
port?: number;
|
|
25
|
+
public?: boolean;
|
|
26
|
+
stationId?: string;
|
|
27
|
+
}
|
|
28
|
+
export declare function startDaemon(opts?: DaemonOptions): Promise<void>;
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deadman.fm — Cross-platform Node Daemon
|
|
3
|
+
*
|
|
4
|
+
* The actual protocol participant. YOUR MACHINE is the network.
|
|
5
|
+
*
|
|
6
|
+
* Replaces background.py with a single Node.js process that:
|
|
7
|
+
* - Seeds content via Hypercore/Hyperswarm (P2P)
|
|
8
|
+
* - Indexes chain events (stations, heartbeats)
|
|
9
|
+
* - Serves the heartbeat API (decentralized, miner-hosted)
|
|
10
|
+
* - Serves segments to peers and the OpenHome speaker
|
|
11
|
+
* - Handles key exchange (on-chain verification)
|
|
12
|
+
*
|
|
13
|
+
* Cross-platform: Mac, Windows, Linux — anywhere Node.js runs.
|
|
14
|
+
* Zero Python dependency. Zero relay dependency.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* import { startDaemon } from "./node/daemon.js";
|
|
18
|
+
* await startDaemon({ port: 9999, stationId: "0" });
|
|
19
|
+
*
|
|
20
|
+
* Or via CLI:
|
|
21
|
+
* deadman-fm --daemon --port 9999
|
|
22
|
+
*/
|
|
23
|
+
import { createServer } from "node:http";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { homedir, platform } from "node:os";
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, } from "node:fs";
|
|
27
|
+
import { createPublicClient, http, parseAbiItem } from "viem";
|
|
28
|
+
import { base } from "viem/chains";
|
|
29
|
+
// ─── Config ──────────────────────────────────────────────────────────────────
|
|
30
|
+
const BASE_RPC = "https://mainnet.base.org";
|
|
31
|
+
const REGISTRY_ADDRESS = "0x3b9b6c9BBa15173ba4EF544c17Cb2Ba969d8b585";
|
|
32
|
+
const HEARTBEAT_ADDRESS = "0xB3D6f8FEeC79De2Bf4A1F6bF48f95e5B98D42163";
|
|
33
|
+
const DEPLOY_BLOCK = 43856547n;
|
|
34
|
+
const BLOCKS_PER_SEGMENT = 3;
|
|
35
|
+
// Cross-platform data directory
|
|
36
|
+
const DATA_DIR = join(homedir(), ".deadman-fm");
|
|
37
|
+
const KEYS_DIR = join(DATA_DIR, "keys");
|
|
38
|
+
const SEGMENTS_DIR = join(DATA_DIR, "segments");
|
|
39
|
+
const CACHE_DIR = join(DATA_DIR, "cache");
|
|
40
|
+
// Ensure directories exist
|
|
41
|
+
for (const dir of [DATA_DIR, KEYS_DIR, SEGMENTS_DIR, CACHE_DIR]) {
|
|
42
|
+
mkdirSync(dir, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
// ─── Chain Client ────────────────────────────────────────────────────────────
|
|
45
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
46
|
+
let client;
|
|
47
|
+
function getClient() {
|
|
48
|
+
if (!client) {
|
|
49
|
+
client = createPublicClient({
|
|
50
|
+
chain: base,
|
|
51
|
+
transport: http(BASE_RPC),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return client;
|
|
55
|
+
}
|
|
56
|
+
// ─── Station Discovery (from chain) ─────────────────────────────────────────
|
|
57
|
+
const stationCreatedEvent = parseAbiItem("event StationCreated(uint256 indexed stationId, address indexed creator, string name, bytes32 genre, bytes32 hypercorePubKey, address curve)");
|
|
58
|
+
async function discoverStations() {
|
|
59
|
+
const c = getClient();
|
|
60
|
+
const currentBlock = await c.getBlockNumber();
|
|
61
|
+
const CHUNK = 9500n;
|
|
62
|
+
const stations = [];
|
|
63
|
+
let from = DEPLOY_BLOCK;
|
|
64
|
+
while (from <= currentBlock) {
|
|
65
|
+
const to = from + CHUNK > currentBlock ? currentBlock : from + CHUNK;
|
|
66
|
+
try {
|
|
67
|
+
const logs = await c.getLogs({
|
|
68
|
+
address: REGISTRY_ADDRESS,
|
|
69
|
+
event: stationCreatedEvent,
|
|
70
|
+
fromBlock: from,
|
|
71
|
+
toBlock: to,
|
|
72
|
+
});
|
|
73
|
+
for (const log of logs) {
|
|
74
|
+
const args = log.args;
|
|
75
|
+
const genre = args.genre
|
|
76
|
+
? Buffer.from(args.genre.replace(/^0x/, ""), "hex")
|
|
77
|
+
.toString("utf8")
|
|
78
|
+
.replace(/\0/g, "")
|
|
79
|
+
: "Other";
|
|
80
|
+
stations.push({
|
|
81
|
+
id: args.stationId?.toString() ?? "0",
|
|
82
|
+
creator: args.creator ?? "0x0",
|
|
83
|
+
name: args.name ?? "Unnamed",
|
|
84
|
+
genre,
|
|
85
|
+
blockNumber: Number(log.blockNumber),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// RPC error on chunk — skip and continue
|
|
91
|
+
}
|
|
92
|
+
from = to + 1n;
|
|
93
|
+
}
|
|
94
|
+
return stations;
|
|
95
|
+
}
|
|
96
|
+
// ─── Heartbeat Reads (from chain) ────────────────────────────────────────────
|
|
97
|
+
const heartbeatABI = [
|
|
98
|
+
{
|
|
99
|
+
name: "isAlive",
|
|
100
|
+
type: "function",
|
|
101
|
+
stateMutability: "view",
|
|
102
|
+
inputs: [{ name: "stationId", type: "uint256" }],
|
|
103
|
+
outputs: [{ name: "", type: "bool" }],
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: "isDead",
|
|
107
|
+
type: "function",
|
|
108
|
+
stateMutability: "view",
|
|
109
|
+
inputs: [{ name: "stationId", type: "uint256" }],
|
|
110
|
+
outputs: [{ name: "", type: "bool" }],
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: "daysRemaining",
|
|
114
|
+
type: "function",
|
|
115
|
+
stateMutability: "view",
|
|
116
|
+
inputs: [{ name: "stationId", type: "uint256" }],
|
|
117
|
+
outputs: [{ name: "", type: "uint256" }],
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: "daysUntilDeath",
|
|
121
|
+
type: "function",
|
|
122
|
+
stateMutability: "view",
|
|
123
|
+
inputs: [{ name: "stationId", type: "uint256" }],
|
|
124
|
+
outputs: [{ name: "", type: "uint256" }],
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: "lastHeartbeat",
|
|
128
|
+
type: "function",
|
|
129
|
+
stateMutability: "view",
|
|
130
|
+
inputs: [{ name: "stationId", type: "uint256" }],
|
|
131
|
+
outputs: [{ name: "", type: "uint256" }],
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: "emissionScale",
|
|
135
|
+
type: "function",
|
|
136
|
+
stateMutability: "view",
|
|
137
|
+
inputs: [{ name: "stationId", type: "uint256" }],
|
|
138
|
+
outputs: [{ name: "", type: "uint256" }],
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "contentSegments",
|
|
142
|
+
type: "function",
|
|
143
|
+
stateMutability: "view",
|
|
144
|
+
inputs: [{ name: "stationId", type: "uint256" }],
|
|
145
|
+
outputs: [{ name: "", type: "uint256" }],
|
|
146
|
+
},
|
|
147
|
+
];
|
|
148
|
+
async function fetchHeartbeat(stationId) {
|
|
149
|
+
const c = getClient();
|
|
150
|
+
const sid = BigInt(stationId);
|
|
151
|
+
try {
|
|
152
|
+
const [alive, dead, daysLeft, daysUntilDeath, lastPing, scale, segments] = await Promise.all([
|
|
153
|
+
c.readContract({
|
|
154
|
+
address: HEARTBEAT_ADDRESS,
|
|
155
|
+
abi: heartbeatABI,
|
|
156
|
+
functionName: "isAlive",
|
|
157
|
+
args: [sid],
|
|
158
|
+
}),
|
|
159
|
+
c.readContract({
|
|
160
|
+
address: HEARTBEAT_ADDRESS,
|
|
161
|
+
abi: heartbeatABI,
|
|
162
|
+
functionName: "isDead",
|
|
163
|
+
args: [sid],
|
|
164
|
+
}),
|
|
165
|
+
c.readContract({
|
|
166
|
+
address: HEARTBEAT_ADDRESS,
|
|
167
|
+
abi: heartbeatABI,
|
|
168
|
+
functionName: "daysRemaining",
|
|
169
|
+
args: [sid],
|
|
170
|
+
}),
|
|
171
|
+
c.readContract({
|
|
172
|
+
address: HEARTBEAT_ADDRESS,
|
|
173
|
+
abi: heartbeatABI,
|
|
174
|
+
functionName: "daysUntilDeath",
|
|
175
|
+
args: [sid],
|
|
176
|
+
}),
|
|
177
|
+
c.readContract({
|
|
178
|
+
address: HEARTBEAT_ADDRESS,
|
|
179
|
+
abi: heartbeatABI,
|
|
180
|
+
functionName: "lastHeartbeat",
|
|
181
|
+
args: [sid],
|
|
182
|
+
}),
|
|
183
|
+
c.readContract({
|
|
184
|
+
address: HEARTBEAT_ADDRESS,
|
|
185
|
+
abi: heartbeatABI,
|
|
186
|
+
functionName: "emissionScale",
|
|
187
|
+
args: [sid],
|
|
188
|
+
}),
|
|
189
|
+
c.readContract({
|
|
190
|
+
address: HEARTBEAT_ADDRESS,
|
|
191
|
+
abi: heartbeatABI,
|
|
192
|
+
functionName: "contentSegments",
|
|
193
|
+
args: [sid],
|
|
194
|
+
}),
|
|
195
|
+
]);
|
|
196
|
+
return {
|
|
197
|
+
stationId,
|
|
198
|
+
alive: alive,
|
|
199
|
+
dead: dead,
|
|
200
|
+
daysLeft: Number(daysLeft),
|
|
201
|
+
daysUntilDeath: Number(daysUntilDeath),
|
|
202
|
+
lastPing: Number(lastPing),
|
|
203
|
+
emissionScale: Number(scale),
|
|
204
|
+
contentSegments: Number(segments),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return {
|
|
209
|
+
stationId,
|
|
210
|
+
alive: false,
|
|
211
|
+
dead: false,
|
|
212
|
+
daysLeft: 0,
|
|
213
|
+
daysUntilDeath: 0,
|
|
214
|
+
lastPing: 0,
|
|
215
|
+
emissionScale: 0,
|
|
216
|
+
contentSegments: 0,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// ─── Key Persistence (cross-platform) ────────────────────────────────────────
|
|
221
|
+
function saveStationKey(stationId, key) {
|
|
222
|
+
writeFileSync(join(KEYS_DIR, `${stationId}.key`), key, { mode: 0o600 });
|
|
223
|
+
}
|
|
224
|
+
function loadStationKey(stationId) {
|
|
225
|
+
const p = join(KEYS_DIR, `${stationId}.key`);
|
|
226
|
+
if (existsSync(p))
|
|
227
|
+
return readFileSync(p, "utf8").trim();
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
// ─── On-chain Access Verification ────────────────────────────────────────────
|
|
231
|
+
const accessCache = new Map();
|
|
232
|
+
const ACCESS_CACHE_TTL = 300_000; // 5 minutes
|
|
233
|
+
async function verifyHasAccess(stationId, walletAddress) {
|
|
234
|
+
const cacheKey = `${stationId}:${walletAddress.toLowerCase()}`;
|
|
235
|
+
const cached = accessCache.get(cacheKey);
|
|
236
|
+
if (cached && Date.now() - cached.timestamp < ACCESS_CACHE_TTL) {
|
|
237
|
+
return cached.result;
|
|
238
|
+
}
|
|
239
|
+
const c = getClient();
|
|
240
|
+
try {
|
|
241
|
+
// Get station's bonding curve address
|
|
242
|
+
const curveAddr = await c.readContract({
|
|
243
|
+
address: REGISTRY_ADDRESS,
|
|
244
|
+
abi: [
|
|
245
|
+
{
|
|
246
|
+
name: "getStationCurve",
|
|
247
|
+
type: "function",
|
|
248
|
+
stateMutability: "view",
|
|
249
|
+
inputs: [{ name: "stationId", type: "uint256" }],
|
|
250
|
+
outputs: [{ name: "", type: "address" }],
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
functionName: "getStationCurve",
|
|
254
|
+
args: [BigInt(stationId)],
|
|
255
|
+
});
|
|
256
|
+
if (!curveAddr ||
|
|
257
|
+
curveAddr === "0x0000000000000000000000000000000000000000") {
|
|
258
|
+
accessCache.set(cacheKey, { result: false, timestamp: Date.now() });
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
// Check hasAccess on the bonding curve
|
|
262
|
+
const hasAccess = await c.readContract({
|
|
263
|
+
address: curveAddr,
|
|
264
|
+
abi: [
|
|
265
|
+
{
|
|
266
|
+
name: "hasAccess",
|
|
267
|
+
type: "function",
|
|
268
|
+
stateMutability: "view",
|
|
269
|
+
inputs: [{ name: "addr", type: "address" }],
|
|
270
|
+
outputs: [{ name: "", type: "bool" }],
|
|
271
|
+
},
|
|
272
|
+
],
|
|
273
|
+
functionName: "hasAccess",
|
|
274
|
+
args: [walletAddress],
|
|
275
|
+
});
|
|
276
|
+
accessCache.set(cacheKey, {
|
|
277
|
+
result: hasAccess,
|
|
278
|
+
timestamp: Date.now(),
|
|
279
|
+
});
|
|
280
|
+
return hasAccess;
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// ─── Segment Serving ─────────────────────────────────────────────────────────
|
|
287
|
+
function getLocalStationSegments(stationId) {
|
|
288
|
+
const stationsDir = join(DATA_DIR, "stations");
|
|
289
|
+
if (!existsSync(stationsDir))
|
|
290
|
+
return [];
|
|
291
|
+
// Find station feed directory
|
|
292
|
+
for (const slug of readdirSync(stationsDir)) {
|
|
293
|
+
const metaPath = join(stationsDir, slug, "station.json");
|
|
294
|
+
if (!existsSync(metaPath))
|
|
295
|
+
continue;
|
|
296
|
+
try {
|
|
297
|
+
const meta = JSON.parse(readFileSync(metaPath, "utf8"));
|
|
298
|
+
if (String(meta.stationId) === stationId || meta.name === stationId) {
|
|
299
|
+
const segDir = join(stationsDir, slug, "segments");
|
|
300
|
+
if (existsSync(segDir)) {
|
|
301
|
+
return readdirSync(segDir).filter((f) => f.endsWith(".ts"));
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
function getSegmentData(stationId, seq) {
|
|
312
|
+
const stationsDir = join(DATA_DIR, "stations");
|
|
313
|
+
if (!existsSync(stationsDir))
|
|
314
|
+
return null;
|
|
315
|
+
for (const slug of readdirSync(stationsDir)) {
|
|
316
|
+
const metaPath = join(stationsDir, slug, "station.json");
|
|
317
|
+
if (!existsSync(metaPath))
|
|
318
|
+
continue;
|
|
319
|
+
try {
|
|
320
|
+
const meta = JSON.parse(readFileSync(metaPath, "utf8"));
|
|
321
|
+
if (String(meta.stationId) === stationId || meta.name === stationId) {
|
|
322
|
+
const segPath = join(stationsDir, slug, "segments", `${seq}.ts`);
|
|
323
|
+
if (existsSync(segPath))
|
|
324
|
+
return readFileSync(segPath);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// Also check the P2P cache directory
|
|
332
|
+
const cachePath = join(SEGMENTS_DIR, stationId, `${seq}.ts`);
|
|
333
|
+
if (existsSync(cachePath))
|
|
334
|
+
return readFileSync(cachePath);
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
// ─── HTTP API Server ─────────────────────────────────────────────────────────
|
|
338
|
+
function sendJSON(res, status, data) {
|
|
339
|
+
const body = JSON.stringify(data);
|
|
340
|
+
res.writeHead(status, {
|
|
341
|
+
"Content-Type": "application/json",
|
|
342
|
+
"Access-Control-Allow-Origin": "*",
|
|
343
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
344
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
345
|
+
});
|
|
346
|
+
res.end(body);
|
|
347
|
+
}
|
|
348
|
+
function readBody(req) {
|
|
349
|
+
return new Promise((resolve, reject) => {
|
|
350
|
+
const chunks = [];
|
|
351
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
352
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
353
|
+
req.on("error", reject);
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
function createAPIServer(state) {
|
|
357
|
+
return createServer(async (req, res) => {
|
|
358
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
359
|
+
const path = url.pathname;
|
|
360
|
+
// CORS preflight
|
|
361
|
+
if (req.method === "OPTIONS") {
|
|
362
|
+
res.writeHead(204, {
|
|
363
|
+
"Access-Control-Allow-Origin": "*",
|
|
364
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
365
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
366
|
+
});
|
|
367
|
+
res.end();
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
// ── GET /health ──────────────────────────────────────────────────
|
|
371
|
+
if (path === "/health" && req.method === "GET") {
|
|
372
|
+
sendJSON(res, 200, {
|
|
373
|
+
ok: true,
|
|
374
|
+
platform: platform(),
|
|
375
|
+
block: state.currentBlock,
|
|
376
|
+
stations: state.stations.length,
|
|
377
|
+
heartbeats: state.heartbeats.size,
|
|
378
|
+
peers: state.peerCount,
|
|
379
|
+
uptime: process.uptime(),
|
|
380
|
+
});
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
// ── GET /stations ────────────────────────────────────────────────
|
|
384
|
+
if (path === "/stations" && req.method === "GET") {
|
|
385
|
+
sendJSON(res, 200, state.stations);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
// ── GET /heartbeat/:stationId ────────────────────────────────────
|
|
389
|
+
// The decentralized heartbeat API — miners serve this
|
|
390
|
+
const heartbeatMatch = path.match(/^\/heartbeat\/(\d+)$/);
|
|
391
|
+
if (heartbeatMatch && req.method === "GET") {
|
|
392
|
+
const stationId = heartbeatMatch[1];
|
|
393
|
+
// Check cache first
|
|
394
|
+
const cached = state.heartbeats.get(stationId);
|
|
395
|
+
if (cached && Date.now() - state.lastHeartbeatPoll < 30_000) {
|
|
396
|
+
sendJSON(res, 200, cached);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
// Fresh read from chain
|
|
400
|
+
const hb = await fetchHeartbeat(stationId);
|
|
401
|
+
state.heartbeats.set(stationId, hb);
|
|
402
|
+
sendJSON(res, 200, hb);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
// ── GET /heartbeat — all known stations ──────────────────────────
|
|
406
|
+
if (path === "/heartbeat" && req.method === "GET") {
|
|
407
|
+
const all = [];
|
|
408
|
+
for (const [, hb] of state.heartbeats) {
|
|
409
|
+
all.push(hb);
|
|
410
|
+
}
|
|
411
|
+
sendJSON(res, 200, all);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
// ── GET /segments/:stationId/:seq.ts ─────────────────────────────
|
|
415
|
+
const segMatch = path.match(/^\/segments\/([^/]+)\/(\d+)\.ts$/);
|
|
416
|
+
if (segMatch && req.method === "GET") {
|
|
417
|
+
const data = getSegmentData(segMatch[1], parseInt(segMatch[2], 10));
|
|
418
|
+
if (data) {
|
|
419
|
+
res.writeHead(200, {
|
|
420
|
+
"Content-Type": "video/mp2t",
|
|
421
|
+
"Content-Length": data.length.toString(),
|
|
422
|
+
"Access-Control-Allow-Origin": "*",
|
|
423
|
+
});
|
|
424
|
+
res.end(data);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
sendJSON(res, 404, { error: "segment not found" });
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
// ── POST /key-exchange ───────────────────────────────────────────
|
|
431
|
+
if (path === "/key-exchange" && req.method === "POST") {
|
|
432
|
+
try {
|
|
433
|
+
const body = JSON.parse(await readBody(req));
|
|
434
|
+
const { stationId, walletAddress } = body;
|
|
435
|
+
if (!stationId || !walletAddress) {
|
|
436
|
+
sendJSON(res, 400, { error: "missing stationId or walletAddress" });
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const key = loadStationKey(stationId);
|
|
440
|
+
if (!key) {
|
|
441
|
+
sendJSON(res, 404, { error: "no key for this station" });
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const hasAccess = await verifyHasAccess(stationId, walletAddress);
|
|
445
|
+
if (!hasAccess) {
|
|
446
|
+
sendJSON(res, 403, {
|
|
447
|
+
error: "no position held — buy on the bonding curve first",
|
|
448
|
+
});
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
sendJSON(res, 200, { stationKey: key, stationId });
|
|
452
|
+
console.log(`[daemon] shared key for station ${stationId} with ${walletAddress.slice(0, 10)}...`);
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
sendJSON(res, 400, { error: "invalid request body" });
|
|
456
|
+
}
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
// ── GET /register/lookup?room_code=XXXX ──────────────────────
|
|
460
|
+
if (path === "/register/lookup" && req.method === "GET") {
|
|
461
|
+
const roomCode = url.searchParams.get("room_code");
|
|
462
|
+
if (!roomCode) {
|
|
463
|
+
sendJSON(res, 400, { error: "missing room_code query parameter" });
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const registryFile = join(CACHE_DIR, "device-registry.json");
|
|
467
|
+
if (!existsSync(registryFile)) {
|
|
468
|
+
sendJSON(res, 404, { error: "no devices registered yet" });
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
let registry = {};
|
|
472
|
+
try {
|
|
473
|
+
registry = JSON.parse(readFileSync(registryFile, "utf8"));
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
sendJSON(res, 500, { error: "failed to read device registry" });
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const entry = Object.values(registry).find((d) => d.room_code === roomCode.toUpperCase());
|
|
480
|
+
if (!entry) {
|
|
481
|
+
sendJSON(res, 404, { error: "room code not found" });
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
sendJSON(res, 200, entry);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
// ── POST /register — speaker device registration ───────────────
|
|
488
|
+
if (path === "/register" && req.method === "POST") {
|
|
489
|
+
try {
|
|
490
|
+
const body = JSON.parse(await readBody(req));
|
|
491
|
+
const { device_id } = body;
|
|
492
|
+
if (!device_id) {
|
|
493
|
+
sendJSON(res, 400, { error: "missing device_id" });
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
// Load or create device registry
|
|
497
|
+
const registryFile = join(CACHE_DIR, "device-registry.json");
|
|
498
|
+
let registry = {};
|
|
499
|
+
if (existsSync(registryFile)) {
|
|
500
|
+
try {
|
|
501
|
+
registry = JSON.parse(readFileSync(registryFile, "utf8"));
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
registry = {};
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
// Existing device — return known wallet
|
|
508
|
+
if (registry[device_id]) {
|
|
509
|
+
sendJSON(res, 200, {
|
|
510
|
+
address: registry[device_id].address,
|
|
511
|
+
room_code: registry[device_id].room_code,
|
|
512
|
+
is_new: false,
|
|
513
|
+
});
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
// New device — generate room code, placeholder address
|
|
517
|
+
// TODO: Replace with CDP wallet creation via Coinbase SDK
|
|
518
|
+
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
519
|
+
const roomCode = Array.from({ length: 4 }, () => chars[Math.floor(Math.random() * chars.length)]).join("");
|
|
520
|
+
// Placeholder: deterministic address from device_id hash
|
|
521
|
+
// In production: call Coinbase CDP API to create a real wallet
|
|
522
|
+
const { createHash } = await import("node:crypto");
|
|
523
|
+
const hash = createHash("sha256").update(device_id).digest("hex");
|
|
524
|
+
const placeholderAddress = `0x${hash.slice(0, 40)}`;
|
|
525
|
+
registry[device_id] = {
|
|
526
|
+
address: placeholderAddress,
|
|
527
|
+
room_code: roomCode,
|
|
528
|
+
created_at: new Date().toISOString(),
|
|
529
|
+
};
|
|
530
|
+
writeFileSync(registryFile, JSON.stringify(registry, null, 2));
|
|
531
|
+
console.log(`[daemon] registered device ${device_id} → ${placeholderAddress} (code: ${roomCode})`);
|
|
532
|
+
sendJSON(res, 200, {
|
|
533
|
+
address: placeholderAddress,
|
|
534
|
+
room_code: roomCode,
|
|
535
|
+
is_new: true,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
sendJSON(res, 400, { error: "invalid request body" });
|
|
540
|
+
}
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
// ── 404 ──────────────────────────────────────────────────────────
|
|
544
|
+
sendJSON(res, 404, {
|
|
545
|
+
error: "not found",
|
|
546
|
+
routes: [
|
|
547
|
+
"GET /health",
|
|
548
|
+
"GET /stations",
|
|
549
|
+
"GET /heartbeat/:stationId",
|
|
550
|
+
"GET /heartbeat",
|
|
551
|
+
"GET /segments/:stationId/:seq.ts",
|
|
552
|
+
"GET /register/lookup?room_code=XXXX",
|
|
553
|
+
"POST /key-exchange",
|
|
554
|
+
"POST /register",
|
|
555
|
+
],
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
// ─── Background Pollers ──────────────────────────────────────────────────────
|
|
560
|
+
async function pollStations(state) {
|
|
561
|
+
try {
|
|
562
|
+
state.stations = await discoverStations();
|
|
563
|
+
state.lastStationPoll = Date.now();
|
|
564
|
+
console.log(`[daemon] indexed ${state.stations.length} stations from chain`);
|
|
565
|
+
}
|
|
566
|
+
catch (e) {
|
|
567
|
+
console.error(`[daemon] station poll failed: ${e}`);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
async function pollHeartbeats(state) {
|
|
571
|
+
try {
|
|
572
|
+
const promises = state.stations.map(async (s) => {
|
|
573
|
+
const hb = await fetchHeartbeat(s.id);
|
|
574
|
+
state.heartbeats.set(s.id, hb);
|
|
575
|
+
});
|
|
576
|
+
await Promise.all(promises);
|
|
577
|
+
state.lastHeartbeatPoll = Date.now();
|
|
578
|
+
const alive = [...state.heartbeats.values()].filter((h) => h.alive).length;
|
|
579
|
+
console.log(`[daemon] heartbeats: ${alive}/${state.heartbeats.size} alive`);
|
|
580
|
+
}
|
|
581
|
+
catch (e) {
|
|
582
|
+
console.error(`[daemon] heartbeat poll failed: ${e}`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
async function pollBlock(state) {
|
|
586
|
+
try {
|
|
587
|
+
const c = getClient();
|
|
588
|
+
state.currentBlock = Number(await c.getBlockNumber());
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
// silent — block polling is best-effort
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
// ─── Main Entry ──────────────────────────────────────────────────────────────
|
|
595
|
+
export async function startDaemon(opts = {}) {
|
|
596
|
+
const port = opts.port ?? 9999;
|
|
597
|
+
const bindAddr = opts.public ? "0.0.0.0" : "127.0.0.1";
|
|
598
|
+
const state = {
|
|
599
|
+
stations: [],
|
|
600
|
+
currentBlock: 0,
|
|
601
|
+
lastStationPoll: 0,
|
|
602
|
+
lastHeartbeatPoll: 0,
|
|
603
|
+
heartbeats: new Map(),
|
|
604
|
+
peerCount: 0,
|
|
605
|
+
};
|
|
606
|
+
console.log(`[daemon] deadman.fm node starting on ${platform()}`);
|
|
607
|
+
console.log(`[daemon] data: ${DATA_DIR}`);
|
|
608
|
+
// Initial chain reads
|
|
609
|
+
await pollBlock(state);
|
|
610
|
+
console.log(`[daemon] Base block: ${state.currentBlock}`);
|
|
611
|
+
await pollStations(state);
|
|
612
|
+
await pollHeartbeats(state);
|
|
613
|
+
// Start HTTP API server
|
|
614
|
+
const server = createAPIServer(state);
|
|
615
|
+
server.listen(port, bindAddr, () => {
|
|
616
|
+
console.log(`[daemon] API server: http://${bindAddr}:${port}`);
|
|
617
|
+
console.log(`[daemon] heartbeat API: http://${bindAddr}:${port}/heartbeat/:stationId`);
|
|
618
|
+
console.log(`[daemon] ${state.stations.length} stations indexed, ${state.heartbeats.size} heartbeats cached`);
|
|
619
|
+
});
|
|
620
|
+
// Background polling intervals
|
|
621
|
+
// Block: every 6 seconds
|
|
622
|
+
setInterval(() => pollBlock(state), 6_000);
|
|
623
|
+
// Stations: every 60 seconds
|
|
624
|
+
setInterval(() => pollStations(state), 60_000);
|
|
625
|
+
// Heartbeats: every 30 seconds
|
|
626
|
+
setInterval(() => pollHeartbeats(state), 30_000);
|
|
627
|
+
// Write state file for Local Link / OpenHome ability interop
|
|
628
|
+
setInterval(() => {
|
|
629
|
+
const stateFile = join(DATA_DIR, "daemon-state.json");
|
|
630
|
+
const out = {
|
|
631
|
+
block: state.currentBlock,
|
|
632
|
+
stations: state.stations,
|
|
633
|
+
heartbeats: Object.fromEntries(state.heartbeats),
|
|
634
|
+
peers: state.peerCount,
|
|
635
|
+
port,
|
|
636
|
+
platform: platform(),
|
|
637
|
+
updatedAt: new Date().toISOString(),
|
|
638
|
+
};
|
|
639
|
+
try {
|
|
640
|
+
writeFileSync(stateFile, JSON.stringify(out, null, 2));
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
// non-critical
|
|
644
|
+
}
|
|
645
|
+
}, 5_000);
|
|
646
|
+
// Keep alive
|
|
647
|
+
await new Promise(() => { });
|
|
648
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deadman.fm — Heartbeat ping utility
|
|
3
|
+
*
|
|
4
|
+
* Calls StationHeartbeat.ping(stationId, segments) on Base to reset
|
|
5
|
+
* the 7-day dead man's switch after content is synced.
|
|
6
|
+
*
|
|
7
|
+
* Only the station creator can call ping(). If no private key is
|
|
8
|
+
* configured, a warning is logged and the call is skipped gracefully.
|
|
9
|
+
*/
|
|
10
|
+
export declare const HEARTBEAT_ADDRESS: "0xB3D6f8FEeC79De2Bf4A1F6bF48f95e5B98D42163";
|
|
11
|
+
export declare const HEARTBEAT_ABI: readonly [{
|
|
12
|
+
readonly name: "ping";
|
|
13
|
+
readonly type: "function";
|
|
14
|
+
readonly stateMutability: "nonpayable";
|
|
15
|
+
readonly inputs: readonly [{
|
|
16
|
+
readonly type: "uint256";
|
|
17
|
+
readonly name: "stationId";
|
|
18
|
+
}, {
|
|
19
|
+
readonly type: "uint256";
|
|
20
|
+
readonly name: "segments";
|
|
21
|
+
}];
|
|
22
|
+
readonly outputs: readonly [];
|
|
23
|
+
}, {
|
|
24
|
+
readonly name: "HeartbeatPinged";
|
|
25
|
+
readonly type: "event";
|
|
26
|
+
readonly inputs: readonly [{
|
|
27
|
+
readonly type: "uint256";
|
|
28
|
+
readonly name: "stationId";
|
|
29
|
+
readonly indexed: true;
|
|
30
|
+
}, {
|
|
31
|
+
readonly type: "address";
|
|
32
|
+
readonly name: "creator";
|
|
33
|
+
readonly indexed: true;
|
|
34
|
+
}, {
|
|
35
|
+
readonly type: "uint256";
|
|
36
|
+
readonly name: "segments";
|
|
37
|
+
}, {
|
|
38
|
+
readonly type: "uint256";
|
|
39
|
+
readonly name: "timestamp";
|
|
40
|
+
}];
|
|
41
|
+
}];
|
|
42
|
+
/**
|
|
43
|
+
* Ping the StationHeartbeat contract to reset the dead man's switch.
|
|
44
|
+
*
|
|
45
|
+
* @param stationId On-chain station ID (uint256)
|
|
46
|
+
* @param totalSegments Total segment count to report
|
|
47
|
+
* @param privateKey Optional hex private key (0x-prefixed). If omitted,
|
|
48
|
+
* the call is skipped and a warning is printed.
|
|
49
|
+
* @returns Transaction hash, or null if skipped.
|
|
50
|
+
*/
|
|
51
|
+
export declare function pingHeartbeat(stationId: number, totalSegments: number, privateKey?: string): Promise<string | null>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deadman.fm — Heartbeat ping utility
|
|
3
|
+
*
|
|
4
|
+
* Calls StationHeartbeat.ping(stationId, segments) on Base to reset
|
|
5
|
+
* the 7-day dead man's switch after content is synced.
|
|
6
|
+
*
|
|
7
|
+
* Only the station creator can call ping(). If no private key is
|
|
8
|
+
* configured, a warning is logged and the call is skipped gracefully.
|
|
9
|
+
*/
|
|
10
|
+
import { createWalletClient, createPublicClient, http, parseAbi, } from "viem";
|
|
11
|
+
import { base } from "viem/chains";
|
|
12
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
13
|
+
// ── Contract constants ────────────────────────────────────────────────────────
|
|
14
|
+
export const HEARTBEAT_ADDRESS = "0xB3D6f8FEeC79De2Bf4A1F6bF48f95e5B98D42163";
|
|
15
|
+
export const HEARTBEAT_ABI = parseAbi([
|
|
16
|
+
"function ping(uint256 stationId, uint256 segments) external",
|
|
17
|
+
"event HeartbeatPinged(uint256 indexed stationId, address indexed creator, uint256 segments, uint256 timestamp)",
|
|
18
|
+
]);
|
|
19
|
+
// ── Main export ───────────────────────────────────────────────────────────────
|
|
20
|
+
/**
|
|
21
|
+
* Ping the StationHeartbeat contract to reset the dead man's switch.
|
|
22
|
+
*
|
|
23
|
+
* @param stationId On-chain station ID (uint256)
|
|
24
|
+
* @param totalSegments Total segment count to report
|
|
25
|
+
* @param privateKey Optional hex private key (0x-prefixed). If omitted,
|
|
26
|
+
* the call is skipped and a warning is printed.
|
|
27
|
+
* @returns Transaction hash, or null if skipped.
|
|
28
|
+
*/
|
|
29
|
+
export async function pingHeartbeat(stationId, totalSegments, privateKey) {
|
|
30
|
+
if (!privateKey) {
|
|
31
|
+
console.warn("\n ⚠️ Heartbeat NOT pinged — no wallet configured.");
|
|
32
|
+
console.warn(" The 7-day dead man's switch is ticking. Ping manually:");
|
|
33
|
+
console.warn(` deadman-fm heartbeat --station ${stationId} --segments ${totalSegments} --key <PRIVATE_KEY>`);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
// Normalise key: viem expects a 0x-prefixed 32-byte hex string
|
|
37
|
+
const keyHex = privateKey.startsWith("0x")
|
|
38
|
+
? privateKey
|
|
39
|
+
: `0x${privateKey}`;
|
|
40
|
+
let account;
|
|
41
|
+
try {
|
|
42
|
+
account = privateKeyToAccount(keyHex);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
console.error(" ❌ Heartbeat: invalid private key —", err.message);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const walletClient = createWalletClient({
|
|
49
|
+
account,
|
|
50
|
+
chain: base,
|
|
51
|
+
transport: http(),
|
|
52
|
+
});
|
|
53
|
+
const publicClient = createPublicClient({
|
|
54
|
+
chain: base,
|
|
55
|
+
transport: http(),
|
|
56
|
+
});
|
|
57
|
+
console.log(`\n 💓 Pinging heartbeat for station #${stationId} (${totalSegments} segments)...`);
|
|
58
|
+
try {
|
|
59
|
+
const hash = await walletClient.writeContract({
|
|
60
|
+
address: HEARTBEAT_ADDRESS,
|
|
61
|
+
abi: HEARTBEAT_ABI,
|
|
62
|
+
functionName: "ping",
|
|
63
|
+
args: [BigInt(stationId), BigInt(totalSegments)],
|
|
64
|
+
});
|
|
65
|
+
// Wait for 1 confirmation so the user gets a definitive success signal
|
|
66
|
+
await publicClient.waitForTransactionReceipt({ hash, confirmations: 1 });
|
|
67
|
+
console.log(` ✅ Heartbeat pinged — tx: ${hash}`);
|
|
68
|
+
return hash;
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
console.error(" ❌ Heartbeat ping failed —", err.message);
|
|
72
|
+
console.warn(" The 7-day dead man's switch is still ticking. Retry with:");
|
|
73
|
+
console.warn(` deadman-fm heartbeat --station ${stationId} --segments ${totalSegments} --key <PRIVATE_KEY>`);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|