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,
createOfferSuccess,
createOfferError,
CLINKErrorCode,
OfferErrorCode,
encodeNdebit,
formatNdebitUri,
} from '@lamassu/clink'
@ -201,7 +201,7 @@ export async function initializeLightningServices(): Promise<LightningServices>
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<LightningServices>
} 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) + '...')

View file

@ -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<boolean> {
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:',

View file

@ -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', () => {

View file

@ -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<T = unknown>(
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<DebitResponse> {
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<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),
})
@ -251,12 +318,12 @@ export class CLINKClient {
targetPubkey: string,
request: ManagementRequest
): Promise<ManagementResponse> {
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<void> {
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)
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<void> {
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)
// 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<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)
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<T>(this.identity, fromPubkey, event.content)
const response = decryptCLINKJSON<T>(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

View file

@ -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

View file

@ -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,
}
}

View file

@ -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<OfferConfig, 'id'>
}
}
/** 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,
}
// ============================================================================

View file

@ -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 {