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:
parent
e816f50363
commit
e185947259
8 changed files with 422 additions and 162 deletions
|
|
@ -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) + '...')
|
||||
|
|
|
|||
|
|
@ -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:',
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue