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.
@@ -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 {};
@@ -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: "create",
300
+ value: "mine",
253
301
  label: "Start Mining",
254
- hint: "point to your music, start earning $FM",
302
+ hint: "sync music, start earning $FM",
255
303
  },
256
304
  {
257
- value: "sync",
258
- label: "Sync Music",
259
- hint: "add or update tracks on your station",
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: "Check status",
270
- hint: "relay, speaker, wallet",
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 "create":
322
+ case "mine":
281
323
  await handleCreate();
282
324
  break;
283
- case "sync":
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
- const stations = loadLocalStations();
303
- if (stations.length === 0) {
304
- p.log.warn("You don't own a station yet.");
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 — go straight to sync
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 = null;
343
- if (stations.length === 1) {
344
- stationName = stations[0].meta.name;
345
- stationId = stations[0].meta.stationId ?? null;
346
- p.log.info(`Using station: ${stationName}`);
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: stations.map(({ meta }) => ({
352
- value: meta.name,
353
- label: meta.name,
354
- hint: meta.stationId !== undefined
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 = stations.find((s) => s.meta.name === selected);
361
- stationName = found?.meta.name ?? selected;
362
- stationId = found?.meta.stationId ?? null;
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
- await handleSyncForStation(stationName, stationId);
429
+ // Go straight to sync with the selected chain station
430
+ await handleSyncForStation(stationName, stationId, genre);
365
431
  }
366
- // ---------------------------------------------------------------------------
367
- // Shared sync logic
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 — Connect to speaker
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
- * Run with no arguments for interactive menu.
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
- * Run with no arguments for interactive menu.
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
- const program = new Command();
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deadman-fm",
3
- "version": "0.1.4",
3
+ "version": "0.1.7",
4
4
  "description": "💀.fm — Create and seed decentralized radio stations",
5
5
  "type": "module",
6
6
  "files": [