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:
parent
66a3c054e7
commit
cf053730c6
8 changed files with 378 additions and 92 deletions
|
|
@ -19,7 +19,14 @@ import {
|
||||||
type MachineIdentity,
|
type MachineIdentity,
|
||||||
} from '@lamassu/nostr-client'
|
} from '@lamassu/nostr-client'
|
||||||
import { LightningPubClient } from '@lamassu/lightning'
|
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 { OfferRequest } from '@lamassu/clink'
|
||||||
import type { ATMServices, ATMContext } from '@lamassu/state-machine'
|
import type { ATMServices, ATMContext } from '@lamassu/state-machine'
|
||||||
|
|
||||||
|
|
@ -186,6 +193,28 @@ function createATMServices(
|
||||||
_onPaymentSuccess: (preimage: string) => void
|
_onPaymentSuccess: (preimage: string) => void
|
||||||
): ATMServices {
|
): ATMServices {
|
||||||
return {
|
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
|
* Generate a CLINK offer (noffer) for receiving payment
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,12 @@ const mockServices: ATMServices = {
|
||||||
return `noffer1mock${Date.now().toString(36)}`
|
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) => {
|
generateLnurlWithdraw: async (context) => {
|
||||||
console.log('[Mock] Generating LNURL-withdraw for', context.satsAmount, 'sats')
|
console.log('[Mock] Generating LNURL-withdraw for', context.satsAmount, 'sats')
|
||||||
// This is a mock LNURL - in reality it would be a bech32 encoded URL
|
// This is a mock LNURL - in reality it would be a bech32 encoded URL
|
||||||
|
|
|
||||||
|
|
@ -13,16 +13,31 @@ import QRCode from '@/components/QRCode.vue'
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const atmStore = useAtmStore()
|
const atmStore = useAtmStore()
|
||||||
|
|
||||||
// Input modes
|
|
||||||
type PaymentMode = 'clink' | 'invoice'
|
|
||||||
const paymentMode = ref<PaymentMode>('clink')
|
|
||||||
|
|
||||||
// CLINK Debit: customer's npub/pubkey
|
// CLINK Debit: customer's npub/pubkey
|
||||||
const pubkeyInput = ref('')
|
const pubkeyInput = ref('')
|
||||||
|
|
||||||
// Invoice input for manual payment
|
// Invoice input for manual payment
|
||||||
const invoiceInput = ref('')
|
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
|
// Navigate back to idle when transaction completes
|
||||||
watch(
|
watch(
|
||||||
() => atmStore.isIdle,
|
() => atmStore.isIdle,
|
||||||
|
|
@ -180,54 +195,71 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Generating LNURL-withdraw -->
|
<!-- Generating NDebit -->
|
||||||
<Card v-else-if="nestedState === 'generatingLnurlWithdraw'" class="w-80 border-0 bg-white/5">
|
<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">
|
<CardContent class="flex flex-col items-center gap-4 p-8">
|
||||||
<Skeleton class="h-10 w-10 rounded-full" />
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Display Payment Options -->
|
<!-- Display NDebit QR -->
|
||||||
<Card v-else-if="nestedState === 'displayingQR'" class="w-[480px] border-0 bg-white/5">
|
<Card v-else-if="nestedState === 'displayingQR'" class="w-[480px] border-0 bg-white/5">
|
||||||
<CardHeader class="text-center">
|
<CardHeader class="text-center">
|
||||||
<CardTitle>Receive Your Sats</CardTitle>
|
<CardTitle>Scan to Receive Sats</CardTitle>
|
||||||
<CardDescription v-if="context">
|
<CardDescription v-if="context">
|
||||||
<Badge variant="secondary" class="text-lg">
|
<Badge variant="secondary" class="text-lg">
|
||||||
{{ formatSats(context.satsAmount) }} sats
|
{{ formatSats(context.satsAmount) }} sats
|
||||||
</Badge>
|
</Badge>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-6">
|
||||||
<!-- Payment mode tabs -->
|
<!-- NDebit QR Code (Primary) -->
|
||||||
<div class="flex gap-2 rounded-lg bg-white/5 p-1">
|
<div class="flex flex-col items-center">
|
||||||
<Button
|
<QRCode v-if="context?.ndebitUri" :value="context.ndebitUri" :size="220" />
|
||||||
:variant="paymentMode === 'clink' ? 'default' : 'ghost'"
|
<p class="mt-4 text-center text-sm text-muted-foreground">
|
||||||
size="sm"
|
Scan with Shock Wallet or compatible CLINK wallet
|
||||||
class="flex-1"
|
</p>
|
||||||
@click="paymentMode = 'clink'"
|
|
||||||
>
|
|
||||||
CLINK Debit (Nostr)
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
:variant="paymentMode === 'invoice' ? 'default' : 'ghost'"
|
|
||||||
size="sm"
|
|
||||||
class="flex-1"
|
|
||||||
@click="paymentMode = 'invoice'"
|
|
||||||
>
|
|
||||||
Paste Invoice
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CLINK Debit Mode -->
|
<!-- Copyable NDebit URI -->
|
||||||
<div v-if="paymentMode === 'clink'" class="space-y-4">
|
<div v-if="context?.ndebitUri" class="space-y-2">
|
||||||
<Alert class="border-blue-500/50 bg-blue-500/10">
|
<div class="flex gap-2">
|
||||||
<AlertDescription>
|
<Input :model-value="context.ndebitUri" readonly class="flex-1 font-mono text-xs" />
|
||||||
<p class="mb-2 text-sm font-medium text-blue-400">CLINK Debit (Nostr-Native)</p>
|
<Button variant="outline" size="sm" @click="copyNdebit">
|
||||||
<p class="mb-3 text-xs text-muted-foreground">
|
{{ copied ? '✓ Copied' : 'Copy' }}
|
||||||
Enter your Shock Wallet's npub or pubkey. We'll send a debit request and your
|
</Button>
|
||||||
wallet will automatically pay.
|
</div>
|
||||||
</p>
|
<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">
|
<div class="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
v-model="pubkeyInput"
|
v-model="pubkeyInput"
|
||||||
|
|
@ -236,33 +268,19 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
|
||||||
:disabled="isProcessing"
|
:disabled="isProcessing"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
:disabled="!pubkeyInput.trim() || isProcessing"
|
:disabled="!pubkeyInput.trim() || isProcessing"
|
||||||
@click="submitDebitRequest"
|
@click="submitDebitRequest"
|
||||||
>
|
>
|
||||||
{{ atmStore.isRequestingDebit ? 'Requesting...' : 'Request' }}
|
{{ atmStore.isRequestingDebit ? '...' : 'Send' }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="atmStore.paymentError" class="mt-2 text-xs text-red-400">
|
</div>
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- Invoice Paste Mode -->
|
<!-- Invoice paste -->
|
||||||
<div v-if="paymentMode === 'invoice'" class="space-y-4">
|
<div class="space-y-2">
|
||||||
<Alert class="border-orange-500/50 bg-orange-500/10">
|
<p class="text-xs font-medium text-orange-400">Or paste your invoice:</p>
|
||||||
<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>
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
v-model="invoiceInput"
|
v-model="invoiceInput"
|
||||||
|
|
@ -271,41 +289,21 @@ const isProcessing = computed(() => atmStore.isPayingInvoice || atmStore.isReque
|
||||||
:disabled="isProcessing"
|
:disabled="isProcessing"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
:disabled="!invoiceInput.trim() || isProcessing"
|
:disabled="!invoiceInput.trim() || isProcessing"
|
||||||
@click="submitInvoice"
|
@click="submitInvoice"
|
||||||
>
|
>
|
||||||
{{ atmStore.isPayingInvoice ? 'Paying...' : 'Pay' }}
|
{{ atmStore.isPayingInvoice ? '...' : 'Pay' }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="atmStore.paymentError" class="mt-2 text-xs text-red-400">
|
</div>
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- Divider -->
|
<p v-if="atmStore.paymentError" class="text-xs text-red-400">
|
||||||
<div class="relative flex items-center justify-center py-2">
|
Error: {{ atmStore.paymentError }}
|
||||||
<div class="absolute inset-0 flex items-center">
|
</p>
|
||||||
<span class="w-full border-t border-white/20" />
|
|
||||||
</div>
|
</div>
|
||||||
<span class="relative bg-background px-3 text-xs text-muted-foreground">
|
</details>
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- Debug: Simulate payment button -->
|
<!-- Debug: Simulate payment button -->
|
||||||
<div v-if="atmStore.debugMode" class="pt-2 text-center">
|
<div v-if="atmStore.debugMode" class="pt-2 text-center">
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,9 @@ export {
|
||||||
type CLINKOffer,
|
type CLINKOffer,
|
||||||
type PriceType,
|
type PriceType,
|
||||||
NofferTLV,
|
NofferTLV,
|
||||||
|
// Ndebit types
|
||||||
|
type DebitPointer,
|
||||||
|
NdebitTLV,
|
||||||
// Function types
|
// Function types
|
||||||
type GenerateInvoice,
|
type GenerateInvoice,
|
||||||
type PayInvoice,
|
type PayInvoice,
|
||||||
|
|
@ -82,6 +85,15 @@ export {
|
||||||
// Noffer encoding/decoding
|
// Noffer encoding/decoding
|
||||||
export { encodeNoffer, decodeNoffer, isValidNoffer, npubToHex, hexToNpub } from './noffer.js'
|
export { encodeNoffer, decodeNoffer, isValidNoffer, npubToHex, hexToNpub } from './noffer.js'
|
||||||
|
|
||||||
|
// Ndebit encoding/decoding
|
||||||
|
export {
|
||||||
|
encodeNdebit,
|
||||||
|
decodeNdebit,
|
||||||
|
isValidNdebit,
|
||||||
|
formatNdebitUri,
|
||||||
|
parseNdebitAmount,
|
||||||
|
} from './ndebit.js'
|
||||||
|
|
||||||
// Client
|
// Client
|
||||||
export {
|
export {
|
||||||
CLINKClient,
|
CLINKClient,
|
||||||
|
|
|
||||||
206
lamassu-next/packages/clink/src/ndebit.ts
Normal file
206
lamassu-next/packages/clink/src/ndebit.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -269,6 +269,30 @@ export enum NofferTLV {
|
||||||
Description = 4,
|
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
|
// Function types for service injection
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,12 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
|
||||||
}
|
}
|
||||||
return services.generateClinkOffer(input)
|
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 }) => {
|
generateLnurlWithdraw: fromPromise(async ({ input }: { input: ATMContext }) => {
|
||||||
if (!services.generateLnurlWithdraw) {
|
if (!services.generateLnurlWithdraw) {
|
||||||
throw new Error('generateLnurlWithdraw service not provided')
|
throw new Error('generateLnurlWithdraw service not provided')
|
||||||
|
|
@ -288,21 +294,21 @@ export function createATMMachine(services: Partial<ATMServices> = {}) {
|
||||||
},
|
},
|
||||||
FINISH_INSERTING: {
|
FINISH_INSERTING: {
|
||||||
guard: 'hasInsertedBills',
|
guard: 'hasInsertedBills',
|
||||||
target: 'generatingLnurlWithdraw',
|
target: 'generatingNdebit',
|
||||||
},
|
},
|
||||||
CANCEL: '#atm.idle',
|
CANCEL: '#atm.idle',
|
||||||
TIMEOUT: '#atm.idle',
|
TIMEOUT: '#atm.idle',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
generatingLnurlWithdraw: {
|
generatingNdebit: {
|
||||||
invoke: {
|
invoke: {
|
||||||
src: 'generateLnurlWithdraw',
|
src: 'generateNdebit',
|
||||||
input: ({ context }) => context,
|
input: ({ context }) => context,
|
||||||
onDone: {
|
onDone: {
|
||||||
target: 'displayingQR',
|
target: 'displayingQR',
|
||||||
actions: assign({
|
actions: assign({
|
||||||
lnurlWithdraw: ({ event }) => event.output,
|
ndebitUri: ({ event }) => event.output,
|
||||||
paymentMethod: () => 'lnurl_withdraw' as const,
|
paymentMethod: () => 'clink_offer' as const,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
onError: {
|
onError: {
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,9 @@ export interface ATMContext {
|
||||||
clinkOffer: string | null
|
clinkOffer: string | null
|
||||||
/** noffer string displayed to user for cash-out */
|
/** noffer string displayed to user for cash-out */
|
||||||
nofferString: string | null
|
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
|
lnurlWithdraw: string | null
|
||||||
/** Current payment status */
|
/** Current payment status */
|
||||||
paymentStatus: PaymentStatus
|
paymentStatus: PaymentStatus
|
||||||
|
|
@ -116,6 +118,7 @@ export const initialContext: ATMContext = {
|
||||||
invoice: null,
|
invoice: null,
|
||||||
clinkOffer: null,
|
clinkOffer: null,
|
||||||
nofferString: null,
|
nofferString: null,
|
||||||
|
ndebitUri: null,
|
||||||
lnurlWithdraw: null,
|
lnurlWithdraw: null,
|
||||||
paymentStatus: null,
|
paymentStatus: null,
|
||||||
preimage: null,
|
preimage: null,
|
||||||
|
|
@ -135,7 +138,9 @@ export const initialContext: ATMContext = {
|
||||||
export interface ATMServices {
|
export interface ATMServices {
|
||||||
/** Generate a CLINK offer (for cash-out) */
|
/** Generate a CLINK offer (for cash-out) */
|
||||||
generateClinkOffer: (context: ATMContext) => Promise<string>
|
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>
|
generateLnurlWithdraw: (context: ATMContext) => Promise<string>
|
||||||
/** Generate a Lightning invoice */
|
/** Generate a Lightning invoice */
|
||||||
generateInvoice: (amountMsat: number) => Promise<string>
|
generateInvoice: (amountMsat: number) => Promise<string>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue