From e185947259ed34b6124bc92059501be9a5b72e1e Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Fri, 30 Jan 2026 13:31:42 -0500 Subject: [PATCH] fix(clink): align CLINK implementation with protocol spec Reviewed the CLINK spec from the shocknet/clink repository and fixed several compliance issues: ## noffer TLV encoding (NIP-19 style) - Fixed TLV numbers: pubkey=0, relay=1, offerId=2, priceType=3, amount=4, currency=5 - Added offerId (TLV 2) and currency (TLV 5) fields to noffer encoding - Updated CLINKOffer interface to use offerId instead of description ## Error codes - Split error codes into OfferErrorCode (Kind 21001) and GFYCode (Kind 21002/21003) - Added all error codes per CLINK spec with proper numeric values - Deprecated CLINKErrorCode alias for backward compatibility ## CLINK events - Added mandatory clink_version tag ["clink_version", "1"] to all CLINK events - Validate clink_version tag when receiving events - Switched to NIP-44 v2 encryption for all CLINK events (21001-21003) ## Debit requests (Kind 21002) - Fixed DebitRequest to include bolt11 field for payment requests - Split into DebitPaymentRequest (with bolt11) and DebitBudgetRequest - Added isDebitPaymentRequest type guard ## Client API changes - Renamed requestDebit to requestDebitPayment (requires bolt11 invoice) - Added requestDebitBudget for recurring budget authorization - Updated createOffer signature to use offerId instead of description ## apps/machine updates - Updated lightning.ts to use OfferErrorCode enum values - Updated atm.ts to generate invoice before debit request Co-Authored-By: Claude Opus 4.5 --- .../apps/machine/src/services/lightning.ts | 12 +- lamassu-next/apps/machine/src/stores/atm.ts | 32 ++- .../clink/src/__tests__/noffer.test.ts | 61 ++++- lamassu-next/packages/clink/src/client.ts | 203 +++++++++++----- lamassu-next/packages/clink/src/index.ts | 20 +- lamassu-next/packages/clink/src/noffer.ts | 43 ++-- lamassu-next/packages/clink/src/types.ts | 216 ++++++++++++------ .../packages/nostr-client/src/index.ts | 9 +- 8 files changed, 428 insertions(+), 168 deletions(-) diff --git a/lamassu-next/apps/machine/src/services/lightning.ts b/lamassu-next/apps/machine/src/services/lightning.ts index 487e257..d6f05ac 100644 --- a/lamassu-next/apps/machine/src/services/lightning.ts +++ b/lamassu-next/apps/machine/src/services/lightning.ts @@ -23,7 +23,7 @@ import { CLINKClient, createOfferSuccess, createOfferError, - CLINKErrorCode, + OfferErrorCode, encodeNdebit, formatNdebitUri, } from '@lamassu/clink' @@ -201,7 +201,7 @@ export async function initializeLightningServices(): Promise const amountSats = request.amount_sats if (!amountSats || amountSats <= 0) { console.log('[CLINK] Invalid amount requested') - return createOfferError(CLINKErrorCode.InvalidAmount, 'Invalid amount') + return createOfferError(OfferErrorCode.InvalidAmount, 'Invalid amount') } console.log('[CLINK] Creating invoice for', amountSats, 'sats') @@ -225,7 +225,7 @@ export async function initializeLightningServices(): Promise } catch (error) { console.error('[CLINK] Failed to create invoice:', error) return createOfferError( - CLINKErrorCode.TemporaryFailure, + OfferErrorCode.TemporaryFailure, error instanceof Error ? error.message : 'Failed to create invoice' ) } @@ -304,9 +304,7 @@ function createATMServices( const noffer = clink.createOffer({ priceType: 'fixed', amountSats: context.satsAmount, - description: `Lamassu ATM - ${context.satsAmount} sats`, - minAmountSats: context.satsAmount, - maxAmountSats: context.satsAmount, + offerId: `cashout-${Date.now()}`, }) console.log('[ATM Service] Generated noffer:', noffer.slice(0, 32) + '...') @@ -437,7 +435,7 @@ function createATMServices( // Create a spontaneous noffer (any amount accepted) const noffer = clink.createOffer({ priceType: 'spontaneous', - description: 'Lamassu ATM - Cash Out', + offerId: 'cashout', }) console.log('[ATM Service] Generated noffer:', noffer.slice(0, 32) + '...') diff --git a/lamassu-next/apps/machine/src/stores/atm.ts b/lamassu-next/apps/machine/src/stores/atm.ts index c52aadf..7eb229f 100644 --- a/lamassu-next/apps/machine/src/stores/atm.ts +++ b/lamassu-next/apps/machine/src/stores/atm.ts @@ -264,11 +264,16 @@ export const useAtmStore = defineStore('atm', () => { /** * Request a CLINK Debit from customer's wallet (for cash-in flow) * Customer provides their Nostr pubkey, we request payment authorization + * + * Flow: + * 1. ATM generates an invoice for the amount + * 2. ATM sends DebitPaymentRequest with that invoice to customer's wallet + * 3. Customer's wallet pays the invoice and returns preimage */ async function requestDebit(customerPubkey: string, amountSats?: number): Promise { - if (!clinkClient.value) { - paymentError.value = 'CLINK client not initialized' - console.error('[ATM] Cannot request debit: CLINK client not initialized') + if (!clinkClient.value || !lightningPub.value) { + paymentError.value = 'Lightning services not initialized' + console.error('[ATM] Cannot request debit: Lightning services not initialized') return false } @@ -286,10 +291,27 @@ export const useAtmStore = defineStore('atm', () => { console.log('[ATM] Requesting CLINK Debit from', customerPubkey.slice(0, 16) + '...') console.log('[ATM] Amount:', amount, 'sats') - const response = await clinkClient.value.requestDebit(customerPubkey, amount, { - memo: `Lamassu ATM - Buy ${amount} sats`, + // Step 1: Generate an invoice for the amount + console.log('[ATM] Generating invoice for debit request...') + const invoice = await lightningPub.value.createInvoice({ + amountSats: amount, + description: `Lamassu ATM - Buy ${amount} sats`, }) + if (!invoice.paymentRequest) { + throw new Error('Failed to generate invoice') + } + + // Step 2: Send debit payment request with the invoice + const response = await clinkClient.value.requestDebitPayment( + customerPubkey, + invoice.paymentRequest, + { + amountSats: amount, + description: `Lamassu ATM - Buy ${amount} sats`, + } + ) + if (response.res === 'ok' && response.preimage) { console.log( '[ATM] CLINK Debit successful! Preimage:', diff --git a/lamassu-next/packages/clink/src/__tests__/noffer.test.ts b/lamassu-next/packages/clink/src/__tests__/noffer.test.ts index a755e9d..483d208 100644 --- a/lamassu-next/packages/clink/src/__tests__/noffer.test.ts +++ b/lamassu-next/packages/clink/src/__tests__/noffer.test.ts @@ -3,11 +3,12 @@ import { encodeNoffer, decodeNoffer, isValidNoffer } from '../noffer.js' import type { CLINKOffer } from '../types.js' describe('noffer encoding/decoding', () => { + // Basic spontaneous payment offer (per CLINK spec) const sampleOffer: CLINKOffer = { pubkey: 'a'.repeat(64), // 64 hex chars relays: ['wss://relay.example.com', 'wss://relay2.example.com'], - priceType: 'variable', - description: 'Test offer', + priceType: 'spontaneous', + offerId: 'test-offer-123', } describe('encodeNoffer', () => { @@ -18,7 +19,7 @@ describe('noffer encoding/decoding', () => { expect(typeof noffer).toBe('string') }) - it('should encode an offer with amount', () => { + it('should encode an offer with amount (fixed price)', () => { const offer: CLINKOffer = { ...sampleOffer, priceType: 'fixed', @@ -28,17 +29,29 @@ describe('noffer encoding/decoding', () => { const noffer = encodeNoffer(offer) expect(noffer).toMatch(/^noffer1/) }) + + it('should encode an offer with currency (variable price)', () => { + const offer: CLINKOffer = { + ...sampleOffer, + priceType: 'variable', + amountSats: 10, // $10 USD + currency: 'USD', + } + + const noffer = encodeNoffer(offer) + expect(noffer).toMatch(/^noffer1/) + }) }) describe('decodeNoffer', () => { - it('should round-trip encode/decode', () => { + it('should round-trip encode/decode spontaneous offer', () => { const noffer = encodeNoffer(sampleOffer) const decoded = decodeNoffer(noffer) expect(decoded.pubkey).toBe(sampleOffer.pubkey) expect(decoded.relays).toEqual(sampleOffer.relays) expect(decoded.priceType).toBe(sampleOffer.priceType) - expect(decoded.description).toBe(sampleOffer.description) + expect(decoded.offerId).toBe(sampleOffer.offerId) }) it('should decode fixed price offer correctly', () => { @@ -55,9 +68,47 @@ describe('noffer encoding/decoding', () => { expect(decoded.amountSats).toBe(50000) }) + it('should decode variable price offer with currency', () => { + const offer: CLINKOffer = { + pubkey: 'b'.repeat(64), + relays: ['wss://relay.example.com'], + priceType: 'variable', + amountSats: 25, // $25 USD + currency: 'USD', + offerId: 'fiat-product', + } + + const noffer = encodeNoffer(offer) + const decoded = decodeNoffer(noffer) + + expect(decoded.priceType).toBe('variable') + expect(decoded.amountSats).toBe(25) + expect(decoded.currency).toBe('USD') + expect(decoded.offerId).toBe('fiat-product') + }) + + it('should default to spontaneous when price type not specified', () => { + // Encode an offer without price type (should still work) + const minimalOffer: CLINKOffer = { + pubkey: 'c'.repeat(64), + relays: ['wss://relay.example.com'], + priceType: 'spontaneous', // Required in our interface but defaults in spec + } + + const noffer = encodeNoffer(minimalOffer) + const decoded = decodeNoffer(noffer) + + expect(decoded.priceType).toBe('spontaneous') + }) + it('should throw on invalid prefix', () => { expect(() => decodeNoffer('invalid1abc')).toThrow() }) + + it('should throw on missing pubkey', () => { + // This would require a malformed noffer string + expect(() => decodeNoffer('noffer1qqqqqq')).toThrow() + }) }) describe('isValidNoffer', () => { diff --git a/lamassu-next/packages/clink/src/client.ts b/lamassu-next/packages/clink/src/client.ts index f357222..f7a2c4e 100644 --- a/lamassu-next/packages/clink/src/client.ts +++ b/lamassu-next/packages/clink/src/client.ts @@ -13,10 +13,37 @@ import type { Event, UnsignedEvent } from 'nostr-tools' import { finalizeEvent } from 'nostr-tools' import type { MachineIdentity, NostrClient } from '@lamassu/nostr-client' -import { encryptContent, decryptJSON } from '@lamassu/nostr-client' +import { encryptContentV2, decryptContentV2 } from '@lamassu/nostr-client' + +/** CLINK protocol version tag (mandatory per CLINK spec) */ +const CLINK_VERSION_TAG: [string, string] = ['clink_version', '1'] + +/** + * Encrypt content using NIP-44 v2 (required for CLINK events) + */ +function encryptCLINK( + identity: MachineIdentity, + recipientPubkey: string, + content: unknown +): string { + return encryptContentV2(identity, recipientPubkey, content) +} + +/** + * Decrypt and parse JSON content using NIP-44 v2 + */ +function decryptCLINKJSON( + identity: MachineIdentity, + senderPubkey: string, + ciphertext: string +): T { + const plaintext = decryptContentV2(identity, senderPubkey, ciphertext) + return JSON.parse(plaintext) as T +} import { CLINKEventKind, - CLINKErrorCode, + OfferErrorCode, + GFYCode, type CLINKOffer, type OfferRequest, type OfferResponse, @@ -98,22 +125,25 @@ export class CLINKClient { /** * Create a noffer (static payment code) for this machine + * + * @param options.priceType - Pricing model (fixed, variable, spontaneous) + * @param options.offerId - Opaque offer identifier (for routing/tracking) + * @param options.amountSats - Amount in sats (for fixed price or display) + * @param options.currency - Currency code (e.g., "USD") for variable pricing */ createOffer(options: { priceType: CLINKOffer['priceType'] + offerId?: string amountSats?: number - description?: string - minAmountSats?: number - maxAmountSats?: number + currency?: string }): string { const offer: CLINKOffer = { pubkey: this.identity.publicKey, relays: this.relays, priceType: options.priceType, + offerId: options.offerId, amountSats: options.amountSats, - description: options.description, - minAmountSats: options.minAmountSats, - maxAmountSats: options.maxAmountSats, + currency: options.currency, } return encodeNoffer(offer) @@ -191,19 +221,19 @@ export class CLINKClient { const offer = decodeNoffer(noffer) const request: OfferRequest = { - offer: noffer, + offer: offer.offerId ?? noffer, // Use offer ID if available, otherwise full noffer amount_sats: amountSats, payer_data: options?.payerData, description: options?.description, expires_in_seconds: options?.expiresInSeconds, } - const content = encryptContent(this.identity, offer.pubkey, request) + const content = encryptCLINK(this.identity, offer.pubkey, request) const event = this.createSignedEvent({ kind: CLINKEventKind.Offer, content, - tags: [['p', offer.pubkey]], + tags: [['p', offer.pubkey], CLINK_VERSION_TAG], created_at: Math.floor(Date.now() / 1000), }) @@ -214,28 +244,65 @@ export class CLINKClient { } /** - * Send a debit request (Kind 21002) + * Send a debit payment request (Kind 21002) + * User's wallet will pay the provided invoice */ - async requestDebit( + async requestDebitPayment( targetPubkey: string, - amountSats: number, + bolt11: string, options?: { - memo?: string - rules?: DebitRequest['rules'] + pointer?: string + amountSats?: number + description?: string } ): Promise { const request: DebitRequest = { - amount_sats: amountSats, - memo: options?.memo, - rules: options?.rules, + bolt11, + pointer: options?.pointer, + amount_sats: options?.amountSats, + description: options?.description, } - const content = encryptContent(this.identity, targetPubkey, request) + const content = encryptCLINK(this.identity, targetPubkey, request) const event = this.createSignedEvent({ kind: CLINKEventKind.Debit, content, - tags: [['p', targetPubkey]], + tags: [['p', targetPubkey], CLINK_VERSION_TAG], + created_at: Math.floor(Date.now() / 1000), + }) + + await this.nostrClient.publish(event) + + return this.waitForResponse(targetPubkey, CLINKEventKind.Debit, event.id) + } + + /** + * Send a debit budget request (Kind 21002) + * Request authorization for a spending budget + */ + async requestDebitBudget( + targetPubkey: string, + amountSats: number, + options?: { + pointer?: string + frequency?: { number: number; unit: 'day' | 'week' | 'month' } + description?: string + } + ): Promise { + const request: DebitRequest = { + amount_sats: amountSats, + pointer: options?.pointer, + frequency: options?.frequency, + description: options?.description, + } + + const content = encryptCLINK(this.identity, targetPubkey, request) + + const event = this.createSignedEvent({ + kind: CLINKEventKind.Debit, + content, + tags: [['p', targetPubkey], CLINK_VERSION_TAG], created_at: Math.floor(Date.now() / 1000), }) @@ -251,12 +318,12 @@ export class CLINKClient { targetPubkey: string, request: ManagementRequest ): Promise { - const content = encryptContent(this.identity, targetPubkey, request) + const content = encryptCLINK(this.identity, targetPubkey, request) const event = this.createSignedEvent({ kind: CLINKEventKind.Manage, content, - tags: [['p', targetPubkey]], + tags: [['p', targetPubkey], CLINK_VERSION_TAG], created_at: Math.floor(Date.now() / 1000), }) @@ -314,21 +381,25 @@ export class CLINKClient { private async handleOfferEvent(event: Event): Promise { if (!this.offerHandler) return - const request = decryptJSON(this.identity, event.pubkey, event.content) + // Validate clink_version tag (per CLINK spec) + const versionTag = event.tags.find((t) => t[0] === 'clink_version') + if (!versionTag || versionTag[1] !== '1') { + console.warn('Ignoring CLINK event with missing or unsupported clink_version') + return + } + + const request = decryptCLINKJSON(this.identity, event.pubkey, event.content) const response = await this.offerHandler(request, event.pubkey) if (!response) return - // Send encrypted response - const content = encryptContent(this.identity, event.pubkey, response) + // Send encrypted response with clink_version tag + const content = encryptCLINK(this.identity, event.pubkey, response) const responseEvent = this.createSignedEvent({ kind: CLINKEventKind.Offer, content, - tags: [ - ['p', event.pubkey], - ['e', event.id], - ], + tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG], created_at: Math.floor(Date.now() / 1000), }) @@ -341,20 +412,24 @@ export class CLINKClient { private async handleDebitEvent(event: Event): Promise { if (!this.debitHandler) return - const request = decryptJSON(this.identity, event.pubkey, event.content) + // Validate clink_version tag (per CLINK spec) + const versionTag = event.tags.find((t) => t[0] === 'clink_version') + if (!versionTag || versionTag[1] !== '1') { + console.warn('Ignoring CLINK event with missing or unsupported clink_version') + return + } + + const request = decryptCLINKJSON(this.identity, event.pubkey, event.content) const response = await this.debitHandler(request, event.pubkey) - // Send encrypted response - const content = encryptContent(this.identity, event.pubkey, response) + // Send encrypted response with clink_version tag + const content = encryptCLINK(this.identity, event.pubkey, response) const responseEvent = this.createSignedEvent({ kind: CLINKEventKind.Debit, content, - tags: [ - ['p', event.pubkey], - ['e', event.id], - ], + tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG], created_at: Math.floor(Date.now() / 1000), }) @@ -371,23 +446,27 @@ export class CLINKClient { return } + // Validate clink_version tag (per CLINK spec) + const versionTag = event.tags.find((t) => t[0] === 'clink_version') + if (!versionTag || versionTag[1] !== '1') { + console.warn('Ignoring CLINK event with missing or unsupported clink_version') + return + } + if (!this.managementHandler) return - const request = decryptJSON(this.identity, event.pubkey, event.content) + const request = decryptCLINKJSON(this.identity, event.pubkey, event.content) const response = await this.managementHandler(request, event.pubkey) if (!response) return - // Send encrypted response - const content = encryptContent(this.identity, event.pubkey, response) + // Send encrypted response with clink_version tag + const content = encryptCLINK(this.identity, event.pubkey, response) const responseEvent = this.createSignedEvent({ kind: CLINKEventKind.Manage, content, - tags: [ - ['p', event.pubkey], - ['e', event.id], - ], + tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG], created_at: Math.floor(Date.now() / 1000), }) @@ -416,10 +495,17 @@ export class CLINKClient { ], { onEvent: (event) => { + // Validate clink_version tag + const versionTag = event.tags.find((t) => t[0] === 'clink_version') + if (!versionTag || versionTag[1] !== '1') { + console.warn('Ignoring response with missing or unsupported clink_version') + return + } + clearTimeout(timeout) this.nostrClient.unsubscribe(subId) try { - const response = decryptJSON(this.identity, fromPubkey, event.content) + const response = decryptCLINKJSON(this.identity, fromPubkey, event.content) resolve(response) } catch (e) { reject(e) @@ -454,11 +540,11 @@ export function createOfferSuccess(bolt11: string): OfferSuccessResponse { * Create an offer error response */ export function createOfferError( - code: CLINKErrorCode, + code: OfferErrorCode, error: string, - range?: { min: number; max: number } + options?: { range?: { min: number; max: number }; latest?: string } ): OfferErrorResponse { - return { code, error, range } + return { code, error, range: options?.range, latest: options?.latest } } /** @@ -469,10 +555,25 @@ export function createDebitSuccess(preimage: string): DebitSuccessResponse { } /** - * Create a debit failure response + * Create a debit failure response (GFY) */ -export function createDebitFailure(code: CLINKErrorCode, error: string): DebitFailureResponse { - return { res: 'GFY', code, error } +export function createDebitFailure( + code: GFYCode, + error: string, + options?: { + delta?: { max_delta_ms: number; actual_delta_ms: number } + retry_after?: number + range?: { min: number; max: number } + } +): DebitFailureResponse { + return { + res: 'GFY', + code, + error, + delta: options?.delta, + retry_after: options?.retry_after, + range: options?.range, + } } // Re-export type guards diff --git a/lamassu-next/packages/clink/src/index.ts b/lamassu-next/packages/clink/src/index.ts index 6c7b9df..b4cdf98 100644 --- a/lamassu-next/packages/clink/src/index.ts +++ b/lamassu-next/packages/clink/src/index.ts @@ -24,10 +24,10 @@ * relays: ['wss://relay.example.com'], * }) * - * // Create a noffer for the ATM + * // Create a noffer for the ATM (spontaneous payment) * const noffer = clink.createOffer({ - * priceType: 'variable', - * description: 'Buy Bitcoin at ATM', + * priceType: 'spontaneous', + * offerId: 'atm-cashout', // Opaque identifier for routing * }) * * // Handle incoming offer requests @@ -44,7 +44,10 @@ export { // Event kinds CLINKEventKind, - CLINKErrorCode, + // Error codes (per CLINK spec) + OfferErrorCode, + GFYCode, + type CLINKErrorCode, // deprecated alias // Offer types (Kind 21001) type OfferRequest, type OfferResponse, @@ -52,18 +55,19 @@ export { type OfferErrorResponse, // Debit types (Kind 21002) type DebitRequest, + type DebitPaymentRequest, + type DebitBudgetRequest, + type DebitFrequency, type DebitResponse, type DebitSuccessResponse, type DebitFailureResponse, - type DebitRules, - type DebitInterval, // Management types (Kind 21003) type ManagementRequest, type ManagementResponse, type ManagementSuccessResponse, type ManagementFailureResponse, type ManagementAction, - type OfferConfig, + type ManagementResource, // Beacon types (Kind 30078) type ServiceBeacon, // Noffer types @@ -79,7 +83,9 @@ export { // Type guards isOfferError, isDebitFailure, + isDebitPaymentRequest, isManagementFailure, + isManagementSuccess, } from './types.js' // Noffer encoding/decoding diff --git a/lamassu-next/packages/clink/src/noffer.ts b/lamassu-next/packages/clink/src/noffer.ts index 1fa3892..531b281 100644 --- a/lamassu-next/packages/clink/src/noffer.ts +++ b/lamassu-next/packages/clink/src/noffer.ts @@ -16,36 +16,42 @@ const NOFFER_PREFIX = 'noffer' const BECH32_LIMIT = 2000 /** - * Encode a CLINK offer as a noffer string + * Encode a CLINK offer as a noffer string (per CLINK spec) */ export function encodeNoffer(offer: CLINKOffer): string { const tlvData: number[] = [] - // Pubkey (TLV 0) - convert npub to hex if needed + // TLV 0: Pubkey (32 bytes) - convert npub to hex if needed const hexPubkey = npubToHex(offer.pubkey) const pubkeyBytes = hexToBytes(hexPubkey) writeTLV(tlvData, NofferTLV.Pubkey, pubkeyBytes) - // Relays (TLV 1) - one entry per relay + // TLV 1: Relay URLs - one entry per relay for (const relay of offer.relays) { const relayBytes = new TextEncoder().encode(relay) writeTLV(tlvData, NofferTLV.Relay, relayBytes) } - // Price type (TLV 2) + // TLV 2: Offer identifier (opaque string) - if present + if (offer.offerId) { + const offerIdBytes = new TextEncoder().encode(offer.offerId) + writeTLV(tlvData, NofferTLV.OfferId, offerIdBytes) + } + + // TLV 3: Price type (0=fixed, 1=variable, 2=spontaneous) const priceTypeByte = encodePriceType(offer.priceType) writeTLV(tlvData, NofferTLV.PriceType, [priceTypeByte]) - // Amount (TLV 3) - if present (in satoshis) + // TLV 4: Amount in sats - if present if (offer.amountSats !== undefined) { const amountBytes = encodeUint64BE(offer.amountSats) writeTLV(tlvData, NofferTLV.Amount, amountBytes) } - // Description (TLV 4) - if present - if (offer.description) { - const descBytes = new TextEncoder().encode(offer.description) - writeTLV(tlvData, NofferTLV.Description, descBytes) + // TLV 5: Currency code - if present (requires variable pricing) + if (offer.currency) { + const currencyBytes = new TextEncoder().encode(offer.currency) + writeTLV(tlvData, NofferTLV.Currency, currencyBytes) } // Convert to Uint8Array and bech32 encode @@ -56,7 +62,7 @@ export function encodeNoffer(offer: CLINKOffer): string { } /** - * Decode a noffer string to a CLINK offer + * Decode a noffer string to a CLINK offer (per CLINK spec) */ export function decodeNoffer(noffer: string): CLINKOffer { // Decode bech32 - cast to expected type (contains "1" separator) @@ -72,9 +78,10 @@ export function decodeNoffer(noffer: string): CLINKOffer { // Parse TLV entries let pubkey: string | undefined const relays: string[] = [] - let priceType: PriceType = 'spontaneous' + let offerId: string | undefined + let priceType: PriceType = 'spontaneous' // Default per spec let amountSats: number | undefined - let description: string | undefined + let currency: string | undefined let offset = 0 while (offset < data.length) { @@ -100,16 +107,19 @@ export function decodeNoffer(noffer: string): CLINKOffer { case NofferTLV.Relay: relays.push(new TextDecoder().decode(value)) break + case NofferTLV.OfferId: + offerId = new TextDecoder().decode(value) + break case NofferTLV.PriceType: priceType = decodePriceType(value[0] ?? 2) break case NofferTLV.Amount: amountSats = decodeUint64BE(value) break - case NofferTLV.Description: - description = new TextDecoder().decode(value) + case NofferTLV.Currency: + currency = new TextDecoder().decode(value) break - // Ignore unknown TLV types for forward compatibility + // Ignore unknown TLV types for forward compatibility (per NIP-19) } } @@ -124,9 +134,10 @@ export function decodeNoffer(noffer: string): CLINKOffer { return { pubkey, relays, + offerId, priceType, amountSats, - description, + currency, } } diff --git a/lamassu-next/packages/clink/src/types.ts b/lamassu-next/packages/clink/src/types.ts index 7a6f8e8..ecc4e37 100644 --- a/lamassu-next/packages/clink/src/types.ts +++ b/lamassu-next/packages/clink/src/types.ts @@ -28,22 +28,39 @@ export enum CLINKEventKind { Beacon = 30078, } -/** CLINK error codes */ -export enum CLINKErrorCode { - /** Request was denied (warning) */ - RequestDenied = 1, - /** Temporary failure, may retry */ +/** CLINK Offer error codes (Kind 21001) - per CLINK spec */ +export enum OfferErrorCode { + /** Invalid Offer: The requested offer ID is invalid or no longer available */ + InvalidOffer = 1, + /** Temporary Failure: The receiver is temporarily unable to process */ TemporaryFailure = 2, - /** Request has expired */ - ExpiredRequest = 3, - /** Rate limited */ - RateLimited = 4, - /** Invalid amount (out of range) */ + /** Expired or Moved: The offer has expired, been replaced, or permanently moved */ + ExpiredOrMoved = 3, + /** Unsupported Feature: The receiver doesn't support a requested feature */ + UnsupportedFeature = 4, + /** Invalid Amount: The amount specified is too big or too small */ InvalidAmount = 5, - /** Invalid request format */ +} + +/** CLINK Debit/Manage GFY codes (Kind 21002, 21003) - per CLINK spec */ +export enum GFYCode { + /** Request Denied: User or rule denied the request */ + RequestDenied = 1, + /** Temporary Failure: Wallet service issue (e.g., node offline) */ + TemporaryFailure = 2, + /** Expired Request: Request timestamp too old (e.g., >30s delta) */ + ExpiredRequest = 3, + /** Rate Limited: Requestor sending too many requests */ + RateLimited = 4, + /** Invalid Amount/Field: Amount outside acceptable range or invalid field */ + InvalidAmount = 5, + /** Invalid Request: Malformed payload, missing fields, etc. */ InvalidRequest = 6, } +/** @deprecated Use OfferErrorCode or GFYCode instead */ +export type CLINKErrorCode = OfferErrorCode | GFYCode + // ============================================================================ // Kind 21001: Offer (Noffer) // ============================================================================ @@ -72,12 +89,14 @@ export interface OfferSuccessResponse { /** Offer error response (Kind 21001) */ export interface OfferErrorResponse { - /** Error code */ - code: CLINKErrorCode + /** Error code (per CLINK Offers spec) */ + code: OfferErrorCode /** Human-readable error message */ error: string - /** Acceptable amount range (for InvalidAmount errors) */ + /** Acceptable amount range (for InvalidAmount errors, code 5) */ range?: { min: number; max: number } + /** New noffer string (for ExpiredOrMoved errors, code 3) */ + latest?: string } /** Offer response - either success or error */ @@ -92,35 +111,44 @@ export function isOfferError(response: OfferResponse): response is OfferErrorRes // Kind 21002: Debit (Ndebit) // ============================================================================ -/** Debit frequency interval */ -export type DebitInterval = 'day' | 'week' | 'month' - -/** Debit rules for recurring payments */ -export interface DebitRules { - /** Expiration rule */ - expiration?: { - /** Unix timestamp when authorization expires */ - expires_at_unix: number - } - /** Frequency rule for recurring debits */ - frequency?: { - /** Number of intervals allowed */ - number_of_intervals: number - /** Interval type */ - interval: DebitInterval - /** Maximum amount per interval in sats */ - amount: number - } +/** Debit frequency for budget requests */ +export interface DebitFrequency { + /** Number of intervals */ + number: number + /** Interval unit */ + unit: 'day' | 'week' | 'month' } -/** Debit request (Kind 21002) - sent by service to request payment authorization */ -export interface DebitRequest { - /** Amount in satoshis */ +/** Direct payment debit request (Kind 21002) */ +export interface DebitPaymentRequest { + /** Pointer ID from ndebit (optional, routes to specific account) */ + pointer?: string + /** Amount in satoshis (optional, wallet may require for rules) */ + amount_sats?: number + /** BOLT11 invoice to pay */ + bolt11: string + /** Optional description/app data */ + description?: string +} + +/** Budget authorization debit request (Kind 21002) */ +export interface DebitBudgetRequest { + /** Pointer ID from ndebit */ + pointer?: string + /** Budget amount in satoshis */ amount_sats: number - /** Optional memo/description */ - memo?: string - /** Rules for recurring debits */ - rules?: DebitRules + /** Frequency for recurring budget (omit for one-time) */ + frequency?: DebitFrequency + /** Optional description/app data */ + description?: string +} + +/** Debit request (Kind 21002) - either direct payment or budget authorization */ +export type DebitRequest = DebitPaymentRequest | DebitBudgetRequest + +/** Type guard for payment request */ +export function isDebitPaymentRequest(req: DebitRequest): req is DebitPaymentRequest { + return 'bolt11' in req } /** Debit success response (Kind 21002) */ @@ -131,14 +159,20 @@ export interface DebitSuccessResponse { preimage: string } -/** Debit failure response (Kind 21002) */ +/** Debit failure response (Kind 21002) - GFY (General Failure to Yield) */ export interface DebitFailureResponse { - /** Failure indicator (Generic Failure) */ + /** Failure indicator */ res: 'GFY' - /** Error message */ + /** GFY error code */ + code: GFYCode + /** Human-readable error message */ error: string - /** Error code */ - code: CLINKErrorCode + /** For ExpiredRequest (code 3): time delta info */ + delta?: { max_delta_ms: number; actual_delta_ms: number } + /** For RateLimited (code 4): when to retry */ + retry_after?: number + /** For InvalidAmount (code 5): acceptable range */ + range?: { min: number; max: number } } /** Debit response - either success or failure */ @@ -170,36 +204,59 @@ export interface OfferConfig { blind?: boolean } -/** Management request (Kind 21003) */ +/** Management resource types */ +export type ManagementResource = 'offer' + +/** Management request (Kind 21003) - per CLINK Manage spec */ export interface ManagementRequest { + /** Resource type being managed */ + resource: ManagementResource /** Action to perform */ action: ManagementAction - /** App user identifier/pointer */ + /** Pointer ID (optional, for multi-account routing) */ pointer?: string - /** Offer configuration (for create/update) */ - offer?: OfferConfig - /** Offer ID (for update/delete/get) */ - offer_id?: string + /** Offer object (for create/update/delete/get actions) */ + offer?: { + /** Offer ID (required for update/delete/get, generated by server for create) */ + id?: string + /** Human-readable label */ + label?: string + /** Price in satoshis */ + price_sats?: number + /** Callback URL for payment notifications */ + callback_url?: string + /** Required payer data fields */ + payer_data?: string[] + /** Fields to update (for update action only) */ + fields?: Omit + } } -/** Management success response (Kind 21003) */ +/** Management success response (Kind 21003) - per CLINK Manage spec */ export interface ManagementSuccessResponse { - /** Action that was performed */ - action: ManagementAction - /** Result data (varies by action) */ - result: unknown + /** Success indicator */ + res: 'ok' + /** Resource type */ + resource: ManagementResource + /** Result details (offer object for create/update/get, array for list, omitted for delete) */ + details?: unknown } -/** Management failure response (Kind 21003) */ +/** Management failure response (Kind 21003) - GFY */ export interface ManagementFailureResponse { /** Failure indicator */ res: 'GFY' - /** Error message */ + /** GFY error code */ + code: GFYCode + /** Human-readable error message */ error: string - /** Error code */ - code: CLINKErrorCode - /** Retry after (seconds) for rate limiting */ + /** For ExpiredRequest (code 3): time delta info */ + delta?: { max_delta_ms: number; actual_delta_ms: number } + /** For RateLimited (code 4): when to retry */ retry_after?: number + /** For InvalidField (code 5): field and range info */ + field?: string + range?: { min: number; max: number } } /** Management response */ @@ -209,7 +266,14 @@ export type ManagementResponse = ManagementSuccessResponse | ManagementFailureRe export function isManagementFailure( response: ManagementResponse ): response is ManagementFailureResponse { - return 'res' in response && response.res === 'GFY' + return response.res === 'GFY' +} + +/** Type guard for management success */ +export function isManagementSuccess( + response: ManagementResponse +): response is ManagementSuccessResponse { + return response.res === 'ok' } // ============================================================================ @@ -239,34 +303,34 @@ export type PriceType = 'fixed' | 'variable' | 'spontaneous' /** CLINK Offer (noffer) configuration */ export interface CLINKOffer { - /** Public key of the offer creator */ + /** Public key of the offer creator (receiving service) */ pubkey: string /** Relays where the offer is accessible */ relays: string[] - /** Pricing model */ + /** Opaque offer identifier (defined by receiving service) */ + offerId?: string + /** Pricing model (default: spontaneous if not specified) */ priceType: PriceType - /** Amount in satoshis (for fixed price) */ + /** Amount in satoshis (for fixed price, or display for variable) */ amountSats?: number - /** Human-readable description */ - description?: string - /** Minimum amount in sats (for variable/spontaneous) */ - minAmountSats?: number - /** Maximum amount in sats (for variable/spontaneous) */ - maxAmountSats?: number + /** Currency code (e.g., "USD") - requires priceType: 'variable' */ + currency?: string } -/** noffer TLV types */ +/** noffer TLV types (per CLINK spec) */ export enum NofferTLV { /** Public key (32 bytes) */ Pubkey = 0, /** Relay URL (variable length string) */ Relay = 1, + /** Offer identifier string (opaque, defined by receiving service) */ + OfferId = 2, /** Price type (1 byte: 0=fixed, 1=variable, 2=spontaneous) */ - PriceType = 2, + PriceType = 3, /** Amount in sats (8 bytes, big-endian) */ - Amount = 3, - /** Description (variable length string) */ - Description = 4, + Amount = 4, + /** Currency code (e.g., "USD", "EUR") - requires price type 1 (variable) */ + Currency = 5, } // ============================================================================ diff --git a/lamassu-next/packages/nostr-client/src/index.ts b/lamassu-next/packages/nostr-client/src/index.ts index 183811d..dfd0e08 100644 --- a/lamassu-next/packages/nostr-client/src/index.ts +++ b/lamassu-next/packages/nostr-client/src/index.ts @@ -66,7 +66,14 @@ export { } from './events.js' // Encryption -export { encryptContent, decryptContent, decryptJSON } from './encryption.js' +export { + encryptContent, + decryptContent, + decryptJSON, + // NIP-44 v2 (standard, for CLINK protocol) + encryptContentV2, + decryptContentV2, +} from './encryption.js' // Types export type {