diff --git a/lamassu-next/apps/machine/src/services/lightning.ts b/lamassu-next/apps/machine/src/services/lightning.ts index 56be9c7..0eee1a3 100644 --- a/lamassu-next/apps/machine/src/services/lightning.ts +++ b/lamassu-next/apps/machine/src/services/lightning.ts @@ -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?amount= + */ + generateNdebit: async (context: ATMContext): Promise => { + 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 * diff --git a/lamassu-next/apps/machine/src/stores/atm.ts b/lamassu-next/apps/machine/src/stores/atm.ts index f078a77..9428c3f 100644 --- a/lamassu-next/apps/machine/src/stores/atm.ts +++ b/lamassu-next/apps/machine/src/stores/atm.ts @@ -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 diff --git a/lamassu-next/apps/machine/src/views/CashInView.vue b/lamassu-next/apps/machine/src/views/CashInView.vue index f7d2921..a679939 100644 --- a/lamassu-next/apps/machine/src/views/CashInView.vue +++ b/lamassu-next/apps/machine/src/views/CashInView.vue @@ -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('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 - - + + -

Preparing your withdrawal...

+

Preparing payment code...

- + - Receive Your Sats + Scan to Receive Sats {{ formatSats(context.satsAmount) }} sats - - -
- - + + +
+ +

+ Scan with Shock Wallet or compatible CLINK wallet +

- -
- - -

CLINK Debit (Nostr-Native)

-

- Enter your Shock Wallet's npub or pubkey. We'll send a debit request and your - wallet will automatically pay. -

+ +
+
+ + +
+

+ Or copy and paste into your wallet +

+
+ + + + +

How it works:

+
    +
  1. 1. Scan the QR code with your Lightning wallet
  2. +
  3. + 2. Your wallet creates an invoice for + {{ formatSats(context?.satsAmount || 0) }} sats +
  4. +
  5. 3. Confirm the claim in your wallet
  6. +
  7. 4. Receive your sats instantly!
  8. +
+
+
+ + +
+ + Alternative: Manual payment options + +
+ +
+

Enter your npub/pubkey:

atmStore.isPayingInvoice || atmStore.isReque :disabled="isProcessing" />
-

- Error: {{ atmStore.paymentError }} -

- - -

- Your Shock Wallet must be connected to relay: ws://192.168.1.122:7777 -

-
+
- -
- - -

Manual Invoice Payment

-

- Create a {{ formatSats(context?.satsAmount || 0) }} sat invoice in your wallet and - paste it below. We'll pay it instantly. -

+ +
+

Or paste your invoice:

atmStore.isPayingInvoice || atmStore.isReque :disabled="isProcessing" />
-

- Error: {{ atmStore.paymentError }} -

- - -

- Note: Invoice must be on same network (regtest for dev) -

-
+
- -
-
- +

+ Error: {{ atmStore.paymentError }} +

- - or scan QR - -
- - -
- -

- LNURL-withdraw (mock in browser dev) -

-
+
diff --git a/lamassu-next/packages/clink/src/index.ts b/lamassu-next/packages/clink/src/index.ts index 5ce4e6e..6c7b9df 100644 --- a/lamassu-next/packages/clink/src/index.ts +++ b/lamassu-next/packages/clink/src/index.ts @@ -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, diff --git a/lamassu-next/packages/clink/src/ndebit.ts b/lamassu-next/packages/clink/src/ndebit.ts new file mode 100644 index 0000000..070b69e --- /dev/null +++ b/lamassu-next/packages/clink/src/ndebit.ts @@ -0,0 +1,206 @@ +/** + * ndebit encoding/decoding + * + * ndebits are bech32-encoded debit pointers that contain + * pubkey, relay, and optional pointer information. + * + * Format: ndebit1 + * + * For ATM cash-in flow, the URI format includes amount: + * clink:ndebit1?amount= + */ + +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 +} diff --git a/lamassu-next/packages/clink/src/types.ts b/lamassu-next/packages/clink/src/types.ts index e1b22f0..7a6f8e8 100644 --- a/lamassu-next/packages/clink/src/types.ts +++ b/lamassu-next/packages/clink/src/types.ts @@ -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 // ============================================================================ diff --git a/lamassu-next/packages/state-machine/src/machine.ts b/lamassu-next/packages/state-machine/src/machine.ts index 5c638b8..f1898c4 100644 --- a/lamassu-next/packages/state-machine/src/machine.ts +++ b/lamassu-next/packages/state-machine/src/machine.ts @@ -30,6 +30,12 @@ export function createATMMachine(services: Partial = {}) { } 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 = {}) { }, 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: { diff --git a/lamassu-next/packages/state-machine/src/types.ts b/lamassu-next/packages/state-machine/src/types.ts index aa07681..2166105 100644 --- a/lamassu-next/packages/state-machine/src/types.ts +++ b/lamassu-next/packages/state-machine/src/types.ts @@ -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 - /** 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 + /** Generate an LNURL-withdraw link (for cash-in - customer receives sats) - legacy */ generateLnurlWithdraw: (context: ATMContext) => Promise /** Generate a Lightning invoice */ generateInvoice: (amountMsat: number) => Promise