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": {
|
||||
"@lamassu/nostr-client": "workspace:*",
|
||||
"@scure/base": "^1.2.0",
|
||||
"nostr-tools": "^2.10.0"
|
||||
},
|
||||
"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