From ffbacafe3958443a845190f97a9eb5fb3dd8e3e1 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Thu, 6 Aug 2026 19:51:39 +0200 Subject: [PATCH 01/15] fix(nfc): auto-recover a wedged CCID reader via USB power-cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Feitian R502-CL (and cheap CCID readers generally) can wedge: it keeps detecting a card but every APDU returns "card absent or mute", and ONLY a USB power-cycle clears it — restarting pcscd or the app does not (confirmed on-device). Until now that left cash-out/cash-in taps dead until a manual replug. - nfc-service.ts: count consecutive read failures; after 3 (gated by a 30s cooldown so a still-wedged reader can't reset-loop) trigger nfc-reader-reset.service. nfc-pcsc then re-detects the reader on USB hotplug with no app restart (verified live). - batm3.nix: nfc-reader-reset.service (oneshot, root) re-binds the reader's USB device (a software replug); reader-agnostic via the CCID interface class (0x0B) so it also covers a future ACR1252U. A polkit rule lets the unprivileged `bitspire` app start just that one unit. Hardware track (separate): the durable fix is a better reader (ACR1252U — large antenna for behind-panel, firmware-upgradable). This change makes any reader's wedge a ~2s self-heal in the meantime. Co-Authored-By: Claude Opus 4.8 --- apps/machine/electron/nfc-service.ts | 58 ++++++++++++++++++++++++++-- deploy/nixos/hardware/batm3.nix | 42 +++++++++++++++++++- 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/apps/machine/electron/nfc-service.ts b/apps/machine/electron/nfc-service.ts index ca9ace1..e94ddaf 100644 --- a/apps/machine/electron/nfc-service.ts +++ b/apps/machine/electron/nfc-service.ts @@ -13,6 +13,8 @@ * QR path keeps working — cash-out never depends on this. */ +import { execFile } from 'node:child_process' + export type NfcState = 'ready' | 'reading' | 'error' | 'card-removed' | 'unavailable' export interface NfcStatus { state: NfcState @@ -83,7 +85,11 @@ export async function readNdefLnurlw( const send = (bytes: number[]) => transmit(Buffer.from(bytes), 256) // Select the NDEF Tag Application (AID D2760000850101). - if (!swOk(await send([0x00, 0xa4, 0x04, 0x00, 0x07, 0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00]))) { + if ( + !swOk( + await send([0x00, 0xa4, 0x04, 0x00, 0x07, 0xd2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00]) + ) + ) { return null } @@ -107,6 +113,29 @@ export async function readNdefLnurlw( let stopFn: (() => void) | null = null +// ── Wedge auto-recovery ─────────────────────────────────────────────────── +// Cheap CCID readers (the Feitian R502-CL especially) occasionally wedge: they +// keep detecting a card but every APDU returns "card absent or mute", and ONLY +// a USB power-cycle clears it — pcscd/app restarts do NOT. When we see a run of +// consecutive read failures we trigger nfc-reader-reset.service (a root oneshot +// that re-binds the reader's USB device = a software replug); nfc-pcsc then +// re-detects the reader on hotplug with no app restart. The trigger is gated by +// a cooldown so a still-wedged reader can't reset-loop. A quality reader (e.g. +// ACR1252U) wedges far less; this is belt-and-suspenders for any reader. +const WEDGE_FAILURE_THRESHOLD = 3 +const RESET_COOLDOWN_MS = 30_000 +// Persist across reader re-enumerations (a reset spawns a fresh reader closure). +let lastReaderResetAt = 0 + +/** Trigger the privileged USB power-cycle of the reader. Best-effort. */ +function resetWedgedReader(): void { + // NixOS: the app runs unprivileged as `bitspire`; a polkit rule authorises it + // to start this one unit. systemctl lives at a stable path on the device. + execFile('/run/current-system/sw/bin/systemctl', ['start', 'nfc-reader-reset.service'], () => { + /* best-effort — if it fails the reader stays wedged until a manual reset */ + }) +} + /** * Start listening for Bolt Card taps. Idempotent. Returns a stop function. * Never throws — failures surface via onStatus. @@ -159,6 +188,10 @@ export async function startNfcReader( // a present↔empty storm when hammered, so ignore re-detections for a beat // after a failure. Successful reads don't cool down. let cooldownUntil = 0 + // Consecutive failed reads → wedge detection (see resetWedgedReader above). + // A completed read (Bolt Card or not) proves the reader is healthy and + // clears the count; only a run of thrown transmits trips the reset. + let consecutiveFailures = 0 r.on('card', async () => { if (Date.now() < cooldownUntil) return onStatus({ state: 'reading', reader: name }) @@ -167,13 +200,30 @@ export async function startNfcReader( // user simply re-taps. try { const lnurlw = await readNdefLnurlw((apdu, maxLen) => r.transmit(apdu, maxLen)) + consecutiveFailures = 0 if (lnurlw) { onCard(lnurlw) return } onStatus({ state: 'error', reader: name, message: 'not a Bolt Card' }) } catch (e) { - onStatus({ state: 'error', reader: name, message: 'card read failed — hold steady & retap' }) + consecutiveFailures++ + if ( + consecutiveFailures >= WEDGE_FAILURE_THRESHOLD && + Date.now() - lastReaderResetAt > RESET_COOLDOWN_MS + ) { + // Reader looks wedged — auto power-cycle it (only fix that works). + lastReaderResetAt = Date.now() + consecutiveFailures = 0 + onStatus({ state: 'error', reader: name, message: 'reader stuck — auto-resetting…' }) + resetWedgedReader() + } else { + onStatus({ + state: 'error', + reader: name, + message: 'card read failed — hold steady & retap', + }) + } void e } cooldownUntil = Date.now() + 1500 @@ -182,7 +232,9 @@ export async function startNfcReader( r.on('error', (err: unknown) => onStatus({ state: 'error', reader: name, message: errMsg(err) }) ) - r.on('end', () => onStatus({ state: 'unavailable', reader: name, message: 'reader disconnected' })) + r.on('end', () => + onStatus({ state: 'unavailable', reader: name, message: 'reader disconnected' }) + ) }) nfc.on('error', (err: unknown) => onStatus({ state: 'error', message: errMsg(err) })) diff --git a/deploy/nixos/hardware/batm3.nix b/deploy/nixos/hardware/batm3.nix index 1d6af0b..79f1b4d 100644 --- a/deploy/nixos/hardware/batm3.nix +++ b/deploy/nixos/hardware/batm3.nix @@ -94,7 +94,8 @@ # pcscd gates client access via polkit; without a rule the sandboxed # `bitspire` service user is "Rejected unauthorized PC/SC client". Authorize - # it to talk to the daemon and the card. + # it to talk to the daemon and the card. The second rule lets the app trigger + # the NFC reader wedge-recovery service (see nfc-reader-reset below). security.polkit.extraConfig = '' polkit.addRule(function(action, subject) { if ((action.id == "org.debian.pcsc-lite.access_pcsc" || @@ -103,8 +104,47 @@ return polkit.Result.YES; } }); + polkit.addRule(function(action, subject) { + if (action.id == "org.freedesktop.systemd1.manage-units" && + action.lookup("unit") == "nfc-reader-reset.service" && + subject.user == "bitspire") { + return polkit.Result.YES; + } + }); ''; + # NFC reader wedge-recovery. The Feitian R502-CL CCID reader (and, less often, + # any CCID reader) can wedge: it keeps detecting a card but every APDU returns + # "card absent or mute", and ONLY a USB power-cycle clears it — restarting + # pcscd or the app does not. This oneshot re-binds the reader's USB device (a + # software replug); pcscd + nfc-pcsc then re-detect it on hotplug with no app + # restart (verified on-device). The app (unprivileged `bitspire`) starts it via + # the polkit rule above when it sees repeated read failures. Reader-agnostic: + # it matches the USB CCID interface class (0x0B), so it also covers a future + # ACR1252U swap without a config change. + systemd.services.nfc-reader-reset = { + description = "Power-cycle a wedged CCID NFC reader (USB re-bind)"; + serviceConfig = { + Type = "oneshot"; + ExecStart = pkgs.writeShellScript "reset-nfc-reader" '' + set -u + found=0 + for iface in /sys/bus/usb/devices/*:*/bInterfaceClass; do + [ -f "$iface" ] || continue + [ "$(${pkgs.coreutils}/bin/cat "$iface" 2>/dev/null)" = "0b" ] || continue + ifname=$(${pkgs.coreutils}/bin/basename "$(${pkgs.coreutils}/bin/dirname "$iface")") + dev=''${ifname%%:*} + echo "reset-nfc-reader: power-cycling CCID reader USB device $dev" >&2 + echo -n "$dev" > /sys/bus/usb/drivers/usb/unbind 2>/dev/null || true + ${pkgs.coreutils}/bin/sleep 2 + echo -n "$dev" > /sys/bus/usb/drivers/usb/bind 2>/dev/null || true + found=1 + done + [ "$found" = 1 ] || { echo "reset-nfc-reader: no CCID reader found" >&2; exit 1; } + ''; + }; + }; + # Disable suspend/hibernate for kiosk systemd.targets = { sleep.enable = false; From 1b671bf4075735ff8430e1ea24d561fb91f542f5 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 6 Sep 2026 19:24:15 +0200 Subject: [PATCH 02/15] build(machine): add a web-only build:web target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The machine app's `build` script runs vue-tsc, the Vite build, two electron tsc passes and an esbuild bundle. Serving the kiosk as a plain SPA needs only the middle one, and the electron passes drag in native-addon typings that a web build has no use for. Add `build:web` (just `vite build`) with a turbo task that still builds the workspace packages first via `dependsOn: ["^build"]`, so a consumer can run `pnpm build:web` at the repo root and get `apps/machine/dist`. `env: ["VITE_*"]` is declared on the task because the Vite vars are baked into the bundle at build time — without it turbo would happily serve a cached build produced under different env. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013A6683cCHnQxFUosx1krY4 --- apps/machine/package.json | 1 + package.json | 1 + turbo.json | 5 +++++ 3 files changed, 7 insertions(+) diff --git a/apps/machine/package.json b/apps/machine/package.json index 7f68db8..cefc699 100644 --- a/apps/machine/package.json +++ b/apps/machine/package.json @@ -15,6 +15,7 @@ "dev:vite": "vite", "electron:dev": "tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && electron dist-electron/main.js", "build": "vue-tsc --noEmit && vite build && tsc -p electron/tsconfig.json && tsc -p electron/tsconfig.preload.json && npx esbuild electron/fund-atm.ts --bundle --platform=node --format=cjs --external:better-sqlite3 --outfile=dist-electron/fund-atm.bundle.cjs", + "build:web": "vite build", "build:electron": "pnpm build && electron-builder", "preview": "vite preview", "typecheck": "vue-tsc --noEmit", diff --git a/package.json b/package.json index 1cf98b1..6499319 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "dev": "turbo dev", "build": "turbo build", + "build:web": "turbo build:web", "test": "turbo test", "lint": "turbo lint", "format": "prettier --write .", diff --git a/turbo.json b/turbo.json index d6d2fb5..a5a9376 100644 --- a/turbo.json +++ b/turbo.json @@ -5,6 +5,11 @@ "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**", "!.next/cache/**"] }, + "build:web": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "env": ["VITE_*"] + }, "dev": { "cache": false, "persistent": true From ac40ea9bb6e6244934990e4f941a74d0f0dd17e3 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 6 Sep 2026 19:24:15 +0200 Subject: [PATCH 03/15] feat(lnbits): wrap the create_wallet RPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transport has exposed `create_wallet` (AUTH_ACCOUNT) since the RPC registry was written, but LnbitsClient never wrapped it — the ATM only ever needed the auto-created default wallet from `list_wallets`. Add `createWallet(name)` plus its `CreatedWallet` reply type. Account-scoped, so the envelope deliberately carries no `wallet_id`: that absence is what makes the server resolve auth to the Account rather than a Wallet. Not wrapped in `idempotent()` — a retry would mint a duplicate wallet, same reasoning as create_invoice. The reply carries the new wallet's adminkey/inkey, hence the type-level note not to log it verbatim. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013A6683cCHnQxFUosx1krY4 --- packages/lnbits/src/client.ts | 12 ++++++++++++ packages/lnbits/src/index.ts | 1 + packages/lnbits/src/types.ts | 9 +++++++++ 3 files changed, 22 insertions(+) diff --git a/packages/lnbits/src/client.ts b/packages/lnbits/src/client.ts index cb3645d..ad212a8 100644 --- a/packages/lnbits/src/client.ts +++ b/packages/lnbits/src/client.ts @@ -37,6 +37,7 @@ import type { CreateInvoiceBody, PayInvoiceBody, WalletInfo, + CreatedWallet, MachineConfigResponse, SubscribePaymentsBody, SubscribeAck, @@ -211,6 +212,17 @@ export class LnbitsClient { return data ?? [] } + /** + * Create an additional wallet on the calling account (`create_wallet`). + * + * Account-scoped (AUTH_ACCOUNT): the envelope carries no `wallet_id`, which + * is what makes the server resolve auth to the Account rather than a Wallet. + * NOT wrapped in `idempotent()` — a retry would mint a duplicate wallet. + */ + async createWallet(name: string): Promise { + return this.sendRpc('create_wallet', { body: { name } }) + } + /** Pull server-delivered machine config (operator pubkey + fee config) over * the authenticated transport — spirekeeper's `get_machine_config` RPC * (bitspire#70 P1). Lets a seed-only ATM configure itself with no per-machine diff --git a/packages/lnbits/src/index.ts b/packages/lnbits/src/index.ts index 9618a2f..e1e87c1 100644 --- a/packages/lnbits/src/index.ts +++ b/packages/lnbits/src/index.ts @@ -66,6 +66,7 @@ export type { CreateInvoiceBody, PayInvoiceBody, WalletInfo, + CreatedWallet, SubscribePaymentsBody, SubscribeAck, SubscribePush, diff --git a/packages/lnbits/src/types.ts b/packages/lnbits/src/types.ts index dd5e7a9..fdebb95 100644 --- a/packages/lnbits/src/types.ts +++ b/packages/lnbits/src/types.ts @@ -112,6 +112,15 @@ export interface WalletInfo { balance: number } +/** Reply shape of the `create_wallet` RPC — unlike WalletInfo it carries the + * fresh wallet's keys, so never log it verbatim. */ +export interface CreatedWallet { + id: string + name: string + adminkey: string + inkey: string +} + // ============================================================================ // Subscriptions // ============================================================================ From 8264dd7472886efb05069c30fc2335457016b2a8 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 6 Sep 2026 19:24:15 +0200 Subject: [PATCH 04/15] feat(machine): VITE_DEMO_TAG for the public web demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser path (no electronAPI) is already a first-class code path: initializeWithLightning() resolves an EPHEMERAL LocalSigner, allows mock fallback and leaves debugMode on, so the bill simulator stands in for the validator. That is what makes a hosted kiosk demo possible at all. Two things still needed fixing for it. 1. Cursor. `cursor: none` was applied globally for the touchscreen, which in an ordinary browser reads as a broken page. Scope it to `.kiosk`, set on by main.ts unless VITE_DEMO_TAG is present — so every real machine keeps today's behavior and only the demo build shows a pointer. 2. Cleanup. An ephemeral identity per page load is the right call (it isolates concurrent visitors, and each fresh account gets its own auto-credit under LNBITS_DEMO_MODE, whereas a single baked-in key would be credited once and then drain). The cost is a throwaway LNbits account per visit, and nothing in an auto-created row distinguishes one: pubkey-set/prvkey-NULL equally describes a real ATM. A nostr pubkey can't carry a marker — grinding a vanity prefix is far too slow to do on page load — and the account/wallet the server auto-creates isn't nameable by the client. So when VITE_DEMO_TAG is set the ATM mints one extra, never-used wallet whose NAME is the tag, turning the sweep into an exact string match instead of a heuristic about what looks disposable. Both are inert on a real machine: the var is unset outside the demo build. The marker call is fire-and-forget — losing it degrades cleanup, not the demo. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013A6683cCHnQxFUosx1krY4 --- apps/machine/.env.example | 12 ++++++++++++ apps/machine/src/main.ts | 7 +++++++ apps/machine/src/services/lightning.ts | 25 +++++++++++++++++++++++++ apps/machine/src/style.css | 11 +++++++---- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/machine/.env.example b/apps/machine/.env.example index 66541dc..ec66f81 100644 --- a/apps/machine/.env.example +++ b/apps/machine/.env.example @@ -73,6 +73,18 @@ VITE_SPIRE_SEED= # Show "Under Service" screen and block all transactions # VITE_MAINTENANCE_MODE=true +# ============================================================================= +# Public Web Demo +# ============================================================================= + +# Set ONLY for the browser demo build (atm.demo.aiolabs.dev). Leave blank on +# every real machine. When set it: +# - keeps the mouse cursor visible (kiosk builds hide it) +# - mints one extra, never-used LNbits wallet named with this exact string, +# so the throwaway accounts the demo creates (one per page load, each with +# its own ephemeral identity) can be swept by name instead of guessed at. +# VITE_DEMO_TAG=bitspire-web-demo + # ============================================================================= # Mock Fallback (Production Safety) # ============================================================================= diff --git a/apps/machine/src/main.ts b/apps/machine/src/main.ts index 5f39e0a..8679c87 100644 --- a/apps/machine/src/main.ts +++ b/apps/machine/src/main.ts @@ -24,6 +24,13 @@ const router = createRouter({ ], }) +// Kiosk chrome (hidden cursor) is the default — every real machine is a +// touchscreen. The public web demo (VITE_DEMO_TAG) runs in a normal browser, +// where an invisible pointer just reads as broken. +if (!import.meta.env.VITE_DEMO_TAG) { + document.documentElement.classList.add('kiosk') +} + // Create Pinia store const pinia = createPinia() diff --git a/apps/machine/src/services/lightning.ts b/apps/machine/src/services/lightning.ts index eb2e1c7..aa6b52c 100644 --- a/apps/machine/src/services/lightning.ts +++ b/apps/machine/src/services/lightning.ts @@ -505,6 +505,31 @@ export async function initializeLightningServices(options?: { } console.log('[Lightning] LNbits wallet:', lnbitsWalletId) + // ── Public web demo: stamp the throwaway account so it can be swept ────── + // The browser demo (atm.demo.aiolabs.dev) runs with an EPHEMERAL identity — + // a fresh keypair per page load — so LNbits mints a new account + a fresh + // auto-credited wallet for every visitor. That isolation is the point (a + // single baked-in key would be credited exactly once and then drain), but it + // leaves throwaway accounts behind, and nothing in an auto-created row says + // "demo": pubkey-set/prvkey-NULL also describes a real ATM. + // + // A nostr pubkey can't carry a marker (you'd have to grind a vanity prefix, + // far too slow to do on page load), and the account/wallet the server + // auto-creates isn't nameable by the client. So we mint one extra, + // never-used wallet whose NAME is the tag: sweeping is then an exact string + // match on wallet name rather than a heuristic about what looks disposable. + // + // Unset on every real machine, so this is inert outside the demo build. The + // call is fire-and-forget: losing the marker degrades cleanup, not the demo. + const demoTag = (import.meta.env.VITE_DEMO_TAG as string | undefined)?.trim() + if (demoTag) { + void lnbits + .createWallet(demoTag) + // Never log the reply — create_wallet returns adminkey/inkey. + .then(() => console.log('[Lightning] Demo marker wallet created:', demoTag)) + .catch((e) => console.warn('[Lightning] Demo marker wallet failed:', e)) + } + // #70 P1: pull operator pubkey + fee config from LNbits over the authenticated // transport (spirekeeper#41 `get_machine_config`). A seed-only machine has no // VITE_OPERATOR_PUBKEYS, so without this it can't trust its fee config and sits diff --git a/apps/machine/src/style.css b/apps/machine/src/style.css index 621cf82..288f0dc 100644 --- a/apps/machine/src/style.css +++ b/apps/machine/src/style.css @@ -1,10 +1,13 @@ @import 'tailwindcss'; @import 'tw-animate-css'; -/* Hide cursor completely on touchscreen kiosk */ -*, -*::before, -*::after { +/* Hide cursor completely on touchscreen kiosk. + Scoped to .kiosk (set on by main.ts) so the public web demo, which + runs in an ordinary browser with a mouse, keeps a visible pointer. */ +.kiosk, +.kiosk *, +.kiosk *::before, +.kiosk *::after { cursor: none !important; } From 46e52f6598eb9e69aa637af069c62f247aaaafe4 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 19 Sep 2026 09:58:42 +0200 Subject: [PATCH 05/15] chore: scrub "Lamassu" from shipped labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kiosk's still read "Lamassu ATM" — visible as the browser tab on the public demo, and inherited by the Electron window. The product has been bitSpire since the rename; Lamassu belongs in the provenance credits (README, the c0b69d1 boundary note), not on the artifact. Rename the user-facing labels that ship: the page title, the flake description (surfaces in `nix flake metadata`), the ISO build banner, the header comments on the live-USB config / udev rules / app derivation that land on the machine image, and the workspace packages' descriptions. Deliberately NOT touched, because they are identifiers rather than labels and renaming them has deployed-machine consequences: - VITE_LAMASSU_MACHINE_MODEL / VITE_LAMASSU_FIAT_CODE (provisioned .env) - LamassuEventKind (exported enum) - localStorage keys lamassu-theme / lamassu-color-mode (would reset every machine's stored theme) - docker container names + devenv scripts (dev-only) - the packages/hal Cargo crate name Hardware names in HAL driver comments ("Lamassu Sintra", "Douro", "Tejo") stay: those are the physical machines' real names — that IS the credit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013A6683cCHnQxFUosx1krY4 --- apps/machine/index.html | 2 +- deploy/nixos/build-iso.sh | 2 +- deploy/nixos/live.nix | 2 +- deploy/nixos/udev/99-bitspire-hardware.rules | 2 +- flake.nix | 2 +- nix/mkAtmApp.nix | 2 +- packages/hal/package.json | 4 ++-- packages/hal/src/index.ts | 2 +- packages/nostr-client/package.json | 2 +- packages/nostr-client/src/client.ts | 2 +- packages/nostr-client/src/events.ts | 2 +- packages/nostr-client/src/index.ts | 2 +- packages/nostr-client/src/types.ts | 2 +- packages/ui-shared/package.json | 2 +- packages/ui-shared/src/index.ts | 2 +- 15 files changed, 16 insertions(+), 16 deletions(-) diff --git a/apps/machine/index.html b/apps/machine/index.html index cc57859..e461ca2 100644 --- a/apps/machine/index.html +++ b/apps/machine/index.html @@ -18,7 +18,7 @@ http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss: http: https:; img-src 'self' data: blob:; font-src 'self'; frame-src 'none'; object-src 'none'" /> - <title>Lamassu ATM + bitSpire ATM