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 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-01-30 13:31:42 -05:00
commit e185947259
8 changed files with 422 additions and 162 deletions

View file

@ -23,7 +23,7 @@ import {
CLINKClient, CLINKClient,
createOfferSuccess, createOfferSuccess,
createOfferError, createOfferError,
CLINKErrorCode, OfferErrorCode,
encodeNdebit, encodeNdebit,
formatNdebitUri, formatNdebitUri,
} from '@lamassu/clink' } from '@lamassu/clink'
@ -201,7 +201,7 @@ export async function initializeLightningServices(): Promise<LightningServices>
const amountSats = request.amount_sats const amountSats = request.amount_sats
if (!amountSats || amountSats <= 0) { if (!amountSats || amountSats <= 0) {
console.log('[CLINK] Invalid amount requested') 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') console.log('[CLINK] Creating invoice for', amountSats, 'sats')
@ -225,7 +225,7 @@ export async function initializeLightningServices(): Promise<LightningServices>
} catch (error) { } catch (error) {
console.error('[CLINK] Failed to create invoice:', error) console.error('[CLINK] Failed to create invoice:', error)
return createOfferError( return createOfferError(
CLINKErrorCode.TemporaryFailure, OfferErrorCode.TemporaryFailure,
error instanceof Error ? error.message : 'Failed to create invoice' error instanceof Error ? error.message : 'Failed to create invoice'
) )
} }
@ -304,9 +304,7 @@ function createATMServices(
const noffer = clink.createOffer({ const noffer = clink.createOffer({
priceType: 'fixed', priceType: 'fixed',
amountSats: context.satsAmount, amountSats: context.satsAmount,
description: `Lamassu ATM - ${context.satsAmount} sats`, offerId: `cashout-${Date.now()}`,
minAmountSats: context.satsAmount,
maxAmountSats: context.satsAmount,
}) })
console.log('[ATM Service] Generated noffer:', noffer.slice(0, 32) + '...') console.log('[ATM Service] Generated noffer:', noffer.slice(0, 32) + '...')
@ -437,7 +435,7 @@ function createATMServices(
// Create a spontaneous noffer (any amount accepted) // Create a spontaneous noffer (any amount accepted)
const noffer = clink.createOffer({ const noffer = clink.createOffer({
priceType: 'spontaneous', priceType: 'spontaneous',
description: 'Lamassu ATM - Cash Out', offerId: 'cashout',
}) })
console.log('[ATM Service] Generated noffer:', noffer.slice(0, 32) + '...') console.log('[ATM Service] Generated noffer:', noffer.slice(0, 32) + '...')

View file

@ -264,11 +264,16 @@ export const useAtmStore = defineStore('atm', () => {
/** /**
* Request a CLINK Debit from customer's wallet (for cash-in flow) * Request a CLINK Debit from customer's wallet (for cash-in flow)
* Customer provides their Nostr pubkey, we request payment authorization * 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<boolean> { async function requestDebit(customerPubkey: string, amountSats?: number): Promise<boolean> {
if (!clinkClient.value) { if (!clinkClient.value || !lightningPub.value) {
paymentError.value = 'CLINK client not initialized' paymentError.value = 'Lightning services not initialized'
console.error('[ATM] Cannot request debit: CLINK client not initialized') console.error('[ATM] Cannot request debit: Lightning services not initialized')
return false 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] Requesting CLINK Debit from', customerPubkey.slice(0, 16) + '...')
console.log('[ATM] Amount:', amount, 'sats') console.log('[ATM] Amount:', amount, 'sats')
const response = await clinkClient.value.requestDebit(customerPubkey, amount, { // Step 1: Generate an invoice for the amount
memo: `Lamassu ATM - Buy ${amount} sats`, 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) { if (response.res === 'ok' && response.preimage) {
console.log( console.log(
'[ATM] CLINK Debit successful! Preimage:', '[ATM] CLINK Debit successful! Preimage:',

View file

@ -3,11 +3,12 @@ import { encodeNoffer, decodeNoffer, isValidNoffer } from '../noffer.js'
import type { CLINKOffer } from '../types.js' import type { CLINKOffer } from '../types.js'
describe('noffer encoding/decoding', () => { describe('noffer encoding/decoding', () => {
// Basic spontaneous payment offer (per CLINK spec)
const sampleOffer: CLINKOffer = { const sampleOffer: CLINKOffer = {
pubkey: 'a'.repeat(64), // 64 hex chars pubkey: 'a'.repeat(64), // 64 hex chars
relays: ['wss://relay.example.com', 'wss://relay2.example.com'], relays: ['wss://relay.example.com', 'wss://relay2.example.com'],
priceType: 'variable', priceType: 'spontaneous',
description: 'Test offer', offerId: 'test-offer-123',
} }
describe('encodeNoffer', () => { describe('encodeNoffer', () => {
@ -18,7 +19,7 @@ describe('noffer encoding/decoding', () => {
expect(typeof noffer).toBe('string') expect(typeof noffer).toBe('string')
}) })
it('should encode an offer with amount', () => { it('should encode an offer with amount (fixed price)', () => {
const offer: CLINKOffer = { const offer: CLINKOffer = {
...sampleOffer, ...sampleOffer,
priceType: 'fixed', priceType: 'fixed',
@ -28,17 +29,29 @@ describe('noffer encoding/decoding', () => {
const noffer = encodeNoffer(offer) const noffer = encodeNoffer(offer)
expect(noffer).toMatch(/^noffer1/) 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', () => { describe('decodeNoffer', () => {
it('should round-trip encode/decode', () => { it('should round-trip encode/decode spontaneous offer', () => {
const noffer = encodeNoffer(sampleOffer) const noffer = encodeNoffer(sampleOffer)
const decoded = decodeNoffer(noffer) const decoded = decodeNoffer(noffer)
expect(decoded.pubkey).toBe(sampleOffer.pubkey) expect(decoded.pubkey).toBe(sampleOffer.pubkey)
expect(decoded.relays).toEqual(sampleOffer.relays) expect(decoded.relays).toEqual(sampleOffer.relays)
expect(decoded.priceType).toBe(sampleOffer.priceType) 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', () => { it('should decode fixed price offer correctly', () => {
@ -55,9 +68,47 @@ describe('noffer encoding/decoding', () => {
expect(decoded.amountSats).toBe(50000) 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', () => { it('should throw on invalid prefix', () => {
expect(() => decodeNoffer('invalid1abc')).toThrow() expect(() => decodeNoffer('invalid1abc')).toThrow()
}) })
it('should throw on missing pubkey', () => {
// This would require a malformed noffer string
expect(() => decodeNoffer('noffer1qqqqqq')).toThrow()
})
}) })
describe('isValidNoffer', () => { describe('isValidNoffer', () => {

View file

@ -13,10 +13,37 @@
import type { Event, UnsignedEvent } from 'nostr-tools' import type { Event, UnsignedEvent } from 'nostr-tools'
import { finalizeEvent } from 'nostr-tools' import { finalizeEvent } from 'nostr-tools'
import type { MachineIdentity, NostrClient } from '@lamassu/nostr-client' 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<T = unknown>(
identity: MachineIdentity,
senderPubkey: string,
ciphertext: string
): T {
const plaintext = decryptContentV2(identity, senderPubkey, ciphertext)
return JSON.parse(plaintext) as T
}
import { import {
CLINKEventKind, CLINKEventKind,
CLINKErrorCode, OfferErrorCode,
GFYCode,
type CLINKOffer, type CLINKOffer,
type OfferRequest, type OfferRequest,
type OfferResponse, type OfferResponse,
@ -98,22 +125,25 @@ export class CLINKClient {
/** /**
* Create a noffer (static payment code) for this machine * 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: { createOffer(options: {
priceType: CLINKOffer['priceType'] priceType: CLINKOffer['priceType']
offerId?: string
amountSats?: number amountSats?: number
description?: string currency?: string
minAmountSats?: number
maxAmountSats?: number
}): string { }): string {
const offer: CLINKOffer = { const offer: CLINKOffer = {
pubkey: this.identity.publicKey, pubkey: this.identity.publicKey,
relays: this.relays, relays: this.relays,
priceType: options.priceType, priceType: options.priceType,
offerId: options.offerId,
amountSats: options.amountSats, amountSats: options.amountSats,
description: options.description, currency: options.currency,
minAmountSats: options.minAmountSats,
maxAmountSats: options.maxAmountSats,
} }
return encodeNoffer(offer) return encodeNoffer(offer)
@ -191,19 +221,19 @@ export class CLINKClient {
const offer = decodeNoffer(noffer) const offer = decodeNoffer(noffer)
const request: OfferRequest = { const request: OfferRequest = {
offer: noffer, offer: offer.offerId ?? noffer, // Use offer ID if available, otherwise full noffer
amount_sats: amountSats, amount_sats: amountSats,
payer_data: options?.payerData, payer_data: options?.payerData,
description: options?.description, description: options?.description,
expires_in_seconds: options?.expiresInSeconds, 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({ const event = this.createSignedEvent({
kind: CLINKEventKind.Offer, kind: CLINKEventKind.Offer,
content, content,
tags: [['p', offer.pubkey]], tags: [['p', offer.pubkey], CLINK_VERSION_TAG],
created_at: Math.floor(Date.now() / 1000), 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, targetPubkey: string,
amountSats: number, bolt11: string,
options?: { options?: {
memo?: string pointer?: string
rules?: DebitRequest['rules'] amountSats?: number
description?: string
} }
): Promise<DebitResponse> { ): Promise<DebitResponse> {
const request: DebitRequest = { const request: DebitRequest = {
amount_sats: amountSats, bolt11,
memo: options?.memo, pointer: options?.pointer,
rules: options?.rules, 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({ const event = this.createSignedEvent({
kind: CLINKEventKind.Debit, kind: CLINKEventKind.Debit,
content, 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<DebitResponse>(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<DebitResponse> {
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), created_at: Math.floor(Date.now() / 1000),
}) })
@ -251,12 +318,12 @@ export class CLINKClient {
targetPubkey: string, targetPubkey: string,
request: ManagementRequest request: ManagementRequest
): Promise<ManagementResponse> { ): Promise<ManagementResponse> {
const content = encryptContent(this.identity, targetPubkey, request) const content = encryptCLINK(this.identity, targetPubkey, request)
const event = this.createSignedEvent({ const event = this.createSignedEvent({
kind: CLINKEventKind.Manage, kind: CLINKEventKind.Manage,
content, content,
tags: [['p', targetPubkey]], tags: [['p', targetPubkey], CLINK_VERSION_TAG],
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
}) })
@ -314,21 +381,25 @@ export class CLINKClient {
private async handleOfferEvent(event: Event): Promise<void> { private async handleOfferEvent(event: Event): Promise<void> {
if (!this.offerHandler) return if (!this.offerHandler) return
const request = decryptJSON<OfferRequest>(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<OfferRequest>(this.identity, event.pubkey, event.content)
const response = await this.offerHandler(request, event.pubkey) const response = await this.offerHandler(request, event.pubkey)
if (!response) return if (!response) return
// Send encrypted response // Send encrypted response with clink_version tag
const content = encryptContent(this.identity, event.pubkey, response) const content = encryptCLINK(this.identity, event.pubkey, response)
const responseEvent = this.createSignedEvent({ const responseEvent = this.createSignedEvent({
kind: CLINKEventKind.Offer, kind: CLINKEventKind.Offer,
content, content,
tags: [ tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
['p', event.pubkey],
['e', event.id],
],
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
}) })
@ -341,20 +412,24 @@ export class CLINKClient {
private async handleDebitEvent(event: Event): Promise<void> { private async handleDebitEvent(event: Event): Promise<void> {
if (!this.debitHandler) return if (!this.debitHandler) return
const request = decryptJSON<DebitRequest>(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<DebitRequest>(this.identity, event.pubkey, event.content)
const response = await this.debitHandler(request, event.pubkey) const response = await this.debitHandler(request, event.pubkey)
// Send encrypted response // Send encrypted response with clink_version tag
const content = encryptContent(this.identity, event.pubkey, response) const content = encryptCLINK(this.identity, event.pubkey, response)
const responseEvent = this.createSignedEvent({ const responseEvent = this.createSignedEvent({
kind: CLINKEventKind.Debit, kind: CLINKEventKind.Debit,
content, content,
tags: [ tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
['p', event.pubkey],
['e', event.id],
],
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
}) })
@ -371,23 +446,27 @@ export class CLINKClient {
return 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 if (!this.managementHandler) return
const request = decryptJSON<ManagementRequest>(this.identity, event.pubkey, event.content) const request = decryptCLINKJSON<ManagementRequest>(this.identity, event.pubkey, event.content)
const response = await this.managementHandler(request, event.pubkey) const response = await this.managementHandler(request, event.pubkey)
if (!response) return if (!response) return
// Send encrypted response // Send encrypted response with clink_version tag
const content = encryptContent(this.identity, event.pubkey, response) const content = encryptCLINK(this.identity, event.pubkey, response)
const responseEvent = this.createSignedEvent({ const responseEvent = this.createSignedEvent({
kind: CLINKEventKind.Manage, kind: CLINKEventKind.Manage,
content, content,
tags: [ tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
['p', event.pubkey],
['e', event.id],
],
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
}) })
@ -416,10 +495,17 @@ export class CLINKClient {
], ],
{ {
onEvent: (event) => { 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) clearTimeout(timeout)
this.nostrClient.unsubscribe(subId) this.nostrClient.unsubscribe(subId)
try { try {
const response = decryptJSON<T>(this.identity, fromPubkey, event.content) const response = decryptCLINKJSON<T>(this.identity, fromPubkey, event.content)
resolve(response) resolve(response)
} catch (e) { } catch (e) {
reject(e) reject(e)
@ -454,11 +540,11 @@ export function createOfferSuccess(bolt11: string): OfferSuccessResponse {
* Create an offer error response * Create an offer error response
*/ */
export function createOfferError( export function createOfferError(
code: CLINKErrorCode, code: OfferErrorCode,
error: string, error: string,
range?: { min: number; max: number } options?: { range?: { min: number; max: number }; latest?: string }
): OfferErrorResponse { ): 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 { export function createDebitFailure(
return { res: 'GFY', code, error } 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 // Re-export type guards

View file

@ -24,10 +24,10 @@
* relays: ['wss://relay.example.com'], * relays: ['wss://relay.example.com'],
* }) * })
* *
* // Create a noffer for the ATM * // Create a noffer for the ATM (spontaneous payment)
* const noffer = clink.createOffer({ * const noffer = clink.createOffer({
* priceType: 'variable', * priceType: 'spontaneous',
* description: 'Buy Bitcoin at ATM', * offerId: 'atm-cashout', // Opaque identifier for routing
* }) * })
* *
* // Handle incoming offer requests * // Handle incoming offer requests
@ -44,7 +44,10 @@
export { export {
// Event kinds // Event kinds
CLINKEventKind, CLINKEventKind,
CLINKErrorCode, // Error codes (per CLINK spec)
OfferErrorCode,
GFYCode,
type CLINKErrorCode, // deprecated alias
// Offer types (Kind 21001) // Offer types (Kind 21001)
type OfferRequest, type OfferRequest,
type OfferResponse, type OfferResponse,
@ -52,18 +55,19 @@ export {
type OfferErrorResponse, type OfferErrorResponse,
// Debit types (Kind 21002) // Debit types (Kind 21002)
type DebitRequest, type DebitRequest,
type DebitPaymentRequest,
type DebitBudgetRequest,
type DebitFrequency,
type DebitResponse, type DebitResponse,
type DebitSuccessResponse, type DebitSuccessResponse,
type DebitFailureResponse, type DebitFailureResponse,
type DebitRules,
type DebitInterval,
// Management types (Kind 21003) // Management types (Kind 21003)
type ManagementRequest, type ManagementRequest,
type ManagementResponse, type ManagementResponse,
type ManagementSuccessResponse, type ManagementSuccessResponse,
type ManagementFailureResponse, type ManagementFailureResponse,
type ManagementAction, type ManagementAction,
type OfferConfig, type ManagementResource,
// Beacon types (Kind 30078) // Beacon types (Kind 30078)
type ServiceBeacon, type ServiceBeacon,
// Noffer types // Noffer types
@ -79,7 +83,9 @@ export {
// Type guards // Type guards
isOfferError, isOfferError,
isDebitFailure, isDebitFailure,
isDebitPaymentRequest,
isManagementFailure, isManagementFailure,
isManagementSuccess,
} from './types.js' } from './types.js'
// Noffer encoding/decoding // Noffer encoding/decoding

View file

@ -16,36 +16,42 @@ const NOFFER_PREFIX = 'noffer'
const BECH32_LIMIT = 2000 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 { export function encodeNoffer(offer: CLINKOffer): string {
const tlvData: number[] = [] 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 hexPubkey = npubToHex(offer.pubkey)
const pubkeyBytes = hexToBytes(hexPubkey) const pubkeyBytes = hexToBytes(hexPubkey)
writeTLV(tlvData, NofferTLV.Pubkey, pubkeyBytes) 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) { for (const relay of offer.relays) {
const relayBytes = new TextEncoder().encode(relay) const relayBytes = new TextEncoder().encode(relay)
writeTLV(tlvData, NofferTLV.Relay, relayBytes) 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) const priceTypeByte = encodePriceType(offer.priceType)
writeTLV(tlvData, NofferTLV.PriceType, [priceTypeByte]) writeTLV(tlvData, NofferTLV.PriceType, [priceTypeByte])
// Amount (TLV 3) - if present (in satoshis) // TLV 4: Amount in sats - if present
if (offer.amountSats !== undefined) { if (offer.amountSats !== undefined) {
const amountBytes = encodeUint64BE(offer.amountSats) const amountBytes = encodeUint64BE(offer.amountSats)
writeTLV(tlvData, NofferTLV.Amount, amountBytes) writeTLV(tlvData, NofferTLV.Amount, amountBytes)
} }
// Description (TLV 4) - if present // TLV 5: Currency code - if present (requires variable pricing)
if (offer.description) { if (offer.currency) {
const descBytes = new TextEncoder().encode(offer.description) const currencyBytes = new TextEncoder().encode(offer.currency)
writeTLV(tlvData, NofferTLV.Description, descBytes) writeTLV(tlvData, NofferTLV.Currency, currencyBytes)
} }
// Convert to Uint8Array and bech32 encode // 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 { export function decodeNoffer(noffer: string): CLINKOffer {
// Decode bech32 - cast to expected type (contains "1" separator) // Decode bech32 - cast to expected type (contains "1" separator)
@ -72,9 +78,10 @@ export function decodeNoffer(noffer: string): CLINKOffer {
// Parse TLV entries // Parse TLV entries
let pubkey: string | undefined let pubkey: string | undefined
const relays: string[] = [] const relays: string[] = []
let priceType: PriceType = 'spontaneous' let offerId: string | undefined
let priceType: PriceType = 'spontaneous' // Default per spec
let amountSats: number | undefined let amountSats: number | undefined
let description: string | undefined let currency: string | undefined
let offset = 0 let offset = 0
while (offset < data.length) { while (offset < data.length) {
@ -100,16 +107,19 @@ export function decodeNoffer(noffer: string): CLINKOffer {
case NofferTLV.Relay: case NofferTLV.Relay:
relays.push(new TextDecoder().decode(value)) relays.push(new TextDecoder().decode(value))
break break
case NofferTLV.OfferId:
offerId = new TextDecoder().decode(value)
break
case NofferTLV.PriceType: case NofferTLV.PriceType:
priceType = decodePriceType(value[0] ?? 2) priceType = decodePriceType(value[0] ?? 2)
break break
case NofferTLV.Amount: case NofferTLV.Amount:
amountSats = decodeUint64BE(value) amountSats = decodeUint64BE(value)
break break
case NofferTLV.Description: case NofferTLV.Currency:
description = new TextDecoder().decode(value) currency = new TextDecoder().decode(value)
break 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 { return {
pubkey, pubkey,
relays, relays,
offerId,
priceType, priceType,
amountSats, amountSats,
description, currency,
} }
} }

View file

@ -28,22 +28,39 @@ export enum CLINKEventKind {
Beacon = 30078, Beacon = 30078,
} }
/** CLINK error codes */ /** CLINK Offer error codes (Kind 21001) - per CLINK spec */
export enum CLINKErrorCode { export enum OfferErrorCode {
/** Request was denied (warning) */ /** Invalid Offer: The requested offer ID is invalid or no longer available */
RequestDenied = 1, InvalidOffer = 1,
/** Temporary failure, may retry */ /** Temporary Failure: The receiver is temporarily unable to process */
TemporaryFailure = 2, TemporaryFailure = 2,
/** Request has expired */ /** Expired or Moved: The offer has expired, been replaced, or permanently moved */
ExpiredRequest = 3, ExpiredOrMoved = 3,
/** Rate limited */ /** Unsupported Feature: The receiver doesn't support a requested feature */
RateLimited = 4, UnsupportedFeature = 4,
/** Invalid amount (out of range) */ /** Invalid Amount: The amount specified is too big or too small */
InvalidAmount = 5, 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, InvalidRequest = 6,
} }
/** @deprecated Use OfferErrorCode or GFYCode instead */
export type CLINKErrorCode = OfferErrorCode | GFYCode
// ============================================================================ // ============================================================================
// Kind 21001: Offer (Noffer) // Kind 21001: Offer (Noffer)
// ============================================================================ // ============================================================================
@ -72,12 +89,14 @@ export interface OfferSuccessResponse {
/** Offer error response (Kind 21001) */ /** Offer error response (Kind 21001) */
export interface OfferErrorResponse { export interface OfferErrorResponse {
/** Error code */ /** Error code (per CLINK Offers spec) */
code: CLINKErrorCode code: OfferErrorCode
/** Human-readable error message */ /** Human-readable error message */
error: string error: string
/** Acceptable amount range (for InvalidAmount errors) */ /** Acceptable amount range (for InvalidAmount errors, code 5) */
range?: { min: number; max: number } range?: { min: number; max: number }
/** New noffer string (for ExpiredOrMoved errors, code 3) */
latest?: string
} }
/** Offer response - either success or error */ /** Offer response - either success or error */
@ -92,35 +111,44 @@ export function isOfferError(response: OfferResponse): response is OfferErrorRes
// Kind 21002: Debit (Ndebit) // Kind 21002: Debit (Ndebit)
// ============================================================================ // ============================================================================
/** Debit frequency interval */ /** Debit frequency for budget requests */
export type DebitInterval = 'day' | 'week' | 'month' export interface DebitFrequency {
/** Number of intervals */
/** Debit rules for recurring payments */ number: number
export interface DebitRules { /** Interval unit */
/** Expiration rule */ unit: 'day' | 'week' | 'month'
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 request (Kind 21002) - sent by service to request payment authorization */ /** Direct payment debit request (Kind 21002) */
export interface DebitRequest { export interface DebitPaymentRequest {
/** Amount in satoshis */ /** 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 amount_sats: number
/** Optional memo/description */ /** Frequency for recurring budget (omit for one-time) */
memo?: string frequency?: DebitFrequency
/** Rules for recurring debits */ /** Optional description/app data */
rules?: DebitRules 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) */ /** Debit success response (Kind 21002) */
@ -131,14 +159,20 @@ export interface DebitSuccessResponse {
preimage: string preimage: string
} }
/** Debit failure response (Kind 21002) */ /** Debit failure response (Kind 21002) - GFY (General Failure to Yield) */
export interface DebitFailureResponse { export interface DebitFailureResponse {
/** Failure indicator (Generic Failure) */ /** Failure indicator */
res: 'GFY' res: 'GFY'
/** Error message */ /** GFY error code */
code: GFYCode
/** Human-readable error message */
error: string error: string
/** Error code */ /** For ExpiredRequest (code 3): time delta info */
code: CLINKErrorCode 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 */ /** Debit response - either success or failure */
@ -170,36 +204,59 @@ export interface OfferConfig {
blind?: boolean blind?: boolean
} }
/** Management request (Kind 21003) */ /** Management resource types */
export type ManagementResource = 'offer'
/** Management request (Kind 21003) - per CLINK Manage spec */
export interface ManagementRequest { export interface ManagementRequest {
/** Resource type being managed */
resource: ManagementResource
/** Action to perform */ /** Action to perform */
action: ManagementAction action: ManagementAction
/** App user identifier/pointer */ /** Pointer ID (optional, for multi-account routing) */
pointer?: string pointer?: string
/** Offer configuration (for create/update) */ /** Offer object (for create/update/delete/get actions) */
offer?: OfferConfig offer?: {
/** Offer ID (for update/delete/get) */ /** Offer ID (required for update/delete/get, generated by server for create) */
offer_id?: string 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<OfferConfig, 'id'>
}
} }
/** Management success response (Kind 21003) */ /** Management success response (Kind 21003) - per CLINK Manage spec */
export interface ManagementSuccessResponse { export interface ManagementSuccessResponse {
/** Action that was performed */ /** Success indicator */
action: ManagementAction res: 'ok'
/** Result data (varies by action) */ /** Resource type */
result: unknown 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 { export interface ManagementFailureResponse {
/** Failure indicator */ /** Failure indicator */
res: 'GFY' res: 'GFY'
/** Error message */ /** GFY error code */
code: GFYCode
/** Human-readable error message */
error: string error: string
/** Error code */ /** For ExpiredRequest (code 3): time delta info */
code: CLINKErrorCode delta?: { max_delta_ms: number; actual_delta_ms: number }
/** Retry after (seconds) for rate limiting */ /** For RateLimited (code 4): when to retry */
retry_after?: number retry_after?: number
/** For InvalidField (code 5): field and range info */
field?: string
range?: { min: number; max: number }
} }
/** Management response */ /** Management response */
@ -209,7 +266,14 @@ export type ManagementResponse = ManagementSuccessResponse | ManagementFailureRe
export function isManagementFailure( export function isManagementFailure(
response: ManagementResponse response: ManagementResponse
): response is ManagementFailureResponse { ): 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 */ /** CLINK Offer (noffer) configuration */
export interface CLINKOffer { export interface CLINKOffer {
/** Public key of the offer creator */ /** Public key of the offer creator (receiving service) */
pubkey: string pubkey: string
/** Relays where the offer is accessible */ /** Relays where the offer is accessible */
relays: string[] relays: string[]
/** Pricing model */ /** Opaque offer identifier (defined by receiving service) */
offerId?: string
/** Pricing model (default: spontaneous if not specified) */
priceType: PriceType priceType: PriceType
/** Amount in satoshis (for fixed price) */ /** Amount in satoshis (for fixed price, or display for variable) */
amountSats?: number amountSats?: number
/** Human-readable description */ /** Currency code (e.g., "USD") - requires priceType: 'variable' */
description?: string currency?: string
/** Minimum amount in sats (for variable/spontaneous) */
minAmountSats?: number
/** Maximum amount in sats (for variable/spontaneous) */
maxAmountSats?: number
} }
/** noffer TLV types */ /** noffer TLV types (per CLINK spec) */
export enum NofferTLV { export enum NofferTLV {
/** Public key (32 bytes) */ /** Public key (32 bytes) */
Pubkey = 0, Pubkey = 0,
/** Relay URL (variable length string) */ /** Relay URL (variable length string) */
Relay = 1, Relay = 1,
/** Offer identifier string (opaque, defined by receiving service) */
OfferId = 2,
/** Price type (1 byte: 0=fixed, 1=variable, 2=spontaneous) */ /** Price type (1 byte: 0=fixed, 1=variable, 2=spontaneous) */
PriceType = 2, PriceType = 3,
/** Amount in sats (8 bytes, big-endian) */ /** Amount in sats (8 bytes, big-endian) */
Amount = 3, Amount = 4,
/** Description (variable length string) */ /** Currency code (e.g., "USD", "EUR") - requires price type 1 (variable) */
Description = 4, Currency = 5,
} }
// ============================================================================ // ============================================================================

View file

@ -66,7 +66,14 @@ export {
} from './events.js' } from './events.js'
// Encryption // 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 // Types
export type { export type {