deadman-fm 0.1.4 → 0.1.7
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 +154 -64
- package/dist/index.d.ts +1 -6
- package/dist/index.js +2 -50
- 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", {
|
|
@@ -249,25 +297,19 @@ export async function interactive() {
|
|
|
249
297
|
message: "What do you want to do?",
|
|
250
298
|
options: [
|
|
251
299
|
{
|
|
252
|
-
value: "
|
|
300
|
+
value: "mine",
|
|
253
301
|
label: "Start Mining",
|
|
254
|
-
hint: "
|
|
302
|
+
hint: "sync music, start earning $FM",
|
|
255
303
|
},
|
|
256
304
|
{
|
|
257
|
-
value: "
|
|
258
|
-
label: "
|
|
259
|
-
hint: "
|
|
305
|
+
value: "browse",
|
|
306
|
+
label: "Browse Stations",
|
|
307
|
+
hint: "see what's on the network",
|
|
260
308
|
},
|
|
261
|
-
{
|
|
262
|
-
value: "connect",
|
|
263
|
-
label: "Connect speaker",
|
|
264
|
-
hint: "link your OpenHome device",
|
|
265
|
-
},
|
|
266
|
-
{ value: "list", label: "List stations", hint: "browse the network" },
|
|
267
309
|
{
|
|
268
310
|
value: "status",
|
|
269
|
-
label: "
|
|
270
|
-
hint: "
|
|
311
|
+
label: "Status",
|
|
312
|
+
hint: "check everything",
|
|
271
313
|
},
|
|
272
314
|
{ value: "exit", label: "Exit" },
|
|
273
315
|
],
|
|
@@ -277,16 +319,10 @@ export async function interactive() {
|
|
|
277
319
|
process.exit(0);
|
|
278
320
|
}
|
|
279
321
|
switch (choice) {
|
|
280
|
-
case "
|
|
322
|
+
case "mine":
|
|
281
323
|
await handleCreate();
|
|
282
324
|
break;
|
|
283
|
-
case "
|
|
284
|
-
await handleSync();
|
|
285
|
-
break;
|
|
286
|
-
case "connect":
|
|
287
|
-
await handleConnect();
|
|
288
|
-
break;
|
|
289
|
-
case "list":
|
|
325
|
+
case "browse":
|
|
290
326
|
await handleList();
|
|
291
327
|
break;
|
|
292
328
|
case "status":
|
|
@@ -299,9 +335,50 @@ export async function interactive() {
|
|
|
299
335
|
// Option 1 — Create a station
|
|
300
336
|
// ---------------------------------------------------------------------------
|
|
301
337
|
async function handleCreate() {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
338
|
+
// First check: do we have a saved wallet?
|
|
339
|
+
let wallet = loadSavedWallet();
|
|
340
|
+
if (!wallet) {
|
|
341
|
+
const rawWallet = await p.text({
|
|
342
|
+
message: "Your wallet address (the one that created your stations)",
|
|
343
|
+
placeholder: "0x...",
|
|
344
|
+
validate: (val) => {
|
|
345
|
+
if (!val?.match(/^0x[a-fA-F0-9]{40}$/))
|
|
346
|
+
return "Must be a valid Ethereum address";
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
guardCancel(rawWallet);
|
|
350
|
+
wallet = rawWallet.trim();
|
|
351
|
+
// Save for next time
|
|
352
|
+
const configDir = join(homedir(), ".deadman-fm");
|
|
353
|
+
const configPath = join(configDir, "config.json");
|
|
354
|
+
try {
|
|
355
|
+
const { mkdirSync, writeFileSync } = await import("node:fs");
|
|
356
|
+
mkdirSync(configDir, { recursive: true });
|
|
357
|
+
const existing = existsSync(configPath)
|
|
358
|
+
? JSON.parse(readFileSync(configPath, "utf8"))
|
|
359
|
+
: {};
|
|
360
|
+
existing.walletAddress = wallet;
|
|
361
|
+
writeFileSync(configPath, JSON.stringify(existing, null, 2));
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
/* non-critical */
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// Check chain for stations owned by this wallet
|
|
368
|
+
const chainSpinner = p.spinner();
|
|
369
|
+
chainSpinner.start("Checking Base chain for your stations...");
|
|
370
|
+
let chainStations = [];
|
|
371
|
+
try {
|
|
372
|
+
chainStations = await fetchMyStationsFromChain(wallet);
|
|
373
|
+
}
|
|
374
|
+
catch (err) {
|
|
375
|
+
chainSpinner.stop("Chain query failed");
|
|
376
|
+
p.log.error(`Could not reach Base chain: ${err instanceof Error ? err.message : String(err)}`);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
chainSpinner.stop(`Found ${chainStations.length} station${chainStations.length === 1 ? "" : "s"} on chain`);
|
|
380
|
+
if (chainStations.length === 0) {
|
|
381
|
+
p.log.warn("No stations found for this wallet.");
|
|
305
382
|
p.log.info("Win one in the daily emoji auction at deadman.fm");
|
|
306
383
|
const openAuction = await p.confirm({
|
|
307
384
|
message: "Open the auction page?",
|
|
@@ -324,50 +401,36 @@ async function handleCreate() {
|
|
|
324
401
|
}
|
|
325
402
|
return;
|
|
326
403
|
}
|
|
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
|
-
}
|
|
404
|
+
// Has stations on chain — pick one and sync
|
|
341
405
|
let stationName;
|
|
342
|
-
let stationId
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
406
|
+
let stationId;
|
|
407
|
+
let genre;
|
|
408
|
+
if (chainStations.length === 1) {
|
|
409
|
+
stationName = chainStations[0].name;
|
|
410
|
+
stationId = parseInt(chainStations[0].id, 10);
|
|
411
|
+
genre = chainStations[0].genre;
|
|
412
|
+
p.log.info(`Using station: ${stationName} (#${stationId})`);
|
|
347
413
|
}
|
|
348
414
|
else {
|
|
349
415
|
const selected = await p.select({
|
|
350
416
|
message: "Which station?",
|
|
351
|
-
options:
|
|
352
|
-
value:
|
|
353
|
-
label:
|
|
354
|
-
hint:
|
|
355
|
-
? `#${meta.stationId} · ${meta.genre}`
|
|
356
|
-
: meta.genre,
|
|
417
|
+
options: chainStations.map((s) => ({
|
|
418
|
+
value: s.id,
|
|
419
|
+
label: s.name,
|
|
420
|
+
hint: `#${s.id} · ${s.genre}`,
|
|
357
421
|
})),
|
|
358
422
|
});
|
|
359
423
|
guardCancel(selected);
|
|
360
|
-
const found =
|
|
361
|
-
stationName = found
|
|
362
|
-
stationId = found
|
|
424
|
+
const found = chainStations.find((s) => s.id === selected);
|
|
425
|
+
stationName = found.name;
|
|
426
|
+
stationId = parseInt(found.id, 10);
|
|
427
|
+
genre = found.genre;
|
|
363
428
|
}
|
|
364
|
-
|
|
429
|
+
// Go straight to sync with the selected chain station
|
|
430
|
+
await handleSyncForStation(stationName, stationId, genre);
|
|
365
431
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
// ---------------------------------------------------------------------------
|
|
369
|
-
async function handleSyncForStation(stationName, stationId) {
|
|
370
|
-
const idLabel = stationId !== null ? `#${stationId}` : `"${stationName}"`;
|
|
432
|
+
async function handleSyncForStation(stationName, stationId, genre) {
|
|
433
|
+
const idLabel = `#${stationId}`;
|
|
371
434
|
const rawPath = await p.text({
|
|
372
435
|
message: "Path to music folder",
|
|
373
436
|
placeholder: "~/Music",
|
|
@@ -418,10 +481,6 @@ async function handleSyncForStation(stationName, stationId) {
|
|
|
418
481
|
const syncSpinner = p.spinner();
|
|
419
482
|
syncSpinner.start(`Syncing ${tracks.length} tracks to ${idLabel}...`);
|
|
420
483
|
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
484
|
const originalLog = console.log;
|
|
426
485
|
let totalSegments = 0;
|
|
427
486
|
let currentTrackIdx = 0;
|
|
@@ -463,7 +522,34 @@ async function handleSyncForStation(stationName, stationId) {
|
|
|
463
522
|
await new Promise(() => { });
|
|
464
523
|
}
|
|
465
524
|
// ---------------------------------------------------------------------------
|
|
466
|
-
// Option 3 —
|
|
525
|
+
// Option 3 — Run Node (daemon with heartbeat API)
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
async function handleNode() {
|
|
528
|
+
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");
|
|
529
|
+
const portInput = await p.text({
|
|
530
|
+
message: "API port",
|
|
531
|
+
placeholder: "9999",
|
|
532
|
+
initialValue: "9999",
|
|
533
|
+
validate: (val) => {
|
|
534
|
+
const n = parseInt(val ?? "", 10);
|
|
535
|
+
if (isNaN(n) || n < 1024 || n > 65535)
|
|
536
|
+
return "Port must be 1024-65535";
|
|
537
|
+
},
|
|
538
|
+
});
|
|
539
|
+
guardCancel(portInput);
|
|
540
|
+
const servePublic = await p.confirm({
|
|
541
|
+
message: "Serve to the public network? (peers can query your node)",
|
|
542
|
+
initialValue: false,
|
|
543
|
+
});
|
|
544
|
+
guardCancel(servePublic);
|
|
545
|
+
const port = parseInt(portInput, 10);
|
|
546
|
+
const bind = servePublic ? "0.0.0.0" : "127.0.0.1";
|
|
547
|
+
p.log.info(`Starting node on ${bind}:${port}...`);
|
|
548
|
+
const { startDaemon } = await import("../node/daemon.js");
|
|
549
|
+
await startDaemon({ port, public: servePublic });
|
|
550
|
+
}
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
// Option 4 — Connect to speaker
|
|
467
553
|
// ---------------------------------------------------------------------------
|
|
468
554
|
async function handleConnect() {
|
|
469
555
|
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 +659,16 @@ async function handleList() {
|
|
|
573
659
|
async function handleStatus() {
|
|
574
660
|
const statusSpinner = p.spinner();
|
|
575
661
|
statusSpinner.start("Checking status...");
|
|
662
|
+
const daemon = checkDaemon();
|
|
576
663
|
const relayUp = checkRelay();
|
|
577
664
|
const linkUp = checkLocalLink();
|
|
578
665
|
const stations = loadLocalStations();
|
|
579
666
|
const wallet = loadSavedWallet();
|
|
580
667
|
const apiKey = await loadApiKey();
|
|
581
668
|
statusSpinner.stop("Status");
|
|
669
|
+
const daemonLine = daemon.up
|
|
670
|
+
? `Node daemon (:9999) running (${daemon.stations ?? 0} stations, block ${daemon.block ?? 0})`
|
|
671
|
+
: "Node daemon (:9999) not running — run 'Run Node' to start";
|
|
582
672
|
const relayLine = `Relay (localhost:4040) ${relayUp ? "running" : "not running"}`;
|
|
583
673
|
const linkLine = `Speaker bridge ${linkUp ? "connected" : "not running"}`;
|
|
584
674
|
let stationLines;
|
|
@@ -598,5 +688,5 @@ async function handleStatus() {
|
|
|
598
688
|
? `Wallet ${wallet.slice(0, 6)}…${wallet.slice(-4)}`
|
|
599
689
|
: "Wallet not saved";
|
|
600
690
|
const apiLine = `OpenHome API key ${apiKey ? "saved" : "not saved"}`;
|
|
601
|
-
p.note([relayLine, linkLine, stationLines, walletLine, apiLine].join("\n"), "System status");
|
|
691
|
+
p.note([daemonLine, relayLine, linkLine, stationLines, walletLine, apiLine].join("\n"), "System status");
|
|
602
692
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,11 +2,6 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* 💀.fm CLI — Decentralized pirate radio
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* Or use commands directly:
|
|
7
|
-
* deadman create --name "Station" --genre Electronic --music ~/Music/
|
|
8
|
-
* deadman list
|
|
9
|
-
* deadman seed --station 0
|
|
10
|
-
* deadman connect
|
|
5
|
+
* Just run it: npx deadman-fm
|
|
11
6
|
*/
|
|
12
7
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -2,55 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* 💀.fm CLI — Decentralized pirate radio
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
* Or use commands directly:
|
|
7
|
-
* deadman create --name "Station" --genre Electronic --music ~/Music/
|
|
8
|
-
* deadman list
|
|
9
|
-
* deadman seed --station 0
|
|
10
|
-
* deadman connect
|
|
5
|
+
* Just run it: npx deadman-fm
|
|
11
6
|
*/
|
|
12
|
-
import { Command } from "commander";
|
|
13
|
-
import { createStation } from "./commands/create.js";
|
|
14
|
-
import { listStations } from "./commands/list.js";
|
|
15
|
-
import { seedStation } from "./commands/seed.js";
|
|
16
7
|
import { interactive } from "./commands/interactive.js";
|
|
17
|
-
|
|
18
|
-
program
|
|
19
|
-
.name("deadman")
|
|
20
|
-
.description("💀.fm — Decentralized pirate radio")
|
|
21
|
-
.version("0.1.0");
|
|
22
|
-
program
|
|
23
|
-
.command("create")
|
|
24
|
-
.description("Create a new radio station")
|
|
25
|
-
.requiredOption("--name <name>", "Station name")
|
|
26
|
-
.requiredOption("--genre <genre>", "Genre")
|
|
27
|
-
.requiredOption("--music <path>", "Path to audio files folder")
|
|
28
|
-
.option("--watch", "Watch folder for new files")
|
|
29
|
-
.action(createStation);
|
|
30
|
-
program
|
|
31
|
-
.command("seed")
|
|
32
|
-
.description("Seed an existing station")
|
|
33
|
-
.requiredOption("--station <id>", "Station ID")
|
|
34
|
-
.action(seedStation);
|
|
35
|
-
program.command("list").description("List all stations").action(listStations);
|
|
36
|
-
program
|
|
37
|
-
.command("connect")
|
|
38
|
-
.description("Connect to your OpenHome speaker")
|
|
39
|
-
.action(async () => {
|
|
40
|
-
const { startLocalLink } = await import("./commands/local-link.js");
|
|
41
|
-
const readline = await import("node:readline");
|
|
42
|
-
const rl = readline.createInterface({
|
|
43
|
-
input: process.stdin,
|
|
44
|
-
output: process.stdout,
|
|
45
|
-
});
|
|
46
|
-
const key = await new Promise((r) => rl.question("OpenHome API key: ", r));
|
|
47
|
-
rl.close();
|
|
48
|
-
await startLocalLink(key.trim());
|
|
49
|
-
});
|
|
50
|
-
// No arguments = interactive mode
|
|
51
|
-
if (process.argv.length <= 2) {
|
|
52
|
-
interactive();
|
|
53
|
-
}
|
|
54
|
-
else {
|
|
55
|
-
program.parse();
|
|
56
|
-
}
|
|
8
|
+
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
|
+
}
|