feat(docker): add dev.sh with auto-funding and ATM app setup
- Add dev.sh script for managing regtest development environment - Implement cmd_fund to fund ATM app owner via Lightning.Pub API - Add --fund flag to cmd_up for automatic funding on startup - Update setup_atm_app to write VITE_APP_ID to machine .env - Fix Electron IPC to pass appId and extensionApiUrl to renderer - Restructure repo from nested lamassu-next/ to root The dev.sh script now supports: - ./dev.sh up --fund # Start regtest and auto-fund ATM - ./dev.sh fund # Fund existing ATM app - ./dev.sh status # Show environment status - ./dev.sh reset # Clean restart Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
30a2eb2199
commit
c98f126ba7
180 changed files with 2695 additions and 9587 deletions
32
packages/clink/package.json
Normal file
32
packages/clink/package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "@lamassu/clink",
|
||||
"version": "0.1.0",
|
||||
"description": "CLINK protocol implementation for Nostr-native Lightning payments",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lamassu/nostr-client": "workspace:*",
|
||||
"@scure/base": "^1.2.0",
|
||||
"nostr-tools": "^2.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
125
packages/clink/src/__tests__/noffer.test.ts
Normal file
125
packages/clink/src/__tests__/noffer.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
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: 'spontaneous',
|
||||
offerId: 'test-offer-123',
|
||||
}
|
||||
|
||||
describe('encodeNoffer', () => {
|
||||
it('should encode a basic offer', () => {
|
||||
const noffer = encodeNoffer(sampleOffer)
|
||||
|
||||
expect(noffer).toMatch(/^noffer1/)
|
||||
expect(typeof noffer).toBe('string')
|
||||
})
|
||||
|
||||
it('should encode an offer with amount (fixed price)', () => {
|
||||
const offer: CLINKOffer = {
|
||||
...sampleOffer,
|
||||
priceType: 'fixed',
|
||||
amountSats: 1000,
|
||||
}
|
||||
|
||||
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 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.offerId).toBe(sampleOffer.offerId)
|
||||
})
|
||||
|
||||
it('should decode fixed price offer correctly', () => {
|
||||
const offer: CLINKOffer = {
|
||||
...sampleOffer,
|
||||
priceType: 'fixed',
|
||||
amountSats: 50000,
|
||||
}
|
||||
|
||||
const noffer = encodeNoffer(offer)
|
||||
const decoded = decodeNoffer(noffer)
|
||||
|
||||
expect(decoded.priceType).toBe('fixed')
|
||||
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', () => {
|
||||
it('should return true for valid noffer', () => {
|
||||
const noffer = encodeNoffer(sampleOffer)
|
||||
expect(isValidNoffer(noffer)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for invalid string', () => {
|
||||
expect(isValidNoffer('invalid')).toBe(false)
|
||||
expect(isValidNoffer('noffer1invalid')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
580
packages/clink/src/client.ts
Normal file
580
packages/clink/src/client.ts
Normal file
|
|
@ -0,0 +1,580 @@
|
|||
/**
|
||||
* CLINK Client
|
||||
*
|
||||
* Handles CLINK protocol communication for ATM payments:
|
||||
* - Creating and serving offers (noffer)
|
||||
* - Handling offer requests and generating invoices
|
||||
* - Processing debit requests
|
||||
* - Receiving management commands
|
||||
*
|
||||
* Uses NIP-44v2 encryption for all messages.
|
||||
*/
|
||||
|
||||
import type { Event, UnsignedEvent } from 'nostr-tools'
|
||||
import { finalizeEvent } from 'nostr-tools'
|
||||
import type { MachineIdentity, NostrClient } 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,
|
||||
OfferErrorCode,
|
||||
GFYCode,
|
||||
type CLINKOffer,
|
||||
type OfferRequest,
|
||||
type OfferResponse,
|
||||
type OfferSuccessResponse,
|
||||
type OfferErrorResponse,
|
||||
type DebitRequest,
|
||||
type DebitResponse,
|
||||
type DebitSuccessResponse,
|
||||
type DebitFailureResponse,
|
||||
type ManagementRequest,
|
||||
type ManagementResponse,
|
||||
type ServiceBeacon,
|
||||
type GenerateInvoice,
|
||||
type PayInvoice,
|
||||
isOfferError,
|
||||
isDebitFailure,
|
||||
} from './types.js'
|
||||
import { encodeNoffer, decodeNoffer } from './noffer.js'
|
||||
|
||||
/** CLINK client options */
|
||||
export interface CLINKClientOptions {
|
||||
/** Nostr client for communication */
|
||||
nostrClient: NostrClient
|
||||
/** Machine identity */
|
||||
identity: MachineIdentity
|
||||
/** Operator pubkey for management commands */
|
||||
operatorPubkey: string
|
||||
/** Relays to use for offers */
|
||||
relays: string[]
|
||||
/** Invoice generator function */
|
||||
generateInvoice?: GenerateInvoice
|
||||
/** Payment function */
|
||||
payInvoice?: PayInvoice
|
||||
}
|
||||
|
||||
/** Offer request handler - returns invoice or error */
|
||||
export type OfferRequestHandler = (
|
||||
request: OfferRequest,
|
||||
senderPubkey: string
|
||||
) => Promise<OfferResponse | null>
|
||||
|
||||
/** Debit request handler - returns success/failure */
|
||||
export type DebitRequestHandler = (
|
||||
request: DebitRequest,
|
||||
senderPubkey: string
|
||||
) => Promise<DebitResponse>
|
||||
|
||||
/** Management request handler */
|
||||
export type ManagementHandler = (
|
||||
request: ManagementRequest,
|
||||
senderPubkey: string
|
||||
) => Promise<ManagementResponse | null>
|
||||
|
||||
/**
|
||||
* CLINK protocol client for ATM payments
|
||||
*/
|
||||
export class CLINKClient {
|
||||
private nostrClient: NostrClient
|
||||
private identity: MachineIdentity
|
||||
private operatorPubkey: string
|
||||
private relays: string[]
|
||||
private generateInvoice?: GenerateInvoice
|
||||
private payInvoice?: PayInvoice
|
||||
|
||||
private offerHandler?: OfferRequestHandler
|
||||
private debitHandler?: DebitRequestHandler
|
||||
private managementHandler?: ManagementHandler
|
||||
|
||||
private subscriptionId?: string
|
||||
|
||||
constructor(options: CLINKClientOptions) {
|
||||
this.nostrClient = options.nostrClient
|
||||
this.identity = options.identity
|
||||
this.operatorPubkey = options.operatorPubkey
|
||||
this.relays = options.relays
|
||||
this.generateInvoice = options.generateInvoice
|
||||
this.payInvoice = options.payInvoice
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
currency?: string
|
||||
}): string {
|
||||
const offer: CLINKOffer = {
|
||||
pubkey: this.identity.publicKey,
|
||||
relays: this.relays,
|
||||
priceType: options.priceType,
|
||||
offerId: options.offerId,
|
||||
amountSats: options.amountSats,
|
||||
currency: options.currency,
|
||||
}
|
||||
|
||||
return encodeNoffer(offer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a noffer string
|
||||
*/
|
||||
decodeOffer(noffer: string): CLINKOffer {
|
||||
return decodeNoffer(noffer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set handler for incoming offer requests (Kind 21001)
|
||||
*/
|
||||
onOfferRequest(handler: OfferRequestHandler): void {
|
||||
this.offerHandler = handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Set handler for incoming debit requests (Kind 21002)
|
||||
*/
|
||||
onDebitRequest(handler: DebitRequestHandler): void {
|
||||
this.debitHandler = handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Set handler for management commands (Kind 21003)
|
||||
*/
|
||||
onManagement(handler: ManagementHandler): void {
|
||||
this.managementHandler = handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Start listening for CLINK events
|
||||
*/
|
||||
startListening(): void {
|
||||
if (this.subscriptionId) return
|
||||
|
||||
this.subscriptionId = this.nostrClient.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [CLINKEventKind.Offer, CLINKEventKind.Debit, CLINKEventKind.Manage],
|
||||
'#p': [this.identity.publicKey],
|
||||
},
|
||||
],
|
||||
{
|
||||
onEvent: (event) => this.handleEvent(event),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop listening for CLINK events
|
||||
*/
|
||||
stopListening(): void {
|
||||
if (this.subscriptionId) {
|
||||
this.nostrClient.unsubscribe(this.subscriptionId)
|
||||
this.subscriptionId = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request an invoice from a noffer (Kind 21001)
|
||||
*/
|
||||
async requestOffer(
|
||||
noffer: string,
|
||||
amountSats: number,
|
||||
options?: {
|
||||
payerData?: Record<string, string>
|
||||
description?: string
|
||||
expiresInSeconds?: number
|
||||
}
|
||||
): Promise<OfferResponse> {
|
||||
const offer = decodeNoffer(noffer)
|
||||
|
||||
const request: OfferRequest = {
|
||||
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 = encryptCLINK(this.identity, offer.pubkey, request)
|
||||
|
||||
const event = this.createSignedEvent({
|
||||
kind: CLINKEventKind.Offer,
|
||||
content,
|
||||
tags: [['p', offer.pubkey], CLINK_VERSION_TAG],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
|
||||
await this.nostrClient.publish(event)
|
||||
|
||||
// Wait for response
|
||||
return this.waitForResponse<OfferResponse>(offer.pubkey, CLINKEventKind.Offer, event.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a debit payment request (Kind 21002)
|
||||
* User's wallet will pay the provided invoice
|
||||
*/
|
||||
async requestDebitPayment(
|
||||
targetPubkey: string,
|
||||
bolt11: string,
|
||||
options?: {
|
||||
pointer?: string
|
||||
amountSats?: number
|
||||
description?: string
|
||||
}
|
||||
): Promise<DebitResponse> {
|
||||
const request: DebitRequest = {
|
||||
bolt11,
|
||||
pointer: options?.pointer,
|
||||
amount_sats: options?.amountSats,
|
||||
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),
|
||||
})
|
||||
|
||||
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),
|
||||
})
|
||||
|
||||
await this.nostrClient.publish(event)
|
||||
|
||||
return this.waitForResponse<DebitResponse>(targetPubkey, CLINKEventKind.Debit, event.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a management request (Kind 21003)
|
||||
*/
|
||||
async sendManagementRequest(
|
||||
targetPubkey: string,
|
||||
request: ManagementRequest
|
||||
): Promise<ManagementResponse> {
|
||||
const content = encryptCLINK(this.identity, targetPubkey, request)
|
||||
|
||||
const event = this.createSignedEvent({
|
||||
kind: CLINKEventKind.Manage,
|
||||
content,
|
||||
tags: [['p', targetPubkey], CLINK_VERSION_TAG],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
|
||||
await this.nostrClient.publish(event)
|
||||
|
||||
return this.waitForResponse<ManagementResponse>(targetPubkey, CLINKEventKind.Manage, event.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover service beacon (Kind 30078)
|
||||
*/
|
||||
async discoverService(servicePubkey: string): Promise<ServiceBeacon | null> {
|
||||
const events = await this.nostrClient.queryEvents([
|
||||
{
|
||||
kinds: [CLINKEventKind.Beacon],
|
||||
authors: [servicePubkey],
|
||||
'#d': ['Lightning.Pub'],
|
||||
limit: 1,
|
||||
},
|
||||
])
|
||||
|
||||
if (events.length === 0) return null
|
||||
|
||||
try {
|
||||
return JSON.parse(events[0]?.content ?? '{}') as ServiceBeacon
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming CLINK event
|
||||
*/
|
||||
private async handleEvent(event: Event): Promise<void> {
|
||||
try {
|
||||
switch (event.kind) {
|
||||
case CLINKEventKind.Offer:
|
||||
await this.handleOfferEvent(event)
|
||||
break
|
||||
case CLINKEventKind.Debit:
|
||||
await this.handleDebitEvent(event)
|
||||
break
|
||||
case CLINKEventKind.Manage:
|
||||
await this.handleManageEvent(event)
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling CLINK event:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle offer request (Kind 21001)
|
||||
*/
|
||||
private async handleOfferEvent(event: Event): Promise<void> {
|
||||
if (!this.offerHandler) 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
|
||||
}
|
||||
|
||||
const request = decryptCLINKJSON<OfferRequest>(this.identity, event.pubkey, event.content)
|
||||
|
||||
const response = await this.offerHandler(request, event.pubkey)
|
||||
if (!response) return
|
||||
|
||||
// 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], CLINK_VERSION_TAG],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
|
||||
await this.nostrClient.publish(responseEvent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle debit request (Kind 21002)
|
||||
*/
|
||||
private async handleDebitEvent(event: Event): Promise<void> {
|
||||
if (!this.debitHandler) 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
|
||||
}
|
||||
|
||||
const request = decryptCLINKJSON<DebitRequest>(this.identity, event.pubkey, event.content)
|
||||
|
||||
const response = await this.debitHandler(request, event.pubkey)
|
||||
|
||||
// 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], CLINK_VERSION_TAG],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
|
||||
await this.nostrClient.publish(responseEvent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle management command (Kind 21003)
|
||||
*/
|
||||
private async handleManageEvent(event: Event): Promise<void> {
|
||||
// Only accept from operator
|
||||
if (event.pubkey !== this.operatorPubkey) {
|
||||
console.warn('Ignoring management command from non-operator:', event.pubkey)
|
||||
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 = decryptCLINKJSON<ManagementRequest>(this.identity, event.pubkey, event.content)
|
||||
|
||||
const response = await this.managementHandler(request, event.pubkey)
|
||||
if (!response) return
|
||||
|
||||
// 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], CLINK_VERSION_TAG],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
|
||||
await this.nostrClient.publish(responseEvent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a response event
|
||||
*/
|
||||
private waitForResponse<T>(fromPubkey: string, kind: number, requestEventId: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.nostrClient.unsubscribe(subId)
|
||||
reject(new Error('Response timeout'))
|
||||
}, 30000)
|
||||
|
||||
const subId = this.nostrClient.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [kind],
|
||||
authors: [fromPubkey],
|
||||
'#p': [this.identity.publicKey],
|
||||
'#e': [requestEventId],
|
||||
since: Math.floor(Date.now() / 1000) - 5,
|
||||
},
|
||||
],
|
||||
{
|
||||
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 = decryptCLINKJSON<T>(this.identity, fromPubkey, event.content)
|
||||
resolve(response)
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a signed event
|
||||
*/
|
||||
private createSignedEvent(event: Omit<UnsignedEvent, 'pubkey'>): Event {
|
||||
// finalizeEvent derives pubkey from the secret key
|
||||
return finalizeEvent(event, this.identity.privateKey)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper functions for creating responses
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create an offer success response
|
||||
*/
|
||||
export function createOfferSuccess(bolt11: string): OfferSuccessResponse {
|
||||
return { bolt11 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an offer error response
|
||||
*/
|
||||
export function createOfferError(
|
||||
code: OfferErrorCode,
|
||||
error: string,
|
||||
options?: { range?: { min: number; max: number }; latest?: string }
|
||||
): OfferErrorResponse {
|
||||
return { code, error, range: options?.range, latest: options?.latest }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a debit success response
|
||||
*/
|
||||
export function createDebitSuccess(preimage: string): DebitSuccessResponse {
|
||||
return { res: 'ok', preimage }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a debit failure response (GFY)
|
||||
*/
|
||||
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
|
||||
export { isOfferError, isDebitFailure }
|
||||
115
packages/clink/src/index.ts
Normal file
115
packages/clink/src/index.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* @lamassu/clink
|
||||
*
|
||||
* CLINK protocol implementation for Nostr-native Lightning payments.
|
||||
*
|
||||
* CLINK enables:
|
||||
* - Static payment codes (noffers) for receiving payments
|
||||
* - Offer requests/responses for invoice generation (Kind 21001)
|
||||
* - Debit authorization for outgoing payments (Kind 21002)
|
||||
* - Management delegation for remote control (Kind 21003)
|
||||
* - Service discovery via beacons (Kind 30078)
|
||||
*
|
||||
* All CLINK messages use NIP-44v2 encryption.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { CLINKClient, encodeNoffer, createOfferSuccess } from '@lamassu/clink'
|
||||
*
|
||||
* // Create a client
|
||||
* const clink = new CLINKClient({
|
||||
* nostrClient,
|
||||
* identity,
|
||||
* operatorPubkey,
|
||||
* relays: ['wss://relay.example.com'],
|
||||
* })
|
||||
*
|
||||
* // Create a noffer for the ATM (spontaneous payment)
|
||||
* const noffer = clink.createOffer({
|
||||
* priceType: 'spontaneous',
|
||||
* offerId: 'atm-cashout', // Opaque identifier for routing
|
||||
* })
|
||||
*
|
||||
* // Handle incoming offer requests
|
||||
* clink.onOfferRequest(async (request, senderPubkey) => {
|
||||
* const bolt11 = await generateInvoice(request.amount_sats)
|
||||
* return createOfferSuccess(bolt11)
|
||||
* })
|
||||
*
|
||||
* clink.startListening()
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Types
|
||||
export {
|
||||
// Event kinds
|
||||
CLINKEventKind,
|
||||
// Error codes (per CLINK spec)
|
||||
OfferErrorCode,
|
||||
GFYCode,
|
||||
type CLINKErrorCode, // deprecated alias
|
||||
// Offer types (Kind 21001)
|
||||
type OfferRequest,
|
||||
type OfferResponse,
|
||||
type OfferSuccessResponse,
|
||||
type OfferErrorResponse,
|
||||
// Debit types (Kind 21002)
|
||||
type DebitRequest,
|
||||
type DebitPaymentRequest,
|
||||
type DebitBudgetRequest,
|
||||
type DebitFrequency,
|
||||
type DebitResponse,
|
||||
type DebitSuccessResponse,
|
||||
type DebitFailureResponse,
|
||||
// Management types (Kind 21003)
|
||||
type ManagementRequest,
|
||||
type ManagementResponse,
|
||||
type ManagementSuccessResponse,
|
||||
type ManagementFailureResponse,
|
||||
type ManagementAction,
|
||||
type ManagementResource,
|
||||
// Beacon types (Kind 30078)
|
||||
type ServiceBeacon,
|
||||
// Noffer types
|
||||
type CLINKOffer,
|
||||
type PriceType,
|
||||
NofferTLV,
|
||||
// Ndebit types
|
||||
type DebitPointer,
|
||||
NdebitTLV,
|
||||
// Function types
|
||||
type GenerateInvoice,
|
||||
type PayInvoice,
|
||||
// Type guards
|
||||
isOfferError,
|
||||
isDebitFailure,
|
||||
isDebitPaymentRequest,
|
||||
isManagementFailure,
|
||||
isManagementSuccess,
|
||||
} from './types.js'
|
||||
|
||||
// Noffer encoding/decoding
|
||||
export { encodeNoffer, decodeNoffer, isValidNoffer, npubToHex, hexToNpub } from './noffer.js'
|
||||
|
||||
// Ndebit encoding/decoding
|
||||
export {
|
||||
encodeNdebit,
|
||||
decodeNdebit,
|
||||
isValidNdebit,
|
||||
formatNdebitUri,
|
||||
parseNdebitAmount,
|
||||
} from './ndebit.js'
|
||||
|
||||
// Client
|
||||
export {
|
||||
CLINKClient,
|
||||
type CLINKClientOptions,
|
||||
type OfferRequestHandler,
|
||||
type DebitRequestHandler,
|
||||
type ManagementHandler,
|
||||
// Response helpers
|
||||
createOfferSuccess,
|
||||
createOfferError,
|
||||
createDebitSuccess,
|
||||
createDebitFailure,
|
||||
} from './client.js'
|
||||
206
packages/clink/src/ndebit.ts
Normal file
206
packages/clink/src/ndebit.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
/**
|
||||
* ndebit encoding/decoding
|
||||
*
|
||||
* ndebits are bech32-encoded debit pointers that contain
|
||||
* pubkey, relay, and optional pointer information.
|
||||
*
|
||||
* Format: ndebit1<bech32-encoded-tlv-data>
|
||||
*
|
||||
* For ATM cash-in flow, the URI format includes amount:
|
||||
* clink:ndebit1<bech32data>?amount=<sats>
|
||||
*/
|
||||
|
||||
import { bech32 } from '@scure/base'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import type { DebitPointer } from './types.js'
|
||||
import { NdebitTLV } from './types.js'
|
||||
|
||||
const NDEBIT_PREFIX = 'ndebit'
|
||||
const BECH32_LIMIT = 2000
|
||||
|
||||
/**
|
||||
* Encode a debit pointer as an ndebit string
|
||||
*/
|
||||
export function encodeNdebit(pointer: DebitPointer): string {
|
||||
const tlvData: number[] = []
|
||||
|
||||
// Pubkey (TLV 0) - convert npub to hex if needed
|
||||
const hexPubkey = npubToHex(pointer.pubkey)
|
||||
const pubkeyBytes = hexToBytes(hexPubkey)
|
||||
writeTLV(tlvData, NdebitTLV.Pubkey, pubkeyBytes)
|
||||
|
||||
// Relay (TLV 1)
|
||||
const relayBytes = new TextEncoder().encode(pointer.relay)
|
||||
writeTLV(tlvData, NdebitTLV.Relay, relayBytes)
|
||||
|
||||
// Pointer (TLV 2) - if present
|
||||
if (pointer.pointer) {
|
||||
const pointerBytes = new TextEncoder().encode(pointer.pointer)
|
||||
writeTLV(tlvData, NdebitTLV.Pointer, pointerBytes)
|
||||
}
|
||||
|
||||
// Convert to Uint8Array and bech32 encode
|
||||
const bytes = new Uint8Array(tlvData)
|
||||
const words = bech32.toWords(bytes)
|
||||
|
||||
return bech32.encode(NDEBIT_PREFIX, words, BECH32_LIMIT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an ndebit string to a debit pointer
|
||||
*/
|
||||
export function decodeNdebit(ndebit: string): DebitPointer {
|
||||
// Remove clink: prefix if present
|
||||
let cleaned = ndebit
|
||||
if (cleaned.toLowerCase().startsWith('clink:')) {
|
||||
cleaned = cleaned.slice(6)
|
||||
}
|
||||
if (cleaned.toLowerCase().startsWith('lightning:')) {
|
||||
cleaned = cleaned.slice(10)
|
||||
}
|
||||
|
||||
// Remove query parameters if present
|
||||
const queryIndex = cleaned.indexOf('?')
|
||||
if (queryIndex !== -1) {
|
||||
cleaned = cleaned.slice(0, queryIndex)
|
||||
}
|
||||
|
||||
// Decode bech32 - cast to expected type (contains "1" separator)
|
||||
const { prefix, words } = bech32.decode(cleaned as `${string}1${string}`, BECH32_LIMIT)
|
||||
|
||||
if (prefix !== NDEBIT_PREFIX) {
|
||||
throw new Error(`Invalid ndebit prefix: ${prefix}`)
|
||||
}
|
||||
|
||||
const bytes = bech32.fromWords(words)
|
||||
const data = new Uint8Array(bytes)
|
||||
|
||||
// Parse TLV entries
|
||||
let pubkey: string | undefined
|
||||
let relay: string | undefined
|
||||
let pointer: string | undefined
|
||||
|
||||
let offset = 0
|
||||
while (offset < data.length) {
|
||||
const type = data[offset]
|
||||
offset++
|
||||
|
||||
if (offset >= data.length) break
|
||||
|
||||
const length = data[offset]
|
||||
offset++
|
||||
|
||||
if (offset + (length ?? 0) > data.length) {
|
||||
throw new Error('Invalid TLV: data truncated')
|
||||
}
|
||||
|
||||
const value = data.slice(offset, offset + (length ?? 0))
|
||||
offset += length ?? 0
|
||||
|
||||
switch (type) {
|
||||
case NdebitTLV.Pubkey:
|
||||
pubkey = bytesToHex(value)
|
||||
break
|
||||
case NdebitTLV.Relay:
|
||||
relay = new TextDecoder().decode(value)
|
||||
break
|
||||
case NdebitTLV.Pointer:
|
||||
pointer = new TextDecoder().decode(value)
|
||||
break
|
||||
// Ignore unknown TLV types for forward compatibility
|
||||
}
|
||||
}
|
||||
|
||||
if (!pubkey) {
|
||||
throw new Error('Invalid ndebit: missing pubkey')
|
||||
}
|
||||
|
||||
if (!relay) {
|
||||
throw new Error('Invalid ndebit: missing relay')
|
||||
}
|
||||
|
||||
return {
|
||||
pubkey,
|
||||
relay,
|
||||
pointer,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an ndebit string without fully decoding
|
||||
*/
|
||||
export function isValidNdebit(ndebit: string): boolean {
|
||||
try {
|
||||
decodeNdebit(ndebit)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ndebit as a full clink: URI with amount
|
||||
*/
|
||||
export function formatNdebitUri(ndebit: string, amountSats: number): string {
|
||||
// Ensure ndebit doesn't already have clink: prefix
|
||||
let cleaned = ndebit
|
||||
if (cleaned.toLowerCase().startsWith('clink:')) {
|
||||
cleaned = cleaned.slice(6)
|
||||
}
|
||||
if (cleaned.toLowerCase().startsWith('lightning:')) {
|
||||
cleaned = cleaned.slice(10)
|
||||
}
|
||||
|
||||
return `clink:${cleaned}?amount=${amountSats}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse amount from ndebit URI query parameters
|
||||
*/
|
||||
export function parseNdebitAmount(uri: string): number | undefined {
|
||||
const match = uri.match(/[?&]amount=(\d+)/)
|
||||
if (match && match[1]) {
|
||||
return parseInt(match[1], 10)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// TLV helpers
|
||||
|
||||
function writeTLV(data: number[], type: number, value: number[] | Uint8Array): void {
|
||||
data.push(type)
|
||||
data.push(value.length)
|
||||
for (const byte of value) {
|
||||
data.push(byte)
|
||||
}
|
||||
}
|
||||
|
||||
function hexToBytes(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2)
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
function bytesToHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert npub to hex pubkey
|
||||
* Accepts either npub1... or hex format, returns hex
|
||||
*/
|
||||
function npubToHex(pubkey: string): string {
|
||||
if (pubkey.startsWith('npub1')) {
|
||||
const decoded = nip19.decode(pubkey)
|
||||
if (decoded.type !== 'npub') {
|
||||
throw new Error(`Invalid npub: expected npub type, got ${decoded.type}`)
|
||||
}
|
||||
return decoded.data
|
||||
}
|
||||
// Already hex
|
||||
return pubkey
|
||||
}
|
||||
246
packages/clink/src/noffer.ts
Normal file
246
packages/clink/src/noffer.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
/**
|
||||
* noffer encoding/decoding
|
||||
*
|
||||
* noffers are bech32-encoded static payment codes that contain
|
||||
* pubkey, relays, and pricing information.
|
||||
*
|
||||
* Format: noffer1<bech32-encoded-tlv-data>
|
||||
*/
|
||||
|
||||
import { bech32 } from '@scure/base'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import type { CLINKOffer, PriceType } from './types.js'
|
||||
import { NofferTLV } from './types.js'
|
||||
|
||||
const NOFFER_PREFIX = 'noffer'
|
||||
const BECH32_LIMIT = 2000
|
||||
|
||||
/**
|
||||
* Encode a CLINK offer as a noffer string (per CLINK spec)
|
||||
*/
|
||||
export function encodeNoffer(offer: CLINKOffer): string {
|
||||
const tlvData: number[] = []
|
||||
|
||||
// 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)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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])
|
||||
|
||||
// TLV 4: Amount in sats - if present
|
||||
if (offer.amountSats !== undefined) {
|
||||
const amountBytes = encodeUint64BE(offer.amountSats)
|
||||
writeTLV(tlvData, NofferTLV.Amount, amountBytes)
|
||||
}
|
||||
|
||||
// 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
|
||||
const bytes = new Uint8Array(tlvData)
|
||||
const words = bech32.toWords(bytes)
|
||||
|
||||
return bech32.encode(NOFFER_PREFIX, words, BECH32_LIMIT)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
const { prefix, words } = bech32.decode(noffer as `${string}1${string}`, BECH32_LIMIT)
|
||||
|
||||
if (prefix !== NOFFER_PREFIX) {
|
||||
throw new Error(`Invalid noffer prefix: ${prefix}`)
|
||||
}
|
||||
|
||||
const bytes = bech32.fromWords(words)
|
||||
const data = new Uint8Array(bytes)
|
||||
|
||||
// Parse TLV entries
|
||||
let pubkey: string | undefined
|
||||
const relays: string[] = []
|
||||
let offerId: string | undefined
|
||||
let priceType: PriceType = 'spontaneous' // Default per spec
|
||||
let amountSats: number | undefined
|
||||
let currency: string | undefined
|
||||
|
||||
let offset = 0
|
||||
while (offset < data.length) {
|
||||
const type = data[offset]
|
||||
offset++
|
||||
|
||||
if (offset >= data.length) break
|
||||
|
||||
const length = data[offset]
|
||||
offset++
|
||||
|
||||
if (offset + (length ?? 0) > data.length) {
|
||||
throw new Error('Invalid TLV: data truncated')
|
||||
}
|
||||
|
||||
const value = data.slice(offset, offset + (length ?? 0))
|
||||
offset += length ?? 0
|
||||
|
||||
switch (type) {
|
||||
case NofferTLV.Pubkey:
|
||||
pubkey = bytesToHex(value)
|
||||
break
|
||||
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.Currency:
|
||||
currency = new TextDecoder().decode(value)
|
||||
break
|
||||
// Ignore unknown TLV types for forward compatibility (per NIP-19)
|
||||
}
|
||||
}
|
||||
|
||||
if (!pubkey) {
|
||||
throw new Error('Invalid noffer: missing pubkey')
|
||||
}
|
||||
|
||||
if (relays.length === 0) {
|
||||
throw new Error('Invalid noffer: no relays')
|
||||
}
|
||||
|
||||
return {
|
||||
pubkey,
|
||||
relays,
|
||||
offerId,
|
||||
priceType,
|
||||
amountSats,
|
||||
currency,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a noffer string without fully decoding
|
||||
*/
|
||||
export function isValidNoffer(noffer: string): boolean {
|
||||
try {
|
||||
decodeNoffer(noffer)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// TLV helpers
|
||||
|
||||
function writeTLV(data: number[], type: number, value: number[] | Uint8Array): void {
|
||||
data.push(type)
|
||||
data.push(value.length)
|
||||
for (const byte of value) {
|
||||
data.push(byte)
|
||||
}
|
||||
}
|
||||
|
||||
function encodePriceType(priceType: PriceType): number {
|
||||
switch (priceType) {
|
||||
case 'fixed':
|
||||
return 0
|
||||
case 'variable':
|
||||
return 1
|
||||
case 'spontaneous':
|
||||
return 2
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
function decodePriceType(byte: number): PriceType {
|
||||
switch (byte) {
|
||||
case 0:
|
||||
return 'fixed'
|
||||
case 1:
|
||||
return 'variable'
|
||||
case 2:
|
||||
return 'spontaneous'
|
||||
default:
|
||||
return 'spontaneous'
|
||||
}
|
||||
}
|
||||
|
||||
function encodeUint64BE(value: number): number[] {
|
||||
// Use division instead of bit shifts because JS bitwise ops only work on 32-bit ints
|
||||
const bytes: number[] = new Array(8).fill(0)
|
||||
let remaining = value
|
||||
for (let i = 7; i >= 0 && remaining > 0; i--) {
|
||||
bytes[i] = remaining % 256
|
||||
remaining = Math.floor(remaining / 256)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
function decodeUint64BE(bytes: Uint8Array): number {
|
||||
let value = 0
|
||||
for (let i = 0; i < bytes.length && i < 8; i++) {
|
||||
value = value * 256 + (bytes[i] ?? 0)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function hexToBytes(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2)
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
function bytesToHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert npub to hex pubkey
|
||||
* Accepts either npub1... or hex format, returns hex
|
||||
*/
|
||||
export function npubToHex(pubkey: string): string {
|
||||
if (pubkey.startsWith('npub1')) {
|
||||
const decoded = nip19.decode(pubkey)
|
||||
if (decoded.type !== 'npub') {
|
||||
throw new Error(`Invalid npub: expected npub type, got ${decoded.type}`)
|
||||
}
|
||||
return decoded.data
|
||||
}
|
||||
// Already hex
|
||||
return pubkey
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex pubkey to npub
|
||||
*/
|
||||
export function hexToNpub(hex: string): string {
|
||||
return nip19.npubEncode(hex)
|
||||
}
|
||||
368
packages/clink/src/types.ts
Normal file
368
packages/clink/src/types.ts
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
/**
|
||||
* CLINK Protocol type definitions
|
||||
*
|
||||
* CLINK is a Nostr-native Lightning payment protocol that enables
|
||||
* static payment codes (noffers) over Nostr relays.
|
||||
*
|
||||
* Event kinds:
|
||||
* - 21000: Generic RPC request/response
|
||||
* - 21001: Offer request/response
|
||||
* - 21002: Debit request/response
|
||||
* - 21003: Management request/response
|
||||
* - 30078: Service beacon (replaceable)
|
||||
*
|
||||
* All CLINK messages use NIP-44v2 encryption.
|
||||
*/
|
||||
|
||||
/** CLINK event kinds */
|
||||
export enum CLINKEventKind {
|
||||
/** Generic RPC request/response */
|
||||
RPC = 21000,
|
||||
/** Offer request/response */
|
||||
Offer = 21001,
|
||||
/** Debit request/response (authorized payments) */
|
||||
Debit = 21002,
|
||||
/** Management request/response */
|
||||
Manage = 21003,
|
||||
/** Service beacon (replaceable event) */
|
||||
Beacon = 30078,
|
||||
}
|
||||
|
||||
/** 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,
|
||||
/** 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,
|
||||
}
|
||||
|
||||
/** 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)
|
||||
// ============================================================================
|
||||
|
||||
/** Offer request (Kind 21001) - sent by payer to service */
|
||||
export interface OfferRequest {
|
||||
/** Offer identifier from service beacon or noffer string */
|
||||
offer: string
|
||||
/** Amount in satoshis */
|
||||
amount_sats: number
|
||||
/** Custom payer metadata */
|
||||
payer_data?: Record<string, string>
|
||||
/** Payment description */
|
||||
description?: string
|
||||
/** Custom expiry in seconds */
|
||||
expires_in_seconds?: number
|
||||
/** Whether this is a zap payment */
|
||||
zap?: boolean
|
||||
}
|
||||
|
||||
/** Offer success response (Kind 21001) */
|
||||
export interface OfferSuccessResponse {
|
||||
/** BOLT-11 Lightning invoice */
|
||||
bolt11: string
|
||||
}
|
||||
|
||||
/** Offer error response (Kind 21001) */
|
||||
export interface OfferErrorResponse {
|
||||
/** Error code (per CLINK Offers spec) */
|
||||
code: OfferErrorCode
|
||||
/** Human-readable error message */
|
||||
error: string
|
||||
/** 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 */
|
||||
export type OfferResponse = OfferSuccessResponse | OfferErrorResponse
|
||||
|
||||
/** Type guard for offer error response */
|
||||
export function isOfferError(response: OfferResponse): response is OfferErrorResponse {
|
||||
return 'code' in response && 'error' in response
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Kind 21002: Debit (Ndebit)
|
||||
// ============================================================================
|
||||
|
||||
/** Debit frequency for budget requests */
|
||||
export interface DebitFrequency {
|
||||
/** Number of intervals */
|
||||
number: number
|
||||
/** Interval unit */
|
||||
unit: 'day' | 'week' | 'month'
|
||||
}
|
||||
|
||||
/** 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
|
||||
/** 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) */
|
||||
export interface DebitSuccessResponse {
|
||||
/** Success indicator */
|
||||
res: 'ok'
|
||||
/** Payment preimage (proof of payment) */
|
||||
preimage: string
|
||||
}
|
||||
|
||||
/** Debit failure response (Kind 21002) - GFY (General Failure to Yield) */
|
||||
export interface DebitFailureResponse {
|
||||
/** Failure indicator */
|
||||
res: 'GFY'
|
||||
/** GFY error code */
|
||||
code: GFYCode
|
||||
/** Human-readable error message */
|
||||
error: string
|
||||
/** 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 */
|
||||
export type DebitResponse = DebitSuccessResponse | DebitFailureResponse
|
||||
|
||||
/** Type guard for debit failure */
|
||||
export function isDebitFailure(response: DebitResponse): response is DebitFailureResponse {
|
||||
return response.res === 'GFY'
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Kind 21003: Management (Nmanage)
|
||||
// ============================================================================
|
||||
|
||||
/** Management action types */
|
||||
export type ManagementAction = 'create' | 'update' | 'delete' | 'list' | 'get'
|
||||
|
||||
/** Offer configuration for create/update */
|
||||
export interface OfferConfig {
|
||||
/** Human-readable label */
|
||||
label?: string
|
||||
/** Fixed price in satoshis */
|
||||
price_sats?: number
|
||||
/** Callback URL for payment notifications */
|
||||
callback_url?: string
|
||||
/** Expected payer data fields */
|
||||
payer_data?: string[]
|
||||
/** Use blinded paths for privacy */
|
||||
blind?: boolean
|
||||
}
|
||||
|
||||
/** 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
|
||||
/** Pointer ID (optional, for multi-account routing) */
|
||||
pointer?: 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) - per CLINK Manage spec */
|
||||
export interface ManagementSuccessResponse {
|
||||
/** 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) - GFY */
|
||||
export interface ManagementFailureResponse {
|
||||
/** Failure indicator */
|
||||
res: 'GFY'
|
||||
/** GFY error code */
|
||||
code: GFYCode
|
||||
/** Human-readable error message */
|
||||
error: string
|
||||
/** 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 */
|
||||
export type ManagementResponse = ManagementSuccessResponse | ManagementFailureResponse
|
||||
|
||||
/** Type guard for management failure */
|
||||
export function isManagementFailure(
|
||||
response: ManagementResponse
|
||||
): response is ManagementFailureResponse {
|
||||
return response.res === 'GFY'
|
||||
}
|
||||
|
||||
/** Type guard for management success */
|
||||
export function isManagementSuccess(
|
||||
response: ManagementResponse
|
||||
): response is ManagementSuccessResponse {
|
||||
return response.res === 'ok'
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Kind 30078: Service Beacon
|
||||
// ============================================================================
|
||||
|
||||
/** Service beacon content (Kind 30078) */
|
||||
export interface ServiceBeacon {
|
||||
/** Beacon type */
|
||||
type: 'service' | 'provider'
|
||||
/** Service name */
|
||||
name: string
|
||||
/** Service avatar URL */
|
||||
avatarUrl?: string
|
||||
/** Fee structure */
|
||||
fees?: Record<string, number>
|
||||
/** Optional relay URL for additional communication */
|
||||
nextRelay?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Noffer (Static Payment Code) encoding
|
||||
// ============================================================================
|
||||
|
||||
/** Offer price type for noffer encoding */
|
||||
export type PriceType = 'fixed' | 'variable' | 'spontaneous'
|
||||
|
||||
/** CLINK Offer (noffer) configuration */
|
||||
export interface CLINKOffer {
|
||||
/** Public key of the offer creator (receiving service) */
|
||||
pubkey: string
|
||||
/** Relays where the offer is accessible */
|
||||
relays: string[]
|
||||
/** Opaque offer identifier (defined by receiving service) */
|
||||
offerId?: string
|
||||
/** Pricing model (default: spontaneous if not specified) */
|
||||
priceType: PriceType
|
||||
/** Amount in satoshis (for fixed price, or display for variable) */
|
||||
amountSats?: number
|
||||
/** Currency code (e.g., "USD") - requires priceType: 'variable' */
|
||||
currency?: string
|
||||
}
|
||||
|
||||
/** 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 = 3,
|
||||
/** Amount in sats (8 bytes, big-endian) */
|
||||
Amount = 4,
|
||||
/** Currency code (e.g., "USD", "EUR") - requires price type 1 (variable) */
|
||||
Currency = 5,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Ndebit (Debit Pointer) encoding
|
||||
// ============================================================================
|
||||
|
||||
/** Debit pointer for ndebit encoding */
|
||||
export interface DebitPointer {
|
||||
/** Public key of the service that will pay (hex) */
|
||||
pubkey: string
|
||||
/** Relay URL for communication */
|
||||
relay: string
|
||||
/** Optional session/voucher identifier */
|
||||
pointer?: string
|
||||
}
|
||||
|
||||
/** ndebit TLV types */
|
||||
export enum NdebitTLV {
|
||||
/** Public key (32 bytes) */
|
||||
Pubkey = 0,
|
||||
/** Relay URL (variable length string) */
|
||||
Relay = 1,
|
||||
/** Pointer/session identifier (variable length string) */
|
||||
Pointer = 2,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Function types for service injection
|
||||
// ============================================================================
|
||||
|
||||
/** Invoice generation function type */
|
||||
export type GenerateInvoice = (amountSats: number, description?: string) => Promise<string>
|
||||
|
||||
/** Payment function type */
|
||||
export type PayInvoice = (invoice: string) => Promise<{ preimage: string } | { error: string }>
|
||||
22
packages/clink/tsconfig.json
Normal file
22
packages/clink/tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
8
packages/clink/vitest.config.ts
Normal file
8
packages/clink/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
globals: false,
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue