feat(docker): add dev.sh with auto-funding and ATM app setup

- Add dev.sh script for managing regtest development environment
- Implement cmd_fund to fund ATM app owner via Lightning.Pub API
- Add --fund flag to cmd_up for automatic funding on startup
- Update setup_atm_app to write VITE_APP_ID to machine .env
- Fix Electron IPC to pass appId and extensionApiUrl to renderer
- Restructure repo from nested lamassu-next/ to root

The dev.sh script now supports:
- ./dev.sh up --fund  # Start regtest and auto-fund ATM
- ./dev.sh fund       # Fund existing ATM app
- ./dev.sh status     # Show environment status
- ./dev.sh reset      # Clean restart

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-02-15 14:19:16 -05:00
commit c98f126ba7
180 changed files with 2695 additions and 9587 deletions

845
docs/ndebit-cash-in-flow.md Normal file
View file

@ -0,0 +1,845 @@
# NDebit Cash-In Flow Implementation Guide
This document describes how to implement the ndebit scanning flow for ATM cash-in, where a user scans an ndebit QR code from an ATM to withdraw sats to their wallet.
## Overview
**Flow Summary:**
1. ATM displays QR code containing `clink:ndebit1...?amount=X`
2. User scans QR with wallet → navigates to Claim screen
3. Wallet creates a Lightning invoice for the specified amount
4. Wallet sends Kind 21002 debit request to the relay encoded in the ndebit
5. Lightning.Pub (ATM's backend) receives the request and pays the invoice
6. User receives sats
**Key Insight:** The user is _receiving_ sats, not sending. The ndebit flow is a RECEIVE action from the wallet's perspective.
## Architecture
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ ATM Machine │ │ Nostr Relay │ │ User's Wallet │
│ │ │ │ │ │
│ Displays QR: │ │ ws://relay:7777│ │ ShockWallet │
│ clink: │ │ │ │ │
│ ndebit1... │ │ │ │ │
│ ?amount=1000 │ │ │ │ │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
│ │ 1. Scan QR │
│ │◄──────────────────────┤
│ │ │
│ │ 2. Create invoice │
│ │ (via Lightning.Pub)│
│ │◄──────────────────────┤
│ │ │
│ │ 3. Kind 21002 debit │
│ │ request with │
│ │ invoice │
│ │◄──────────────────────┤
│ │ │
│ 4. Lightning.Pub │ │
│ receives request │ │
│◄──────────────────────┤ │
│ │ │
│ 5. ATM validates & │ │
│ pays invoice │ │
│──────────────────────►│ │
│ │ │
│ │ 6. Payment received │
│ │──────────────────────►│
│ │ │
```
## NDebit Format
### Bech32 Encoding
The ndebit string is bech32-encoded with the following TLV data:
```typescript
interface DebitPointer {
pubkey: string // Lightning.Pub's pubkey (hex)
relay: string // Relay URL for communication
pointer?: string // Optional session/voucher identifier
}
```
### URI Format with Amount
Following the BIP-21 pattern for unified QR codes, but using `clink:` as the scheme since CLINK is protocol-agnostic (could work with Cashu/Fedimint, not just Lightning):
```
clink:ndebit1<bech32data>?amount=<sats>
```
Example:
```
clink:ndebit1qgpkzardqyfhwue69uhkcmmrv9kxsmmnwsarwdehxuqzq34ndmupyc4vrc3tx0rp0km6xmyldrwmum0afjc58rzjegz9hh0m7up5w0?amount=1000
```
**Important:** The amount is NOT encoded in the bech32 ndebit itself - it's passed as a query parameter. This allows a static ndebit to be used with dynamic amounts.
### Why `clink:` Instead of `lightning:`?
We use `clink:` as the URI scheme instead of `lightning:` for several reasons:
1. **Protocol-agnostic** - CLINK is not Lightning-specific. The same ndebit flow could work with Cashu mints or Fedimint in the future.
2. **Accuracy** - `lightning:` was designed for BOLT11 invoices (`lnbc...`) and LNURL. Using it for `ndebit1...` strings is semantically incorrect.
3. **Future-proofing** - As CLINK expands to support more payment backends, `clink:` remains accurate while `lightning:` would be misleading.
**Browser Support Note:** Neither `clink:` nor `lightning:` are IANA-registered schemes, so browsers treat them identically. For QR code scanning (the primary use case), both work fine since the camera app hands the URI directly to the OS for app routing.
Wallets SHOULD support both schemes for compatibility:
```typescript
const CLINK_SCHEME = '(?:clink:|lightning:)?'
```
## Wallet Implementation
### 1. Regex for Parsing
```typescript
// In lib/regex.ts
// Support both clink: (preferred) and lightning: (legacy) schemes
const CLINK_SCHEME = '(?:clink:|lightning:)?'
const BECH32_DATA = '[02-9ac-hj-np-z]'
export const NDEBIT_REGEX = new RegExp(
`^${CLINK_SCHEME}(ndebit1${BECH32_DATA}+)(?:\\?amount=(\\d+))?`,
'i'
)
```
### 2. Type Definitions
```typescript
// In lib/types/parse.ts
import type { DebitPointer } from '@lamassu/clink'
import type { Satoshi } from './units'
export enum InputClassification {
// ... other types
NDEBIT = 'Ndebit',
}
export interface ParsedNdebitInput {
type: InputClassification.NDEBIT
data: string // The raw ndebit string
ndebit: DebitPointer
amount?: Satoshi // From ?amount= query parameter
}
```
### 3. Input Parser
```typescript
// In lib/parse.ts
import { decodeNdebit } from '@lamassu/clink'
// Add to VALIDATORS array:
{
type: InputClassification.NDEBIT,
test: (s) => {
const m = s.match(NDEBIT_REGEX);
if (m) {
const ndebit = m[1].toLowerCase();
const amount = m[2]; // may be undefined
return {
classification: InputClassification.NDEBIT,
value: amount ? `${ndebit}?amount=${amount}` : ndebit
};
}
return null;
}
}
// In parseBitcoinInput switch:
case InputClassification.NDEBIT: {
const [ndebitPart, queryPart] = input.split("?");
let amount: Satoshi | undefined;
if (queryPart) {
const amountMatch = queryPart.match(/amount=(\d+)/);
if (amountMatch) {
amount = parseInt(amountMatch[1], 10) as Satoshi;
}
}
const decoded = decodeNdebit(ndebitPart)
if (!decoded) {
throw new Error("Invalid ndebit string");
}
return {
type: InputClassification.NDEBIT,
data: ndebitPart,
ndebit: decoded,
amount
};
}
```
### 4. Claim Thunk
```typescript
// In State/scoped/backups/sources/history/claimNdebitThunk.ts
import { getNostrClient } from '@/Api/nostr'
// Note: SendNdebitRequest is wallet-side code, not part of @lamassu/clink
// This example shows the wallet's implementation pattern
import { finalizeEvent } from 'nostr-tools'
import { SimplePool } from 'nostr-tools'
import { hexToBytes } from '@noble/hashes/utils'
export const claimNdebitThunk = ({
sourceId,
parsedInput,
amount,
note,
showToast,
}: {
sourceId: string
parsedInput: ParsedNdebitInput
amount: Satoshi
note?: string
showToast: ShowToast
}): AppThunk<Promise<boolean>> => {
return async (dispatch, getState) => {
const selectedSource = selectSourceViewById(getState(), sourceId)
if (!selectedSource || selectedSource.type !== SourceType.NPROFILE_SOURCE) {
showToast({ message: 'Source not found', color: 'danger' })
return false
}
try {
// Step 1: Create invoice to RECEIVE payment
let client
try {
client = await getNostrClient(
{ pubkey: selectedSource.lpk, relays: selectedSource.relays },
selectedSource.keys
)
} catch (err) {
throw new Error('Cannot connect to Lightning.Pub')
}
let invoiceRes
try {
invoiceRes = await client.NewInvoice({
amountSats: amount,
memo: note || `Debit request for ${amount} sats`,
})
} catch (err) {
throw new Error('Failed to create invoice')
}
if (invoiceRes.status === 'ERROR') {
throw new Error(invoiceRes.reason || 'Failed to create invoice')
}
const invoice = invoiceRes.invoice
// Step 2: Send debit request
showToast({ message: 'Waiting for approval...', color: 'primary' })
const pool = new SimplePool()
const ndebitRes = await SendNdebitRequest(
pool,
hexToBytes(selectedSource.keys.privateKey),
[parsedInput.ndebit.relay],
parsedInput.ndebit.pubkey,
{
bolt11: invoice,
amount_sats: amount,
pointer: parsedInput.ndebit.pointer,
},
30 // 30 second timeout
)
if (ndebitRes.res === 'GFY') {
throw new Error(ndebitRes.error || 'Debit request denied')
}
// Success!
showToast({
message: `Received ${amount} sats`,
color: 'success',
})
// Refresh history
dispatch(historyFetchSourceRequested({ sourceId }))
return true
} catch (err: any) {
showToast({
message: err?.message || 'Debit request failed',
color: 'danger',
})
return false
}
}
}
```
### 5. UI Component (Claim Tab)
Place the ndebit claim UI in the **Receive** page (not Send!) since the user is receiving sats.
Key UI elements:
- Text input for pasting ndebit strings
- QR scanner button
- Amount display (pre-filled if from URI, editable if not)
- "Amount set by sender" indicator when amount comes from URI
- Claim button
## ATM/Service Implementation
### Overview
The ATM implements a **debit approval service** that:
1. Generates ndebit QR codes with the ATM's Lightning.Pub account
2. Subscribes to `GetLiveDebitRequests` to receive incoming debit requests
3. Validates requests against active sessions (single-use protection)
4. Approves valid requests via `RespondToDebit` RPC
### Architecture: Debit Approval Flow
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ ATM Machine │ │ Nostr Relay │ │ Lightning.Pub │ │ User's Wallet │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │ │
│ 1. GetLiveDebitRequests │ │
│ (Kind 21000 subscription) │ │
│──────────────────────►│──────────────────────►│ │
│ │ │ │
│ 2. Display QR: │ │ │
│ clink:ndebit1... │ │ 3. Scan QR │
│ ?amount=2450 │ │◄──────────────────────┤
│ │ │ │
│ │ │ 4. Kind 21002 │
│ │ │ debit request │
│ │◄──────────────────────┼───────────────────────┤
│ │ │ │
│ 5. Live debit request │ │ │
│ (via subscription) │ │ │
│◄──────────────────────┤◄──────────────────────┤ │
│ │ │ │
│ 6. Validate session │ │ │
│ & approve │ │ │
│──────────────────────►│──────────────────────►│ │
│ (RespondToDebit) │ │ │
│ │ │ │
│ │ │ 7. Pay invoice │
│ │ │──────────────────────►│
│ │ │ │
```
### Generating the NDebit QR
```typescript
// apps/machine/src/services/lightning.ts
generateNdebit: async (context: ATMContext): Promise<string> => {
// The pointer MUST be a valid Lightning.Pub account identifier
// Lightning.Pub uses this to find which account should pay
const pointer = 'atm' // ATM's account identifier in Lightning.Pub
const ndebit = encodeNdebit({
pubkey: CONFIG.lightningPubPubkey, // Lightning.Pub's pubkey (NOT ATM's!)
relay: CONFIG.relayUrl,
pointer,
})
// Register session for single-use tracking (see below)
registerActiveSession(context.cashInSessionId, context.satsAmount)
// Format as full URI with amount
return formatNdebitUri(ndebit, context.satsAmount)
// Returns: clink:ndebit1qqsyh68zq...?amount=2450
}
```
**Critical:** The ndebit encodes **Lightning.Pub's pubkey**, not the ATM's pubkey. This is because:
- The wallet sends Kind 21002 to the pubkey in the ndebit
- Lightning.Pub must receive it to process the payment
- The `pointer` field tells Lightning.Pub which account to debit
### Single-Use Protection (Session Management)
Each ndebit QR is valid for **one use only**. This prevents:
- Double-spending from the same QR
- Replay attacks with saved QR codes
#### How It Works
1. **Session Registration**: When generating an ndebit, we create a session:
```typescript
interface ActiveSession {
sessionId: string // Unique ID for this transaction
satsAmount: number // Expected amount
createdAt: number // Timestamp
status: 'active' | 'paid' | 'expired'
}
const activeSessions = new Map<string, ActiveSession>()
function registerActiveSession(sessionId: string, satsAmount: number): void {
activeSessions.set(sessionId, {
sessionId,
satsAmount,
createdAt: Date.now(),
status: 'active',
})
// Auto-expire after 5 minutes
setTimeout(
() => {
const session = activeSessions.get(sessionId)
if (session?.status === 'active') {
session.status = 'expired'
}
},
5 * 60 * 1000
)
}
```
2. **Session Validation**: When a debit request arrives, we find a matching session:
```typescript
function findActiveSessionByAmount(amountSats: number): ActiveSession | null {
for (const session of activeSessions.values()) {
if (session.status !== 'active') continue
// Match with small tolerance for rounding
const tolerance = Math.max(1, Math.floor(session.satsAmount * 0.001))
if (Math.abs(amountSats - session.satsAmount) <= tolerance) {
return session
}
}
return null
}
```
3. **Atomic Status Update**: Before approving, we mark the session as paid:
```typescript
// In debit request handler:
const matchingSession = findActiveSessionByAmount(amountSats)
if (!matchingSession) {
console.log('[Debit] REJECTED: No matching active session')
return
}
// Mark as paid BEFORE sending approval (prevents race conditions)
matchingSession.status = 'paid'
// Now approve...
```
#### Additional Protections
```typescript
// Track processed events (prevents replay of same Nostr event)
const processedEventIds = new Set<string>()
// Track approved invoices (prevents same invoice being approved twice)
const approvedInvoices = new Set<string>()
const handleDebitRequest = async (message: DebitRequest, eventId: string) => {
// Check 1: Event already processed?
if (processedEventIds.has(eventId)) {
console.log('[Debit] REJECTED: Event already processed')
return
}
// Check 2: Invoice already approved?
if (approvedInvoices.has(invoice)) {
console.log('[Debit] REJECTED: Invoice already approved')
return
}
// Check 3: Matching active session?
const session = findActiveSessionByAmount(amountSats)
if (!session) {
console.log('[Debit] REJECTED: No matching active session')
return
}
// Mark everything BEFORE sending approval
session.status = 'paid'
processedEventIds.add(eventId)
approvedInvoices.add(invoice)
// Now approve...
}
```
### Debit Approval Service
The ATM runs a background service that listens for debit requests:
```typescript
function startDebitApprovalService(
nostrClient: NostrClient,
identity: MachineIdentity,
onPaymentApproved?: DebitPaymentCallback
): () => void {
// 1. Subscribe to Kind 21000 events from Lightning.Pub
const subscriptionId = nostrClient.subscribe(
[
{
kinds: [21000],
authors: [CONFIG.lightningPubPubkey],
'#p': [identity.publicKey],
since: Math.floor(Date.now() / 1000) - 5,
},
],
{ onEvent: eventHandler }
)
// 2. Send GetLiveDebitRequests subscription request
const subscribeRequest = {
rpcName: 'GetLiveDebitRequests',
authIdentifier: identity.publicKey,
body: {},
}
const event = createSignedEvent(identity, {
kind: 21000,
tags: [['p', CONFIG.lightningPubPubkey]],
content: encryptContent(identity, CONFIG.lightningPubPubkey, subscribeRequest),
})
await nostrClient.publish(event)
}
```
### Handling Debit Requests
When a debit request arrives via the subscription:
```typescript
const eventHandler = (event: NostrEvent) => {
// Decrypt the message
const message = JSON.parse(decryptContent(identity, CONFIG.lightningPubPubkey, event.content))
// Check if it's a live debit request
if (message.requestId === 'GetLiveDebitRequests' && message.debit) {
handleDebitRequest(message, event.id)
}
}
const handleDebitRequest = async (message: DebitRequest, eventId: string) => {
// Message structure:
// {
// request_id: "9f22a67c...",
// npub: "43bfda6c...",
// debit: {
// type: "invoice",
// invoice: "lnbcrt24500n1p5hm..."
// }
// }
// Extract amount from BOLT11 invoice (amount_sats field is often missing)
let amountSats = message.debit.amount_sats
if (!amountSats) {
amountSats = decodeAmountFromBolt11(message.debit.invoice)
}
// Validate against active sessions (single-use check)
const session = findActiveSessionByAmount(amountSats)
if (!session) {
console.log('[Debit] REJECTED: No matching active session')
return
}
// Mark session as paid (prevents double-use)
session.status = 'paid'
// Approve the debit request
const approveRequest = {
rpcName: 'RespondToDebit',
authIdentifier: identity.publicKey,
body: {
npub: message.npub,
request_id: message.request_id,
response: {
type: 'invoice',
invoice: message.debit.invoice,
},
},
}
const approveEvent = createSignedEvent(identity, {
kind: 21000,
tags: [['p', CONFIG.lightningPubPubkey]],
content: encryptContent(identity, CONFIG.lightningPubPubkey, approveRequest),
})
await nostrClient.publish(approveEvent)
console.log('[Debit] SUCCESS: Approved debit request')
}
```
### BOLT11 Amount Decoding
Lightning.Pub doesn't always include `amount_sats` in the debit request, so we decode it from the invoice:
```typescript
function decodeAmountFromBolt11(invoice: string): number | null {
// BOLT11 format: ln<network><amount><multiplier>...
// Networks: bc (mainnet), tb (testnet), bcrt (regtest)
// Multipliers: m=milli (0.001), u=micro (0.000001), n=nano, p=pico
const match = invoice.toLowerCase().match(/^ln(bc|tb|bcrt)(\d+)([munp])?/)
if (!match) return null
const [, , amountStr, multiplier] = match
let amount = parseInt(amountStr, 10)
// Convert to satoshis (1 BTC = 100,000,000 sats)
switch (multiplier) {
case 'm':
amount = amount * 100000
break // milli-BTC
case 'u':
amount = amount * 100
break // micro-BTC
case 'n':
amount = Math.floor(amount / 10)
break // nano-BTC
case 'p':
amount = Math.floor(amount / 10000)
break // pico-BTC
default:
amount = amount * 100000000 // BTC
}
return amount
}
```
### Lightning.Pub's Role
Lightning.Pub handles the actual payment:
1. Receives Kind 21002 debit request from user's wallet
2. Looks up the account by `pointer` field (e.g., "atm")
3. Forwards request to GetLiveDebitRequests subscribers
4. Waits for approval via RespondToDebit
5. Pays the invoice from the account's balance
6. Sends Kind 21002 response to user's wallet
**Important:** The ATM account must have sufficient balance to pay invoices.
## Infrastructure Requirements
### Relay Configuration
The relay must be accessible from:
1. **Lightning.Pub** (for publishing responses)
2. **User's browser** (for subscribing to responses)
For Docker setups:
- Lightning.Pub uses internal Docker DNS: `ws://strfry:7777`
- Browser uses host mapping: `ws://localhost:7777`
- Both resolve to the same relay
**Lightning.Pub docker-compose config:**
```yaml
environment:
- NOSTR_RELAYS=ws://strfry:7777 # Docker internal
```
**NDebit must encode browser-accessible relay:**
```
ws://localhost:7777 # NOT ws://strfry:7777
```
### Funding the ATM
The ATM user needs a balance to pay invoices:
```bash
# Create invoice for ATM user
curl -X POST "http://localhost:1776/api/app/user/add/invoice" \
-H "Authorization: Bearer $APP_TOKEN" \
-d '{"receiver_identifier": "atm", "payer_identifier": "external",
"invoice_req": {"amountSats": 50000, "memo": "Fund ATM"}}'
# Pay from external node
lncli payinvoice <invoice>
```
## Common Pitfalls
### 1. Wrong Page for NDebit UI
**Problem:** Putting ndebit in the Send page
**Solution:** NDebit is a RECEIVE action - put it in the Receive page
### 2. Relay Mismatch
**Problem:** NDebit encodes Docker-internal relay (`ws://strfry:7777`)
**Solution:** Rewrite relay to browser-accessible URL (`ws://localhost:7777`)
### 3. Pubkey Mismatch
**Problem:** Hardcoded Lightning.Pub pubkey doesn't match after container recreation
**Solution:** Dynamically fetch pubkey from ndebit or API
### 4. Zero Balance
**Problem:** Debit request fails with "Error in single invoice payment"
**Solution:** Fund the ATM user before testing
### 5. LND Not Synced
**Problem:** All RPC calls timeout
**Solution:** Mine blocks to sync LND: `bitcoin-cli -generate 10`
### 6. Amount Not in Bech32
**Problem:** Trying to encode amount in the ndebit bech32 string
**Solution:** Use query parameter: `?amount=1000`
### 7. Invalid Pointer in NDebit
**Problem:** Using custom session IDs or arbitrary strings as the ndebit pointer
```
wallet >> user atm:ml2b5abxdppn58sty5 not found wallet
DebitManager >> ERROR application user not found
```
**Cause:** Lightning.Pub uses the `pointer` field to look up which account should pay. It must be a valid Lightning.Pub user identifier (e.g., `atm`), not a custom session string.
**Solution:** Use the ATM's Lightning.Pub account identifier as the pointer:
```typescript
const pointer = 'atm' // NOT 'atm:sessionId123'
```
Track sessions locally using amount-based matching instead.
### 8. Debit Requests Not Received
**Problem:** GetLiveDebitRequests subscription sent but no debit requests arrive
**Possible Causes:**
1. Wrong pubkey in ndebit (must be Lightning.Pub's pubkey)
2. Subscription filter doesn't match (check `authors` and `#p` tags)
3. Using SimplePool instead of direct Relay connection (see `packages/lightning/TROUBLESHOOTING.md`)
4. Race condition - published before subscription was ready
**Solution:** Add verbose logging to trace the flow:
```typescript
nostrClient.subscribe([...], {
onEvent: (event) => {
console.log('[Debit] Event received:', event.kind, event.id)
// ...
},
onEose: () => {
console.log('[Debit] EOSE received - subscription active')
}
})
```
### 9. Missing Amount in Debit Request
**Problem:** `message.debit.amount_sats` is undefined
**Cause:** Lightning.Pub doesn't always include the amount in the forwarded debit request
**Solution:** Decode the amount from the BOLT11 invoice:
```typescript
const amountSats = message.debit.amount_sats || decodeAmountFromBolt11(invoice)
```
### 10. Double Debit (Same QR Used Twice)
**Problem:** User can claim the same ndebit QR multiple times
**Solution:** Implement session-based single-use protection:
1. Register each ndebit generation as a session with expected amount
2. Match incoming requests against active sessions
3. Mark session as `paid` **before** sending approval (atomic)
4. Reject requests with no matching active session
See "Single-Use Protection" section above for implementation details.
## Testing Checklist
### Infrastructure
- [ ] Lightning.Pub health check returns OK
- [ ] LND is synced (`synced_to_chain: true`)
- [ ] ATM user exists and has balance (`fund-atm` command)
- [ ] Relay is accessible from browser (`ws://localhost:7777`)
### NDebit QR Generation
- [ ] QR contains `clink:` prefix
- [ ] QR contains `?amount=` parameter
- [ ] NDebit encodes **Lightning.Pub's pubkey** (not ATM's)
- [ ] NDebit pointer is valid account identifier (`atm`)
- [ ] Relay URL is browser-accessible (not Docker-internal)
### Debit Approval Service
- [ ] Service starts: `[Debit] Starting debit approval service`
- [ ] Subscription active: `[Debit] EOSE received`
- [ ] Events received: `[Debit] Event received: {kind: 21000, ...}`
- [ ] Decryption works: `[Debit] Decrypted message: {...}`
### Single-Use Protection
- [ ] First claim succeeds: `[Debit] SUCCESS: Approved debit request`
- [ ] Second claim fails: `[Debit] REJECTED: No matching active session`
- [ ] Session marked as paid after approval
- [ ] Same invoice rejected: `[Debit] REJECTED: Invoice already approved`
### End-to-End
- [ ] ATM displays ndebit QR
- [ ] Wallet scans and claims
- [ ] Debit approved and payment received
- [ ] State machine transitions to success
- [ ] Repeated scan is rejected
## Dependencies
```json
{
"@lamassu/clink": "workspace:*",
"nostr-tools": "^2.x.x",
"@noble/hashes": "^1.x.x",
"qrcode": "^1.x.x"
}
```
## References
- [CLINK Protocol Spec](https://github.com/shocknet/clink)
- [Lightning.Pub](https://github.com/shocknet/Lightning.Pub)
- [NIP-19 (bech32 encoding)](https://github.com/nostr-protocol/nips/blob/master/19.md)
- [BIP-21 (URI scheme)](https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki)