Implement @lamassu/clink package
CLINK protocol implementation for Nostr-native Lightning payments: - noffer encoding/decoding (bech32 TLV format) - CLINKClient for protocol communication - Event kinds: 21001 (Offer), 21002 (Debit), 21003 (Manage) Features: - Create static payment codes (noffers) for ATM - Handle incoming offer requests and generate invoices - Send/receive debit requests - Process management commands from operator - Encrypted communication with NIP-44 Includes tests for noffer encoding/decoding round-trip. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
f4cd45c81c
commit
6ddb7c7a06
8 changed files with 844 additions and 0 deletions
|
|
@ -21,6 +21,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lamassu/nostr-client": "workspace:*",
|
"@lamassu/nostr-client": "workspace:*",
|
||||||
|
"@scure/base": "^1.2.0",
|
||||||
"nostr-tools": "^2.10.0"
|
"nostr-tools": "^2.10.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
74
lamassu-next/packages/clink/src/__tests__/noffer.test.ts
Normal file
74
lamassu-next/packages/clink/src/__tests__/noffer.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { encodeNoffer, decodeNoffer, isValidNoffer } from '../noffer.js'
|
||||||
|
import type { CLINKOffer } from '../types.js'
|
||||||
|
|
||||||
|
describe('noffer encoding/decoding', () => {
|
||||||
|
const sampleOffer: CLINKOffer = {
|
||||||
|
pubkey: 'a'.repeat(64), // 64 hex chars
|
||||||
|
relays: ['wss://relay.example.com', 'wss://relay2.example.com'],
|
||||||
|
priceType: 'variable',
|
||||||
|
description: 'Test offer',
|
||||||
|
}
|
||||||
|
|
||||||
|
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', () => {
|
||||||
|
const offer: CLINKOffer = {
|
||||||
|
...sampleOffer,
|
||||||
|
priceType: 'fixed',
|
||||||
|
amountMsat: 1000000,
|
||||||
|
}
|
||||||
|
|
||||||
|
const noffer = encodeNoffer(offer)
|
||||||
|
expect(noffer).toMatch(/^noffer1/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('decodeNoffer', () => {
|
||||||
|
it('should round-trip encode/decode', () => {
|
||||||
|
const noffer = encodeNoffer(sampleOffer)
|
||||||
|
const decoded = decodeNoffer(noffer)
|
||||||
|
|
||||||
|
expect(decoded.pubkey).toBe(sampleOffer.pubkey)
|
||||||
|
expect(decoded.relays).toEqual(sampleOffer.relays)
|
||||||
|
expect(decoded.priceType).toBe(sampleOffer.priceType)
|
||||||
|
expect(decoded.description).toBe(sampleOffer.description)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should decode fixed price offer correctly', () => {
|
||||||
|
const offer: CLINKOffer = {
|
||||||
|
...sampleOffer,
|
||||||
|
priceType: 'fixed',
|
||||||
|
amountMsat: 50000,
|
||||||
|
}
|
||||||
|
|
||||||
|
const noffer = encodeNoffer(offer)
|
||||||
|
const decoded = decodeNoffer(noffer)
|
||||||
|
|
||||||
|
expect(decoded.priceType).toBe('fixed')
|
||||||
|
expect(decoded.amountMsat).toBe(50000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw on invalid prefix', () => {
|
||||||
|
expect(() => decodeNoffer('invalid1abc')).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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
336
lamassu-next/packages/clink/src/client.ts
Normal file
336
lamassu-next/packages/clink/src/client.ts
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Event, UnsignedEvent } from 'nostr-tools'
|
||||||
|
import { finalizeEvent } from 'nostr-tools'
|
||||||
|
import type { MachineIdentity, NostrClient } from '@lamassu/nostr-client'
|
||||||
|
import { encryptContent, decryptJSON } from '@lamassu/nostr-client'
|
||||||
|
import {
|
||||||
|
CLINKEventKind,
|
||||||
|
type CLINKOffer,
|
||||||
|
type OfferRequest,
|
||||||
|
type OfferResponse,
|
||||||
|
type DebitRequest,
|
||||||
|
type DebitResponse,
|
||||||
|
type ManagementDelegation,
|
||||||
|
type GenerateInvoice,
|
||||||
|
type PayInvoice,
|
||||||
|
} from './types.js'
|
||||||
|
import { encodeNoffer } 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 */
|
||||||
|
export type OfferRequestHandler = (
|
||||||
|
request: OfferRequest,
|
||||||
|
senderPubkey: string
|
||||||
|
) => Promise<OfferResponse | null>
|
||||||
|
|
||||||
|
/** Debit request handler */
|
||||||
|
export type DebitRequestHandler = (
|
||||||
|
request: DebitRequest,
|
||||||
|
senderPubkey: string
|
||||||
|
) => Promise<DebitResponse>
|
||||||
|
|
||||||
|
/** Management command handler */
|
||||||
|
export type ManagementHandler = (
|
||||||
|
delegation: ManagementDelegation,
|
||||||
|
senderPubkey: string
|
||||||
|
) => Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
createOffer(options: {
|
||||||
|
priceType: CLINKOffer['priceType']
|
||||||
|
amountMsat?: number
|
||||||
|
description?: string
|
||||||
|
minAmountMsat?: number
|
||||||
|
maxAmountMsat?: number
|
||||||
|
}): string {
|
||||||
|
const offer: CLINKOffer = {
|
||||||
|
pubkey: this.identity.publicKey,
|
||||||
|
relays: this.relays,
|
||||||
|
priceType: options.priceType,
|
||||||
|
amountMsat: options.amountMsat,
|
||||||
|
description: options.description,
|
||||||
|
minAmountMsat: options.minAmountMsat,
|
||||||
|
maxAmountMsat: options.maxAmountMsat,
|
||||||
|
}
|
||||||
|
|
||||||
|
return encodeNoffer(offer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set handler for incoming offer requests
|
||||||
|
*/
|
||||||
|
onOfferRequest(handler: OfferRequestHandler): void {
|
||||||
|
this.offerHandler = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set handler for incoming debit requests
|
||||||
|
*/
|
||||||
|
onDebitRequest(handler: DebitRequestHandler): void {
|
||||||
|
this.debitHandler = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set handler for management commands
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an offer request to another pubkey
|
||||||
|
*/
|
||||||
|
async requestOffer(targetPubkey: string, request: OfferRequest): Promise<OfferResponse> {
|
||||||
|
const content = encryptContent(this.identity, targetPubkey, request)
|
||||||
|
|
||||||
|
const event = this.createSignedEvent({
|
||||||
|
kind: CLINKEventKind.Offer,
|
||||||
|
content,
|
||||||
|
tags: [['p', targetPubkey]],
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.nostrClient.publish(event)
|
||||||
|
|
||||||
|
// Wait for response
|
||||||
|
return this.waitForResponse<OfferResponse>(targetPubkey, CLINKEventKind.Offer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a debit request
|
||||||
|
*/
|
||||||
|
async requestDebit(targetPubkey: string, request: DebitRequest): Promise<DebitResponse> {
|
||||||
|
const content = encryptContent(this.identity, targetPubkey, request)
|
||||||
|
|
||||||
|
const event = this.createSignedEvent({
|
||||||
|
kind: CLINKEventKind.Debit,
|
||||||
|
content,
|
||||||
|
tags: [['p', targetPubkey]],
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.nostrClient.publish(event)
|
||||||
|
|
||||||
|
return this.waitForResponse<DebitResponse>(targetPubkey, CLINKEventKind.Debit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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/response
|
||||||
|
*/
|
||||||
|
private async handleOfferEvent(event: Event): Promise<void> {
|
||||||
|
if (!this.offerHandler) return
|
||||||
|
|
||||||
|
const request = decryptJSON<OfferRequest>(this.identity, event.pubkey, event.content)
|
||||||
|
|
||||||
|
const response = await this.offerHandler(request, event.pubkey)
|
||||||
|
if (!response) return
|
||||||
|
|
||||||
|
// Send encrypted response
|
||||||
|
const content = encryptContent(this.identity, event.pubkey, response)
|
||||||
|
|
||||||
|
const responseEvent = this.createSignedEvent({
|
||||||
|
kind: CLINKEventKind.Offer,
|
||||||
|
content,
|
||||||
|
tags: [
|
||||||
|
['p', event.pubkey],
|
||||||
|
['e', event.id],
|
||||||
|
],
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.nostrClient.publish(responseEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle debit request
|
||||||
|
*/
|
||||||
|
private async handleDebitEvent(event: Event): Promise<void> {
|
||||||
|
if (!this.debitHandler) return
|
||||||
|
|
||||||
|
const request = decryptJSON<DebitRequest>(this.identity, event.pubkey, event.content)
|
||||||
|
|
||||||
|
const response = await this.debitHandler(request, event.pubkey)
|
||||||
|
|
||||||
|
// Send encrypted response
|
||||||
|
const content = encryptContent(this.identity, event.pubkey, response)
|
||||||
|
|
||||||
|
const responseEvent = this.createSignedEvent({
|
||||||
|
kind: CLINKEventKind.Debit,
|
||||||
|
content,
|
||||||
|
tags: [
|
||||||
|
['p', event.pubkey],
|
||||||
|
['e', event.id],
|
||||||
|
],
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.nostrClient.publish(responseEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle management command
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.managementHandler) return
|
||||||
|
|
||||||
|
const delegation = decryptJSON<ManagementDelegation>(this.identity, event.pubkey, event.content)
|
||||||
|
|
||||||
|
await this.managementHandler(delegation, event.pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for a response event
|
||||||
|
*/
|
||||||
|
private waitForResponse<T>(fromPubkey: string, kind: number): 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],
|
||||||
|
since: Math.floor(Date.now() / 1000) - 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
onEvent: (event) => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
this.nostrClient.unsubscribe(subId)
|
||||||
|
try {
|
||||||
|
const response = decryptJSON<T>(this.identity, fromPubkey, event.content)
|
||||||
|
resolve(response)
|
||||||
|
} catch (e) {
|
||||||
|
reject(e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a signed event
|
||||||
|
*/
|
||||||
|
private createSignedEvent(event: Omit<UnsignedEvent, 'pubkey'>): Event {
|
||||||
|
return finalizeEvent(
|
||||||
|
{
|
||||||
|
...event,
|
||||||
|
pubkey: this.identity.publicKey,
|
||||||
|
},
|
||||||
|
this.identity.privateKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
67
lamassu-next/packages/clink/src/index.ts
Normal file
67
lamassu-next/packages/clink/src/index.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
/**
|
||||||
|
* @lamassu/clink
|
||||||
|
*
|
||||||
|
* CLINK protocol implementation for Nostr-native Lightning payments.
|
||||||
|
*
|
||||||
|
* CLINK enables static payment codes (noffers) that work over Nostr,
|
||||||
|
* providing a decentralized alternative to BOLT12 offers.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { CLINKClient, encodeNoffer, decodeNoffer } from '@lamassu/clink'
|
||||||
|
*
|
||||||
|
* // Create a client
|
||||||
|
* const clink = new CLINKClient({
|
||||||
|
* nostrClient,
|
||||||
|
* identity,
|
||||||
|
* operatorPubkey,
|
||||||
|
* relays: ['wss://relay.example.com'],
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* // Create a noffer for the ATM
|
||||||
|
* const noffer = clink.createOffer({
|
||||||
|
* priceType: 'variable',
|
||||||
|
* description: 'Buy Bitcoin at ATM',
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* // Handle incoming offer requests
|
||||||
|
* clink.onOfferRequest(async (request, senderPubkey) => {
|
||||||
|
* const invoice = await generateInvoice(request.amountMsat)
|
||||||
|
* return {
|
||||||
|
* invoice,
|
||||||
|
* amountMsat: request.amountMsat,
|
||||||
|
* expiresAt: Date.now() / 1000 + 600,
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* clink.startListening()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Client
|
||||||
|
export { CLINKClient } from './client.js'
|
||||||
|
export type {
|
||||||
|
CLINKClientOptions,
|
||||||
|
OfferRequestHandler,
|
||||||
|
DebitRequestHandler,
|
||||||
|
ManagementHandler,
|
||||||
|
} from './client.js'
|
||||||
|
|
||||||
|
// noffer encoding/decoding
|
||||||
|
export { encodeNoffer, decodeNoffer, isValidNoffer } from './noffer.js'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export {
|
||||||
|
CLINKEventKind,
|
||||||
|
NofferTLV,
|
||||||
|
type PriceType,
|
||||||
|
type CLINKOffer,
|
||||||
|
type OfferRequest,
|
||||||
|
type OfferResponse,
|
||||||
|
type DebitRequest,
|
||||||
|
type DebitResponse,
|
||||||
|
type ManagementAction,
|
||||||
|
type ManagementDelegation,
|
||||||
|
type GenerateInvoice,
|
||||||
|
type PayInvoice,
|
||||||
|
} from './types.js'
|
||||||
207
lamassu-next/packages/clink/src/noffer.ts
Normal file
207
lamassu-next/packages/clink/src/noffer.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
/**
|
||||||
|
* 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 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
|
||||||
|
*/
|
||||||
|
export function encodeNoffer(offer: CLINKOffer): string {
|
||||||
|
const tlvData: number[] = []
|
||||||
|
|
||||||
|
// Pubkey (TLV 0)
|
||||||
|
const pubkeyBytes = hexToBytes(offer.pubkey)
|
||||||
|
writeTLV(tlvData, NofferTLV.Pubkey, pubkeyBytes)
|
||||||
|
|
||||||
|
// Relays (TLV 1) - one entry per relay
|
||||||
|
for (const relay of offer.relays) {
|
||||||
|
const relayBytes = new TextEncoder().encode(relay)
|
||||||
|
writeTLV(tlvData, NofferTLV.Relay, relayBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Price type (TLV 2)
|
||||||
|
const priceTypeByte = encodePriceType(offer.priceType)
|
||||||
|
writeTLV(tlvData, NofferTLV.PriceType, [priceTypeByte])
|
||||||
|
|
||||||
|
// Amount (TLV 3) - if present
|
||||||
|
if (offer.amountMsat !== undefined) {
|
||||||
|
const amountBytes = encodeUint64BE(offer.amountMsat)
|
||||||
|
writeTLV(tlvData, NofferTLV.Amount, amountBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description (TLV 4) - if present
|
||||||
|
if (offer.description) {
|
||||||
|
const descBytes = new TextEncoder().encode(offer.description)
|
||||||
|
writeTLV(tlvData, NofferTLV.Description, descBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
*/
|
||||||
|
export function decodeNoffer(noffer: string): CLINKOffer {
|
||||||
|
// Decode bech32
|
||||||
|
const { prefix, words } = bech32.decode(noffer, 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 priceType: PriceType = 'spontaneous'
|
||||||
|
let amountMsat: number | undefined
|
||||||
|
let description: 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.PriceType:
|
||||||
|
priceType = decodePriceType(value[0] ?? 2)
|
||||||
|
break
|
||||||
|
case NofferTLV.Amount:
|
||||||
|
amountMsat = decodeUint64BE(value)
|
||||||
|
break
|
||||||
|
case NofferTLV.Description:
|
||||||
|
description = new TextDecoder().decode(value)
|
||||||
|
break
|
||||||
|
// Ignore unknown TLV types for forward compatibility
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pubkey) {
|
||||||
|
throw new Error('Invalid noffer: missing pubkey')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relays.length === 0) {
|
||||||
|
throw new Error('Invalid noffer: no relays')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pubkey,
|
||||||
|
relays,
|
||||||
|
priceType,
|
||||||
|
amountMsat,
|
||||||
|
description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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[] {
|
||||||
|
const bytes: number[] = []
|
||||||
|
for (let i = 7; i >= 0; i--) {
|
||||||
|
bytes.push((value >> (i * 8)) & 0xff)
|
||||||
|
}
|
||||||
|
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('')
|
||||||
|
}
|
||||||
129
lamassu-next/packages/clink/src/types.ts
Normal file
129
lamassu-next/packages/clink/src/types.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
/**
|
||||||
|
* CLINK Protocol type definitions
|
||||||
|
*
|
||||||
|
* CLINK is a Nostr-native Lightning payment protocol that enables
|
||||||
|
* static payment codes (noffers) over Nostr relays.
|
||||||
|
*
|
||||||
|
* Event kinds:
|
||||||
|
* - 21001: Offer request/response
|
||||||
|
* - 21002: Debit request/response
|
||||||
|
* - 21003: Management delegation
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** CLINK event kinds */
|
||||||
|
export enum CLINKEventKind {
|
||||||
|
/** Offer request/response */
|
||||||
|
Offer = 21001,
|
||||||
|
/** Debit request/response (authorized payments) */
|
||||||
|
Debit = 21002,
|
||||||
|
/** Management delegation */
|
||||||
|
Manage = 21003,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Offer price type */
|
||||||
|
export type PriceType = 'fixed' | 'variable' | 'spontaneous'
|
||||||
|
|
||||||
|
/** CLINK Offer (noffer) configuration */
|
||||||
|
export interface CLINKOffer {
|
||||||
|
/** Public key of the offer creator */
|
||||||
|
pubkey: string
|
||||||
|
/** Relays where the offer is accessible */
|
||||||
|
relays: string[]
|
||||||
|
/** Pricing model */
|
||||||
|
priceType: PriceType
|
||||||
|
/** Amount in millisatoshis (for fixed price) */
|
||||||
|
amountMsat?: number
|
||||||
|
/** Human-readable description */
|
||||||
|
description?: string
|
||||||
|
/** Minimum amount in msats (for variable/spontaneous) */
|
||||||
|
minAmountMsat?: number
|
||||||
|
/** Maximum amount in msats (for variable/spontaneous) */
|
||||||
|
maxAmountMsat?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Offer request (Kind 21001) */
|
||||||
|
export interface OfferRequest {
|
||||||
|
/** Requested amount in millisatoshis */
|
||||||
|
amountMsat: number
|
||||||
|
/** Fiat amount (for ATM context) */
|
||||||
|
fiatAmount?: number
|
||||||
|
/** Fiat currency code */
|
||||||
|
fiatCurrency?: string
|
||||||
|
/** Sender's callback pubkey for response */
|
||||||
|
senderPubkey: string
|
||||||
|
/** Optional message */
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Offer response (Kind 21001) */
|
||||||
|
export interface OfferResponse {
|
||||||
|
/** BOLT11 invoice to pay */
|
||||||
|
invoice: string
|
||||||
|
/** Amount in millisatoshis */
|
||||||
|
amountMsat: number
|
||||||
|
/** Optional description */
|
||||||
|
description?: string
|
||||||
|
/** Expiry timestamp */
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Debit request (Kind 21002) */
|
||||||
|
export interface DebitRequest {
|
||||||
|
/** Amount to debit in millisatoshis */
|
||||||
|
amountMsat: number
|
||||||
|
/** Recipient's pubkey */
|
||||||
|
recipientPubkey: string
|
||||||
|
/** Optional description */
|
||||||
|
description?: string
|
||||||
|
/** Unique request ID for idempotency */
|
||||||
|
requestId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Debit response (Kind 21002) */
|
||||||
|
export interface DebitResponse {
|
||||||
|
/** Whether the debit was successful */
|
||||||
|
success: boolean
|
||||||
|
/** Payment preimage (proof of payment) */
|
||||||
|
preimage?: string
|
||||||
|
/** Error message if failed */
|
||||||
|
error?: string
|
||||||
|
/** Matching request ID */
|
||||||
|
requestId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Management action (Kind 21003) */
|
||||||
|
export type ManagementAction =
|
||||||
|
| { type: 'disable' }
|
||||||
|
| { type: 'enable' }
|
||||||
|
| { type: 'set_limits'; minMsat: number; maxMsat: number }
|
||||||
|
| { type: 'revoke' }
|
||||||
|
|
||||||
|
/** Management delegation (Kind 21003) */
|
||||||
|
export interface ManagementDelegation {
|
||||||
|
/** Target machine/account pubkey */
|
||||||
|
targetPubkey: string
|
||||||
|
/** Delegated action */
|
||||||
|
action: ManagementAction
|
||||||
|
/** Timestamp */
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** noffer TLV types */
|
||||||
|
export enum NofferTLV {
|
||||||
|
/** Public key (32 bytes) */
|
||||||
|
Pubkey = 0,
|
||||||
|
/** Relay URL (variable length string) */
|
||||||
|
Relay = 1,
|
||||||
|
/** Price type (1 byte: 0=fixed, 1=variable, 2=spontaneous) */
|
||||||
|
PriceType = 2,
|
||||||
|
/** Amount in msats (8 bytes, big-endian) */
|
||||||
|
Amount = 3,
|
||||||
|
/** Description (variable length string) */
|
||||||
|
Description = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invoice generation function type */
|
||||||
|
export type GenerateInvoice = (amountMsat: number) => Promise<string>
|
||||||
|
|
||||||
|
/** Payment function type */
|
||||||
|
export type PayInvoice = (invoice: string) => Promise<{ preimage: string } | { error: string }>
|
||||||
22
lamassu-next/packages/clink/tsconfig.json
Normal file
22
lamassu-next/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
lamassu-next/packages/clink/vitest.config.ts
Normal file
8
lamassu-next/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