Implement unique hash (id_unique_hash) for LNURL-withdraw session tracking #25

Open
opened 2026-06-13 22:02:50 +00:00 by padreug · 2 comments
Owner

Migrated from aiolabs/lamassu-next#25 — opened by @padreug on 2026-02-14.\n\n## Context

The LNURL-withdraw implementation currently creates simple single-use links without unique hash tracking. For production use, we should implement proper id_unique_hash support for reliable session matching.

Current Implementation

// lightning.ts - generateLnurlWithdraw
const response = await fetch(`${CONFIG.extensionApiUrl}/api/v1/withdraw/create`, {
  body: JSON.stringify({
    title: `ATM Cash-In ${context.cashInSessionId?.slice(0, 8)}`,
    min_withdrawable: context.satsAmount,
    max_withdrawable: context.satsAmount,
    uses: 1,
    wait_time: 0,
    // is_unique: false - not using unique hashes
  }),
})

We poll /api/v1/lnurl/:unique_hash to detect when the link is spent, but this doesn't give us per-use tracking.

Proposed Implementation

The Lightning.Pub withdraw extension supports is_unique: true which generates a unique hash per use:

/api/v1/lnurl/:unique_hash/:id_unique_hash

Benefits of unique hashes

  1. Reliable session matching - Each ATM session gets its own unique identifier
  2. Better tracking - Know exactly which withdrawal corresponds to which session
  3. Webhook support - Extension can call webhook with specific session ID
  4. Prevents edge cases - No ambiguity with concurrent sessions

Changes Required

  1. Create link with is_unique: true
  2. Store the uses_csv returned by the extension (contains per-use hashes)
  3. Generate LNURL with id_unique_hash for this specific session
  4. Poll or webhook using the unique hash for completion detection
  • #24 - Nostr-native withdrawal notifications (alternative to polling)
  • Lightning.Pub withdraw extension: src/extensions/withdraw/managers/withdrawManager.ts
> _Migrated from [aiolabs/lamassu-next#25](https://git.atitlan.io/aiolabs/lamassu-next/issues/25) — opened by @padreug on 2026-02-14._\n\n## Context The LNURL-withdraw implementation currently creates simple single-use links without unique hash tracking. For production use, we should implement proper `id_unique_hash` support for reliable session matching. ## Current Implementation ```typescript // lightning.ts - generateLnurlWithdraw const response = await fetch(`${CONFIG.extensionApiUrl}/api/v1/withdraw/create`, { body: JSON.stringify({ title: `ATM Cash-In ${context.cashInSessionId?.slice(0, 8)}`, min_withdrawable: context.satsAmount, max_withdrawable: context.satsAmount, uses: 1, wait_time: 0, // is_unique: false - not using unique hashes }), }) ``` We poll `/api/v1/lnurl/:unique_hash` to detect when the link is spent, but this doesn't give us per-use tracking. ## Proposed Implementation The Lightning.Pub withdraw extension supports `is_unique: true` which generates a unique hash per use: ``` /api/v1/lnurl/:unique_hash/:id_unique_hash ``` ### Benefits of unique hashes 1. **Reliable session matching** - Each ATM session gets its own unique identifier 2. **Better tracking** - Know exactly which withdrawal corresponds to which session 3. **Webhook support** - Extension can call webhook with specific session ID 4. **Prevents edge cases** - No ambiguity with concurrent sessions ### Changes Required 1. **Create link with `is_unique: true`** 2. **Store the `uses_csv`** returned by the extension (contains per-use hashes) 3. **Generate LNURL with `id_unique_hash`** for this specific session 4. **Poll or webhook** using the unique hash for completion detection ## Related - [#24](https://git.atitlan.io/aiolabs/lamassu-next/issues/24) - Nostr-native withdrawal notifications (alternative to polling) - Lightning.Pub withdraw extension: `src/extensions/withdraw/managers/withdrawManager.ts`
Author
Owner

@padreug commented on 2026-05-13 (lamassu-next#25):

Unblocked on the LNbits side — lnurlw_unique_hashes({id}) now exposes per-use hashes

This issue asks for unique-mode (is_unique: true) LNURL-withdraw with per-use id_unique_hash tracking, calling LP's /api/v1/withdraw/create and polling /api/v1/lnurl/:unique_hash/:id_unique_hash. Under the LNbits migration (see #22) the equivalent surface is:

1. Create the unique link over nostr — already works:

lnurlw_create_link({
  title, min_withdrawable, max_withdrawable,
  uses: N, wait_time, is_unique: true,
  webhook_url? webhook_headers? webhook_body? custom_url? enabled?
})

Returns the link record with id, unique_hash, uses, usescsv (CSV of remaining slot indexes), is_unique.

2. Enumerate per-use sub-link hashes — aiolabs/withdraw commit 82a6d4a added:

lnurlw_unique_hashes({id}) -> {
  link_id, unique_hash, is_unique,
  unredeemed_hashes: [
    {index: "0", id_unique_hash: "..."},
    {index: "1", id_unique_hash: "..."},
    ...
  ]
}

The hashes are derived server-side via the canonical formula in helpers.py:create_lnurl:13:

id_unique_hash = shortuuid.uuid(name=link.id + link.unique_hash + index)

The ATM mints one QR per unredeemed_hashes entry; the callback path for that QR is /withdraw/api/v1/lnurl/<unique_hash>/<id_unique_hash>.

3. Observe claims — subscribe_payments({tag: "withdraw", link_id}) (see #24). The settlement push includes Payment.extra.withdrawal_link_id matching the parent link but does not include the specific id_unique_hash that was claimed. If the ATM needs to know which sub-link was hit, two options:

  • (a) Re-call lnurlw_unique_hashes({id}) on each settlement push and compare the new unredeemed_hashes set to the previous one — the difference is the just-claimed hash.
  • (b) Mint a fresh uses=1, is_unique=False link per session — simpler, no diff logic needed, but uses more DB rows.

For the ATM use case (each customer cash-in is a discrete session) option (b) is usually simpler. Option (a) is the right fit for shareable / batch vouchers.

Withdraw extension's concurrency guard also worth flagging: the hash_check table (withdraw/crud.py:131-175, views_lnurl.py:135-137) blocks concurrent redemption of the same id_unique_hash by single-column primary key collision. So even under hostile load, a unique sub-link can only be claimed once.

Net: LNbits surface is in place. Recommend closing this issue once the ATM's client.ts swaps its LP /api/v1/withdraw/* calls for the equivalent LNbits transport RPCs.

> _@padreug commented on 2026-05-13 ([lamassu-next#25](https://git.atitlan.io/aiolabs/lamassu-next/issues/25#issuecomment-573)):_ ## Unblocked on the LNbits side — `lnurlw_unique_hashes({id})` now exposes per-use hashes This issue asks for unique-mode (`is_unique: true`) LNURL-withdraw with per-use `id_unique_hash` tracking, calling LP's `/api/v1/withdraw/create` and polling `/api/v1/lnurl/:unique_hash/:id_unique_hash`. Under the LNbits migration (see #22) the equivalent surface is: **1. Create the unique link over nostr** — already works: ``` lnurlw_create_link({ title, min_withdrawable, max_withdrawable, uses: N, wait_time, is_unique: true, webhook_url? webhook_headers? webhook_body? custom_url? enabled? }) ``` Returns the link record with `id`, `unique_hash`, `uses`, `usescsv` (CSV of remaining slot indexes), `is_unique`. **2. Enumerate per-use sub-link hashes** — `aiolabs/withdraw` commit `82a6d4a` added: ``` lnurlw_unique_hashes({id}) -> { link_id, unique_hash, is_unique, unredeemed_hashes: [ {index: "0", id_unique_hash: "..."}, {index: "1", id_unique_hash: "..."}, ... ] } ``` The hashes are derived server-side via the canonical formula in `helpers.py:create_lnurl:13`: ``` id_unique_hash = shortuuid.uuid(name=link.id + link.unique_hash + index) ``` The ATM mints one QR per `unredeemed_hashes` entry; the callback path for that QR is `/withdraw/api/v1/lnurl/<unique_hash>/<id_unique_hash>`. **3. Observe claims** — `subscribe_payments({tag: "withdraw", link_id})` (see #24). The settlement push includes `Payment.extra.withdrawal_link_id` matching the parent link but does **not** include the specific `id_unique_hash` that was claimed. If the ATM needs to know which sub-link was hit, two options: - (a) Re-call `lnurlw_unique_hashes({id})` on each settlement push and compare the new `unredeemed_hashes` set to the previous one — the difference is the just-claimed hash. - (b) Mint a fresh `uses=1, is_unique=False` link per session — simpler, no diff logic needed, but uses more DB rows. For the ATM use case (each customer cash-in is a discrete session) option (b) is usually simpler. Option (a) is the right fit for shareable / batch vouchers. **Withdraw extension's concurrency guard** also worth flagging: the `hash_check` table (`withdraw/crud.py:131-175`, `views_lnurl.py:135-137`) blocks concurrent redemption of the same `id_unique_hash` by single-column primary key collision. So even under hostile load, a unique sub-link can only be claimed once. **Net:** LNbits surface is in place. Recommend closing this issue once the ATM's `client.ts` swaps its LP `/api/v1/withdraw/*` calls for the equivalent LNbits transport RPCs.
Author
Owner

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

2026-05-26 cross-codebase review — adjacent finding worth folding in:

The 15-minute safety timeout (apps/machine/src/services/lightning.ts:139, SESSION_SAFETY_TIMEOUT_MS = 15 * 60 * 1000) is on the ATM-business-side but isn't bound to the LNURL link's own server-side TTL. Scenarios this creates:

  1. ATM crashes mid-cash-in. The withdraw link is still live on LNbits. Customer scans + redeems. Sats land in LNbits. ATM (after restart) has no record of this session → no dispense. From the customer's perspective the operator just took their sats.
  2. ATM business timeout fires (15min) but LNbits link is still live. Same outcome — link can be redeemed after the ATM thinks the session is dead.

Adding id_unique_hash (this issue's main thrust) solves the correlation problem. But the liveness problem stays open unless we also:

  • Call lnurlw_delete_link on session cleanup (the current code does this via session.cleanup at lightning.ts:735-738, but only on expireLnurlSession() — if Electron crashes hard between session creation and cleanup, the delete never fires).
  • Persist (linkId, expiryTs) to the state-store DB on link creation so a crash-recovered ATM can clean up orphaned links on boot.
  • Or have the ATM publish a "session aborted" event the customer wallet can see (probably overkill).

Two cheap improvements that fold cleanly into this issue:

  1. Persist active LNURL links + their business-side expiry to the state-store DB; on boot, sweep + delete any that the DB says should already be dead.
  2. Wrap lnbits.deleteWithdrawLink in a retry-with-backoff so a transient LNbits / relay failure during cleanup doesn't leak the link.

Not new work for this issue per se, but the TTL-binding part is in the same code path as unique-hash tracking — worth implementing them together so the cleanup story is coherent.

Identified during the cross-codebase review pass (2026-05-26). Related: aiolabs/lamassu-next#24 (Nostr-native completion notification).

> _@padreug commented on 2026-05-26 ([lamassu-next#25](https://git.atitlan.io/aiolabs/lamassu-next/issues/25#issuecomment-1111)):_ 2026-05-26 cross-codebase review — adjacent finding worth folding in: The 15-minute safety timeout (`apps/machine/src/services/lightning.ts:139`, `SESSION_SAFETY_TIMEOUT_MS = 15 * 60 * 1000`) is on the *ATM-business-side* but **isn't bound to the LNURL link's own server-side TTL**. Scenarios this creates: 1. **ATM crashes mid-cash-in.** The withdraw link is still live on LNbits. Customer scans + redeems. Sats land in LNbits. ATM (after restart) has no record of this session → no dispense. From the customer's perspective the operator just took their sats. 2. **ATM business timeout fires (15min) but LNbits link is still live.** Same outcome — link can be redeemed after the ATM thinks the session is dead. Adding `id_unique_hash` (this issue's main thrust) solves the *correlation* problem. But the *liveness* problem stays open unless we also: - Call `lnurlw_delete_link` on session cleanup (the current code does this via `session.cleanup` at `lightning.ts:735-738`, but only on `expireLnurlSession()` — if Electron crashes hard between session creation and cleanup, the delete never fires). - Persist `(linkId, expiryTs)` to the state-store DB on link creation so a crash-recovered ATM can clean up orphaned links on boot. - Or have the ATM publish a "session aborted" event the customer wallet can see (probably overkill). Two cheap improvements that fold cleanly into this issue: 1. Persist active LNURL links + their business-side expiry to the state-store DB; on boot, sweep + delete any that the DB says should already be dead. 2. Wrap `lnbits.deleteWithdrawLink` in a retry-with-backoff so a transient LNbits / relay failure during cleanup doesn't leak the link. Not new work for this issue per se, but the TTL-binding part is in the same code path as unique-hash tracking — worth implementing them together so the cleanup story is coherent. Identified during the cross-codebase review pass (2026-05-26). Related: `aiolabs/lamassu-next#24` (Nostr-native completion notification).
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#25
No description provided.