Enrich kind 30078 beacon for public ATM status pages #43

Open
opened 2026-06-13 22:02:59 +00:00 by padreug · 1 comment
Owner

Migrated from aiolabs/lamassu-next#43 — opened by @padreug on 2026-04-11.\n\n## Problem

The kind 30078 availability beacon currently publishes a minimal payload:

{
  "cash_in": true,
  "cash_out": true,
  "cash_level": "good",
  "fiat": "USD",
  "model": "sintra"
}

This is enough for an operator to see "is the machine alive", but not enough for a public-facing ATM status page that shows customers useful information — like what the screenshot below shows:

  • Machine name and location
  • What operations are available (Buy B, Sell B)
  • Stock status (Stocked / Low / Empty)
  • Fees being charged
  • How recently the machine checked in

A public status page just subscribes to kind 30078 events from known machine npubs (or from the operator's NIP-51 fleet roster) and renders a card per machine. The beacon needs to carry enough data to make that useful without any backend.

Current state

Two related but disconnected data models exist:

  1. useAvailabilityBroadcast.ts (what the machine actually publishes) — no fees, no name, no location, no denominations.
  2. ServiceBeacon interface in packages/clink/src/types.ts — has name, avatarUrl, fees, but the machine doesn't use this type at all.

These should converge into a single schema that serves both fleet management and customer-facing status.

Proposed beacon content

{
  "name": "Bitcoinmat",
  "location": "Trece Cielos",
  "geo": "14.63,-90.51",
  "fiat": "GTQ",
  "model": "batm3",
  "cash_in": true,
  "cash_out": true,
  "cash_level": "good",
  "denominations": {
    "accept": [1, 5, 10, 20, 50, 100, 200],
    "dispense": [20, 50, 100]
  },
  "fees": {
    "cash_in_pct": 5.0,
    "cash_out_pct": 5.0,
    "cash_in_flat": 0,
    "cash_out_flat": 0
  },
  "limits": {
    "cash_in_min": 10,
    "cash_in_max": 1000,
    "cash_out_min": 20,
    "cash_out_max": 500
  },
  "version": "0.1.0"
}

Field breakdown

Field Type Purpose Public?
name string Operator-assigned machine name Yes
location string Human-readable location Yes
geo string lat,lon for map display (optional) Yes
fiat string Currency code Yes
model string Hardware model Yes
cash_in boolean Can accept bills right now Yes
cash_out boolean Can dispense bills right now Yes
cash_level "none" | "low" | "good" | "full" Coarse stock indicator Yes
denominations.accept number[] Bill denominations the validator accepts Yes
denominations.dispense number[] Bill denominations the dispenser has loaded Yes
fees.cash_in_pct number Buy Bitcoin fee percentage Yes
fees.cash_out_pct number Sell Bitcoin fee percentage Yes
fees.cash_in_flat number Buy Bitcoin flat fee in fiat (0 = none) Yes
fees.cash_out_flat number Sell Bitcoin flat fee in fiat (0 = none) Yes
limits.cash_in_min number Min fiat per cash-in tx Yes
limits.cash_in_max number Max fiat per cash-in tx Yes
limits.cash_out_min number Min fiat per cash-out tx Yes
limits.cash_out_max number Max fiat per cash-out tx Yes
version string Software version Yes

All fields are intentionally public. The beacon is a replaceable event (kind 30078, d tag atm-availability) so only the latest state is visible — no history of stock levels or fee changes.

What stays out

  • Exact bill counts — cash_level is deliberately coarse. Exact counts are fleet-only telemetry (kind 30079, see #42).
  • Lightning.Pub balance — operator-sensitive, not for public display.
  • Exchange rate — changes too fast for a 5-minute heartbeat. Customers see the rate at transaction time.
  • Machine npub — already the event author, no need to duplicate in content.
  • Relay URLs — discoverable via NIP-65.

Where the data comes from

Field Source
name, location, geo, fiat, model Machine config (.env or config file, seeded at provisioning)
cash_in, cash_out, cash_level, denominations.dispense Computed from HAL inventory state (already reactive in useAvailabilityBroadcast)
denominations.accept HAL validator config (static per currency)
fees.*, limits.* Machine config (operator-set, updateable via fleet control per #42)
version package.json version

Changes needed

  1. Unify ServiceBeacon type — update packages/clink/src/types.ts ServiceBeacon interface to match the proposed schema. The machine and the CLINK package should share one type.
  2. Update useAvailabilityBroadcast.ts — extend UseAvailabilityBroadcastOptions to accept the new config fields (name, location, fees, limits, denominations). Update publish() to include them.
  3. Update hasChanged() — fees and limits are config-driven and won't change often, but if the operator pushes a fee change via fleet control (#42), the beacon should republish immediately.
  4. Machine config — add name, location, geo, fees, limits to the machine's config schema (wherever that lives — currently .env).
  5. Status page — a separate, minimal static page (could live in apps/dashboard or be its own thing) that subscribes to kind 30078 and renders the fleet status cards. This is the public-facing consumer of the beacon.

Relation to #42

The fleet management issue (#42) introduces kind 30079 for operator-only telemetry (exact bill counts, LP balance, error logs). This issue is about making the existing kind 30078 beacon useful for public-facing status. The two events serve different audiences:

  • 30078 = public, customer-facing: "is this ATM available, what does it cost?"
  • 30079 = private, operator-facing: "how many $20s are left in cassette B?"

References

  • apps/machine/src/composables/useAvailabilityBroadcast.ts — current implementation
  • packages/clink/src/types.ts:302-313 — existing ServiceBeacon type (unused by machine)
  • #42 — fleet management (kind 30079 telemetry, dashboard)
> _Migrated from [aiolabs/lamassu-next#43](https://git.atitlan.io/aiolabs/lamassu-next/issues/43) — opened by @padreug on 2026-04-11._\n\n## Problem The kind 30078 availability beacon currently publishes a minimal payload: ```json { "cash_in": true, "cash_out": true, "cash_level": "good", "fiat": "USD", "model": "sintra" } ``` This is enough for an operator to see "is the machine alive", but not enough for a **public-facing ATM status page** that shows customers useful information — like what the screenshot below shows: - Machine name and location - What operations are available (Buy B, Sell B) - Stock status (Stocked / Low / Empty) - Fees being charged - How recently the machine checked in A public status page just subscribes to kind 30078 events from known machine npubs (or from the operator's NIP-51 fleet roster) and renders a card per machine. The beacon needs to carry enough data to make that useful without any backend. ## Current state Two related but disconnected data models exist: 1. **`useAvailabilityBroadcast.ts`** (what the machine actually publishes) — no fees, no name, no location, no denominations. 2. **`ServiceBeacon` interface in `packages/clink/src/types.ts`** — has `name`, `avatarUrl`, `fees`, but the machine doesn't use this type at all. These should converge into a single schema that serves both fleet management and customer-facing status. ## Proposed beacon content ```json { "name": "Bitcoinmat", "location": "Trece Cielos", "geo": "14.63,-90.51", "fiat": "GTQ", "model": "batm3", "cash_in": true, "cash_out": true, "cash_level": "good", "denominations": { "accept": [1, 5, 10, 20, 50, 100, 200], "dispense": [20, 50, 100] }, "fees": { "cash_in_pct": 5.0, "cash_out_pct": 5.0, "cash_in_flat": 0, "cash_out_flat": 0 }, "limits": { "cash_in_min": 10, "cash_in_max": 1000, "cash_out_min": 20, "cash_out_max": 500 }, "version": "0.1.0" } ``` ### Field breakdown | Field | Type | Purpose | Public? | |---|---|---|---| | `name` | string | Operator-assigned machine name | Yes | | `location` | string | Human-readable location | Yes | | `geo` | string | `lat,lon` for map display (optional) | Yes | | `fiat` | string | Currency code | Yes | | `model` | string | Hardware model | Yes | | `cash_in` | boolean | Can accept bills right now | Yes | | `cash_out` | boolean | Can dispense bills right now | Yes | | `cash_level` | `"none" \| "low" \| "good" \| "full"` | Coarse stock indicator | Yes | | `denominations.accept` | number[] | Bill denominations the validator accepts | Yes | | `denominations.dispense` | number[] | Bill denominations the dispenser has loaded | Yes | | `fees.cash_in_pct` | number | Buy Bitcoin fee percentage | Yes | | `fees.cash_out_pct` | number | Sell Bitcoin fee percentage | Yes | | `fees.cash_in_flat` | number | Buy Bitcoin flat fee in fiat (0 = none) | Yes | | `fees.cash_out_flat` | number | Sell Bitcoin flat fee in fiat (0 = none) | Yes | | `limits.cash_in_min` | number | Min fiat per cash-in tx | Yes | | `limits.cash_in_max` | number | Max fiat per cash-in tx | Yes | | `limits.cash_out_min` | number | Min fiat per cash-out tx | Yes | | `limits.cash_out_max` | number | Max fiat per cash-out tx | Yes | | `version` | string | Software version | Yes | All fields are intentionally public. The beacon is a **replaceable event** (kind 30078, `d` tag `atm-availability`) so only the latest state is visible — no history of stock levels or fee changes. ### What stays out - **Exact bill counts** — `cash_level` is deliberately coarse. Exact counts are fleet-only telemetry (kind 30079, see #42). - **Lightning.Pub balance** — operator-sensitive, not for public display. - **Exchange rate** — changes too fast for a 5-minute heartbeat. Customers see the rate at transaction time. - **Machine npub** — already the event author, no need to duplicate in content. - **Relay URLs** — discoverable via NIP-65. ## Where the data comes from | Field | Source | |---|---| | `name`, `location`, `geo`, `fiat`, `model` | Machine config (`.env` or config file, seeded at provisioning) | | `cash_in`, `cash_out`, `cash_level`, `denominations.dispense` | Computed from HAL inventory state (already reactive in `useAvailabilityBroadcast`) | | `denominations.accept` | HAL validator config (static per currency) | | `fees.*`, `limits.*` | Machine config (operator-set, updateable via fleet control per #42) | | `version` | `package.json` version | ## Changes needed 1. **Unify `ServiceBeacon` type** — update `packages/clink/src/types.ts` `ServiceBeacon` interface to match the proposed schema. The machine and the CLINK package should share one type. 2. **Update `useAvailabilityBroadcast.ts`** — extend `UseAvailabilityBroadcastOptions` to accept the new config fields (name, location, fees, limits, denominations). Update `publish()` to include them. 3. **Update `hasChanged()`** — fees and limits are config-driven and won't change often, but if the operator pushes a fee change via fleet control (#42), the beacon should republish immediately. 4. **Machine config** — add `name`, `location`, `geo`, `fees`, `limits` to the machine's config schema (wherever that lives — currently `.env`). 5. **Status page** — a separate, minimal static page (could live in `apps/dashboard` or be its own thing) that subscribes to kind 30078 and renders the fleet status cards. This is the public-facing consumer of the beacon. ## Relation to #42 The fleet management issue (#42) introduces kind 30079 for **operator-only telemetry** (exact bill counts, LP balance, error logs). This issue is about making the *existing* kind 30078 beacon useful for public-facing status. The two events serve different audiences: - **30078** = public, customer-facing: "is this ATM available, what does it cost?" - **30079** = private, operator-facing: "how many $20s are left in cassette B?" ## References - `apps/machine/src/composables/useAvailabilityBroadcast.ts` — current implementation - `packages/clink/src/types.ts:302-313` — existing `ServiceBeacon` type (unused by machine) - #42 — fleet management (kind 30079 telemetry, dashboard)
Author
Owner

@padreug commented on 2026-05-26 (lamassu-next#43):

2026-05-26 cross-codebase review — two privacy / resilience nits worth folding in:

1. cash_level timing leak. The issue already states the 4-level coarseness is deliberate ("not precise counts"). Good. But the transitions still leak:

  • "none" → "full" (or any upward step) is a strong signal the operator just loaded cash.
  • For an adversary planning a physical attack, the freshly-loaded transition is the high-value moment.

Mitigation options (cheap, complementary to this issue's enrichment work):

  • Jitter: Randomize whether to report "full" vs "good" for the top bucket (e.g. 50/50). Same idea on the bottom — "none" vs "low".
  • Cadence-only (not change-driven): Publish on a fixed schedule (e.g. every 5min) and never on inventory change. Loses sub-cadence freshness but removes the timing signal entirely.
  • Lag: Report inventory changes with a deliberate 1-30min jitter, so a fresh-load transition is observable but its precise time isn't.

Recommend baking one of these into the enrichment work in this issue — once the beacon is the public-facing source of truth, the timing privacy story matters.

2. Boot-race resilience. packages/nostr-client/src/client.ts:240-250 (publish()) throws immediately if no writable relays — the beacon will silently drop on startup if the relay hasn't connected yet. With this issue's richer payload, the cost of "no beacon for 5min while relay reconnects" becomes "no beacon for 5min on a customer-facing status page." Worth adding a small retry queue (or a one-shot retry-on-relay:connected event) when publish fails due to no writable relays. ~30-min fix.

Both points identified during the 2026-05-26 cross-codebase review pass. Not blocking this issue's main thrust — flagging for the implementation work.

> _@padreug commented on 2026-05-26 ([lamassu-next#43](https://git.atitlan.io/aiolabs/lamassu-next/issues/43#issuecomment-1112)):_ 2026-05-26 cross-codebase review — two privacy / resilience nits worth folding in: **1. `cash_level` timing leak.** The issue already states the 4-level coarseness is deliberate ("not precise counts"). Good. But the *transitions* still leak: - `"none"` → `"full"` (or any upward step) is a strong signal the operator just loaded cash. - For an adversary planning a physical attack, the freshly-loaded transition is the high-value moment. Mitigation options (cheap, complementary to this issue's enrichment work): - **Jitter**: Randomize whether to report `"full"` vs `"good"` for the top bucket (e.g. 50/50). Same idea on the bottom — `"none"` vs `"low"`. - **Cadence-only** (not change-driven): Publish on a fixed schedule (e.g. every 5min) and never on inventory change. Loses sub-cadence freshness but removes the timing signal entirely. - **Lag**: Report inventory changes with a deliberate 1-30min jitter, so a fresh-load transition is observable but its precise time isn't. Recommend baking one of these into the enrichment work in this issue — once the beacon is the public-facing source of truth, the timing privacy story matters. **2. Boot-race resilience.** `packages/nostr-client/src/client.ts:240-250` (`publish()`) throws immediately if no writable relays — the beacon will silently drop on startup if the relay hasn't connected yet. With this issue's richer payload, the cost of "no beacon for 5min while relay reconnects" becomes "no beacon for 5min on a customer-facing status page." Worth adding a small retry queue (or a one-shot retry-on-`relay:connected` event) when publish fails due to no writable relays. ~30-min fix. Both points identified during the 2026-05-26 cross-codebase review pass. Not blocking this issue's main thrust — flagging for the implementation work.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/bitspire#43
No description provided.