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:
Patrick Mulligan 2026-02-15 14:19:16 -05:00
commit c98f126ba7
180 changed files with 2695 additions and 9587 deletions

View 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"
}
}

View 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)
})
})
})

View 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
View 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'

View 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
}

View 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
View 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 }>

View 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"]
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
globals: false,
},
})