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,
|
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) + '...')
|
||||||
|
|
|
||||||
|
|
@ -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:',
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue