feat: implement ndebit QR code display for cash-in flow

Replace LNURL-withdraw with CLINK ndebit for cash-in transactions:
- Add ndebit.ts with TLV-based bech32 encoding/decoding
- Generate clink:ndebit1...?amount=<sats> URIs
- Display QR code with copyable URI in CashInView
- Update state machine flow: generatingNdebit → displayingQR
- Fix shadcn-vue Input binding with :model-value prop

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-01-25 17:24:14 -05:00
commit cf053730c6
8 changed files with 378 additions and 92 deletions

View file

@ -19,7 +19,14 @@ import {
type MachineIdentity,
} from '@lamassu/nostr-client'
import { LightningPubClient } from '@lamassu/lightning'
import { CLINKClient, createOfferSuccess, createOfferError, CLINKErrorCode } from '@lamassu/clink'
import {
CLINKClient,
createOfferSuccess,
createOfferError,
CLINKErrorCode,
encodeNdebit,
formatNdebitUri,
} from '@lamassu/clink'
import type { OfferRequest } from '@lamassu/clink'
import type { ATMServices, ATMContext } from '@lamassu/state-machine'
@ -186,6 +193,28 @@ function createATMServices(
_onPaymentSuccess: (preimage: string) => void
): ATMServices {
return {
/**
* Generate an ndebit URI for cash-in
*
* The ndebit encodes the ATM's pubkey and relay, with amount as query param.
* Format: clink:ndebit1<bech32>?amount=<sats>
*/
generateNdebit: async (context: ATMContext): Promise<string> => {
console.log('[ATM Service] Generating ndebit for', context.satsAmount, 'sats')
// Create ndebit with ATM's pubkey and relay
const ndebit = encodeNdebit({
pubkey: _identity.publicKey,
relay: DEV_CONFIG.relayUrl,
})
// Format as full URI with amount
const uri = formatNdebitUri(ndebit, context.satsAmount)
console.log('[ATM Service] Generated ndebit URI:', uri.slice(0, 60) + '...')
return uri
},
/**
* Generate a CLINK offer (noffer) for receiving payment
*

View file

@ -20,6 +20,12 @@ const mockServices: ATMServices = {
return `noffer1mock${Date.now().toString(36)}`
},
generateNdebit: async (context) => {
console.log('[Mock] Generating ndebit for', context.satsAmount, 'sats')
// Mock ndebit URI with amount
return `clink:ndebit1mock${Date.now().toString(36)}?amount=${context.satsAmount}`
},
generateLnurlWithdraw: async (context) => {
console.log('[Mock] Generating LNURL-withdraw for', context.satsAmount, 'sats')
// This is a mock LNURL - in reality it would be a bech32 encoded URL

View file

@ -13,16 +13,31 @@ import QRCode from '@/components/QRCode.vue'
const router = useRouter()
const atmStore = useAtmStore()
// Input modes
type PaymentMode = 'clink' | 'invoice'
const paymentMode = ref<PaymentMode>('clink')
// CLINK Debit: customer's npub/pubkey
const pubkeyInput = ref('')
// Invoice input for manual payment
const invoiceInput = ref('')
// Copy state for ndebit URI
const copied = ref(false)
// Copy ndebit URI to clipboard
async function copyNdebit() {
const uri = atmStore.context?.ndebitUri
if (!uri) return
try {
await navigator.clipboard.writeText(uri)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (err) {
console.error('[CashIn] Failed to copy:', err)
}
}
// Navigate back to idle when transaction completes
watch(
() => atmStore.isIdle,
@ -180,54 +195,71 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
</CardContent>
</Card>
<!-- Generating LNURL-withdraw -->
<Card v-else-if="nestedState === 'generatingLnurlWithdraw'" class="w-80 border-0 bg-white/5">
<!-- Generating NDebit -->
<Card v-else-if="nestedState === 'generatingNdebit'" class="w-80 border-0 bg-white/5">
<CardContent class="flex flex-col items-center gap-4 p-8">
<Skeleton class="h-10 w-10 rounded-full" />
<p class="text-muted-foreground">Preparing your withdrawal...</p>
<p class="text-muted-foreground">Preparing payment code...</p>
</CardContent>
</Card>
<!-- Display Payment Options -->
<!-- Display NDebit QR -->
<Card v-else-if="nestedState === 'displayingQR'" class="w-[480px] border-0 bg-white/5">
<CardHeader class="text-center">
<CardTitle>Receive Your Sats</CardTitle>
<CardTitle>Scan to Receive Sats</CardTitle>
<CardDescription v-if="context">
<Badge variant="secondary" class="text-lg">
{{ formatSats(context.satsAmount) }} sats
</Badge>
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<!-- Payment mode tabs -->
<div class="flex gap-2 rounded-lg bg-white/5 p-1">
<Button
:variant="paymentMode === 'clink' ? 'default' : 'ghost'"
size="sm"
class="flex-1"
@click="paymentMode = 'clink'"
>
CLINK Debit (Nostr)
</Button>
<Button
:variant="paymentMode === 'invoice' ? 'default' : 'ghost'"
size="sm"
class="flex-1"
@click="paymentMode = 'invoice'"
>
Paste Invoice
</Button>
<CardContent class="space-y-6">
<!-- NDebit QR Code (Primary) -->
<div class="flex flex-col items-center">
<QRCode v-if="context?.ndebitUri" :value="context.ndebitUri" :size="220" />
<p class="mt-4 text-center text-sm text-muted-foreground">
Scan with Shock Wallet or compatible CLINK wallet
</p>
</div>
<!-- CLINK Debit Mode -->
<div v-if="paymentMode === 'clink'" class="space-y-4">
<Alert class="border-blue-500/50 bg-blue-500/10">
<AlertDescription>
<p class="mb-2 text-sm font-medium text-blue-400">CLINK Debit (Nostr-Native)</p>
<p class="mb-3 text-xs text-muted-foreground">
Enter your Shock Wallet's npub or pubkey. We'll send a debit request and your
wallet will automatically pay.
</p>
<!-- Copyable NDebit URI -->
<div v-if="context?.ndebitUri" class="space-y-2">
<div class="flex gap-2">
<Input :model-value="context.ndebitUri" readonly class="flex-1 font-mono text-xs" />
<Button variant="outline" size="sm" @click="copyNdebit">
{{ copied ? '✓ Copied' : 'Copy' }}
</Button>
</div>
<p class="text-center text-xs text-muted-foreground">
Or copy and paste into your wallet
</p>
</div>
<!-- Instructions -->
<Alert class="border-green-500/50 bg-green-500/10">
<AlertDescription>
<p class="mb-2 text-sm font-medium text-green-400">How it works:</p>
<ol class="space-y-1 text-xs text-muted-foreground">
<li>1. Scan the QR code with your Lightning wallet</li>
<li>
2. Your wallet creates an invoice for
{{ formatSats(context?.satsAmount || 0) }} sats
</li>
<li>3. Confirm the claim in your wallet</li>
<li>4. Receive your sats instantly!</li>
</ol>
</AlertDescription>
</Alert>
<!-- Alternative: Manual Options (collapsed) -->
<details class="rounded-lg bg-white/5 p-3">
<summary class="cursor-pointer text-sm text-muted-foreground">
Alternative: Manual payment options
</summary>
<div class="mt-4 space-y-4">
<!-- CLINK Debit by pubkey -->
<div class="space-y-2">
<p class="text-xs font-medium text-blue-400">Enter your npub/pubkey:</p>
<div class="flex gap-2">
<Input
v-model="pubkeyInput"
@ -236,33 +268,19 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
:disabled="isProcessing"
/>
<Button
variant="default"
variant="outline"
size="sm"
:disabled="!pubkeyInput.trim() || isProcessing"
@click="submitDebitRequest"
>
{{ atmStore.isRequestingDebit ? 'Requesting...' : 'Request' }}
{{ atmStore.isRequestingDebit ? '...' : 'Send' }}
</Button>
</div>
<p v-if="atmStore.paymentError" class="mt-2 text-xs text-red-400">
Error: {{ atmStore.paymentError }}
</p>
</AlertDescription>
</Alert>
<p class="text-center text-xs text-muted-foreground">
Your Shock Wallet must be connected to relay: ws://192.168.1.122:7777
</p>
</div>
</div>
<!-- Invoice Paste Mode -->
<div v-if="paymentMode === 'invoice'" class="space-y-4">
<Alert class="border-orange-500/50 bg-orange-500/10">
<AlertDescription>
<p class="mb-2 text-sm font-medium text-orange-400">Manual Invoice Payment</p>
<p class="mb-3 text-xs text-muted-foreground">
Create a {{ formatSats(context?.satsAmount || 0) }} sat invoice in your wallet and
paste it below. We'll pay it instantly.
</p>
<!-- Invoice paste -->
<div class="space-y-2">
<p class="text-xs font-medium text-orange-400">Or paste your invoice:</p>
<div class="flex gap-2">
<Input
v-model="invoiceInput"
@ -271,41 +289,21 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
:disabled="isProcessing"
/>
<Button
variant="default"
variant="outline"
size="sm"
:disabled="!invoiceInput.trim() || isProcessing"
@click="submitInvoice"
>
{{ atmStore.isPayingInvoice ? 'Paying...' : 'Pay' }}
{{ atmStore.isPayingInvoice ? '...' : 'Pay' }}
</Button>
</div>
<p v-if="atmStore.paymentError" class="mt-2 text-xs text-red-400">
Error: {{ atmStore.paymentError }}
</p>
</AlertDescription>
</Alert>
<p class="text-center text-xs text-muted-foreground">
Note: Invoice must be on same network (regtest for dev)
</p>
</div>
</div>
<!-- Divider -->
<div class="relative flex items-center justify-center py-2">
<div class="absolute inset-0 flex items-center">
<span class="w-full border-t border-white/20" />
<p v-if="atmStore.paymentError" class="text-xs text-red-400">
Error: {{ atmStore.paymentError }}
</p>
</div>
<span class="relative bg-background px-3 text-xs text-muted-foreground">
or scan QR
</span>
</div>
<!-- LNURL QR (for wallet scanning - mock in browser) -->
<div class="flex flex-col items-center">
<QRCode v-if="context?.lnurlWithdraw" :value="context.lnurlWithdraw" :size="150" />
<p class="mt-2 text-center text-xs text-muted-foreground">
LNURL-withdraw (mock in browser dev)
</p>
</div>
</details>
<!-- Debug: Simulate payment button -->
<div v-if="atmStore.debugMode" class="pt-2 text-center">

View file

@ -70,6 +70,9 @@ export {
type CLINKOffer,
type PriceType,
NofferTLV,
// Ndebit types
type DebitPointer,
NdebitTLV,
// Function types
type GenerateInvoice,
type PayInvoice,
@ -82,6 +85,15 @@ export {
// Noffer encoding/decoding
export { encodeNoffer, decodeNoffer, isValidNoffer, npubToHex, hexToNpub } from './noffer.js'
// Ndebit encoding/decoding
export {
encodeNdebit,
decodeNdebit,
isValidNdebit,
formatNdebitUri,
parseNdebitAmount,
} from './ndebit.js'
// Client
export {
CLINKClient,

View file

@ -0,0 +1,206 @@
/**
* ndebit encoding/decoding
*
* ndebits are bech32-encoded debit pointers that contain
* pubkey, relay, and optional pointer information.
*
* Format: ndebit1<bech32-encoded-tlv-data>
*
* For ATM cash-in flow, the URI format includes amount:
* clink:ndebit1<bech32data>?amount=<sats>
*/
import { bech32 } from '@scure/base'
import { nip19 } from 'nostr-tools'
import type { DebitPointer } from './types.js'
import { NdebitTLV } from './types.js'
const NDEBIT_PREFIX = 'ndebit'
const BECH32_LIMIT = 2000
/**
* Encode a debit pointer as an ndebit string
*/
export function encodeNdebit(pointer: DebitPointer): string {
const tlvData: number[] = []
// Pubkey (TLV 0) - convert npub to hex if needed
const hexPubkey = npubToHex(pointer.pubkey)
const pubkeyBytes = hexToBytes(hexPubkey)
writeTLV(tlvData, NdebitTLV.Pubkey, pubkeyBytes)
// Relay (TLV 1)
const relayBytes = new TextEncoder().encode(pointer.relay)
writeTLV(tlvData, NdebitTLV.Relay, relayBytes)
// Pointer (TLV 2) - if present
if (pointer.pointer) {
const pointerBytes = new TextEncoder().encode(pointer.pointer)
writeTLV(tlvData, NdebitTLV.Pointer, pointerBytes)
}
// Convert to Uint8Array and bech32 encode
const bytes = new Uint8Array(tlvData)
const words = bech32.toWords(bytes)
return bech32.encode(NDEBIT_PREFIX, words, BECH32_LIMIT)
}
/**
* Decode an ndebit string to a debit pointer
*/
export function decodeNdebit(ndebit: string): DebitPointer {
// Remove clink: prefix if present
let cleaned = ndebit
if (cleaned.toLowerCase().startsWith('clink:')) {
cleaned = cleaned.slice(6)
}
if (cleaned.toLowerCase().startsWith('lightning:')) {
cleaned = cleaned.slice(10)
}
// Remove query parameters if present
const queryIndex = cleaned.indexOf('?')
if (queryIndex !== -1) {
cleaned = cleaned.slice(0, queryIndex)
}
// Decode bech32 - cast to expected type (contains "1" separator)
const { prefix, words } = bech32.decode(cleaned as `${string}1${string}`, BECH32_LIMIT)
if (prefix !== NDEBIT_PREFIX) {
throw new Error(`Invalid ndebit prefix: ${prefix}`)
}
const bytes = bech32.fromWords(words)
const data = new Uint8Array(bytes)
// Parse TLV entries
let pubkey: string | undefined
let relay: string | undefined
let pointer: string | undefined
let offset = 0
while (offset < data.length) {
const type = data[offset]
offset++
if (offset >= data.length) break
const length = data[offset]
offset++
if (offset + (length ?? 0) > data.length) {
throw new Error('Invalid TLV: data truncated')
}
const value = data.slice(offset, offset + (length ?? 0))
offset += length ?? 0
switch (type) {
case NdebitTLV.Pubkey:
pubkey = bytesToHex(value)
break
case NdebitTLV.Relay:
relay = new TextDecoder().decode(value)
break
case NdebitTLV.Pointer:
pointer = new TextDecoder().decode(value)
break
// Ignore unknown TLV types for forward compatibility
}
}
if (!pubkey) {
throw new Error('Invalid ndebit: missing pubkey')
}
if (!relay) {
throw new Error('Invalid ndebit: missing relay')
}
return {
pubkey,
relay,
pointer,
}
}
/**
* Validate an ndebit string without fully decoding
*/
export function isValidNdebit(ndebit: string): boolean {
try {
decodeNdebit(ndebit)
return true
} catch {
return false
}
}
/**
* Format an ndebit as a full clink: URI with amount
*/
export function formatNdebitUri(ndebit: string, amountSats: number): string {
// Ensure ndebit doesn't already have clink: prefix
let cleaned = ndebit
if (cleaned.toLowerCase().startsWith('clink:')) {
cleaned = cleaned.slice(6)
}
if (cleaned.toLowerCase().startsWith('lightning:')) {
cleaned = cleaned.slice(10)
}
return `clink:${cleaned}?amount=${amountSats}`
}
/**
* Parse amount from ndebit URI query parameters
*/
export function parseNdebitAmount(uri: string): number | undefined {
const match = uri.match(/[?&]amount=(\d+)/)
if (match && match[1]) {
return parseInt(match[1], 10)
}
return undefined
}
// TLV helpers
function writeTLV(data: number[], type: number, value: number[] | Uint8Array): void {
data.push(type)
data.push(value.length)
for (const byte of value) {
data.push(byte)
}
}
function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16)
}
return bytes
}
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
/**
* Convert npub to hex pubkey
* Accepts either npub1... or hex format, returns hex
*/
function npubToHex(pubkey: string): string {
if (pubkey.startsWith('npub1')) {
const decoded = nip19.decode(pubkey)
if (decoded.type !== 'npub') {
throw new Error(`Invalid npub: expected npub type, got ${decoded.type}`)
}
return decoded.data
}
// Already hex
return pubkey
}

View file

@ -269,6 +269,30 @@ export enum NofferTLV {
Description = 4,
}
// ============================================================================
// Ndebit (Debit Pointer) encoding
// ============================================================================
/** Debit pointer for ndebit encoding */
export interface DebitPointer {
/** Public key of the service that will pay (hex) */
pubkey: string
/** Relay URL for communication */
relay: string
/** Optional session/voucher identifier */
pointer?: string
}
/** ndebit TLV types */
export enum NdebitTLV {
/** Public key (32 bytes) */
Pubkey = 0,
/** Relay URL (variable length string) */
Relay = 1,
/** Pointer/session identifier (variable length string) */
Pointer = 2,
}
// ============================================================================
// Function types for service injection
// ============================================================================

View file

@ -30,6 +30,12 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
}
return services.generateClinkOffer(input)
}),
generateNdebit: fromPromise(async ({ input }: { input: ATMContext }) => {
if (!services.generateNdebit) {
throw new Error('generateNdebit service not provided')
}
return services.generateNdebit(input)
}),
generateLnurlWithdraw: fromPromise(async ({ input }: { input: ATMContext }) => {
if (!services.generateLnurlWithdraw) {
throw new Error('generateLnurlWithdraw service not provided')
@ -288,21 +294,21 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
},
FINISH_INSERTING: {
guard: 'hasInsertedBills',
target: 'generatingLnurlWithdraw',
target: 'generatingNdebit',
},
CANCEL: '#atm.idle',
TIMEOUT: '#atm.idle',
},
},
generatingLnurlWithdraw: {
generatingNdebit: {
invoke: {
src: 'generateLnurlWithdraw',
src: 'generateNdebit',
input: ({ context }) => context,
onDone: {
target: 'displayingQR',
actions: assign({
lnurlWithdraw: ({ event }) => event.output,
paymentMethod: () => 'lnurl_withdraw' as const,
ndebitUri: ({ event }) => event.output,
paymentMethod: () => 'clink_offer' as const,
}),
},
onError: {

View file

@ -41,7 +41,9 @@ export interface ATMContext {
clinkOffer: string | null
/** noffer string displayed to user for cash-out */
nofferString: string | null
/** LNURL-withdraw string - for cash-in (customer receives sats) */
/** ndebit URI for cash-in (clink:ndebit1...?amount=X) */
ndebitUri: string | null
/** LNURL-withdraw string - for cash-in (customer receives sats) - legacy */
lnurlWithdraw: string | null
/** Current payment status */
paymentStatus: PaymentStatus
@ -116,6 +118,7 @@ export const initialContext: ATMContext = {
invoice: null,
clinkOffer: null,
nofferString: null,
ndebitUri: null,
lnurlWithdraw: null,
paymentStatus: null,
preimage: null,
@ -135,7 +138,9 @@ export const initialContext: ATMContext = {
export interface ATMServices {
/** Generate a CLINK offer (for cash-out) */
generateClinkOffer: (context: ATMContext) => Promise<string>
/** Generate an LNURL-withdraw link (for cash-in - customer receives sats) */
/** Generate an ndebit URI for cash-in (clink:ndebit1...?amount=X) */
generateNdebit: (context: ATMContext) => Promise<string>
/** Generate an LNURL-withdraw link (for cash-in - customer receives sats) - legacy */
generateLnurlWithdraw: (context: ATMContext) => Promise<string>
/** Generate a Lightning invoice */
generateInvoice: (amountMsat: number) => Promise<string>