Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ By solving the networking and programability hurdles, Pub provides Lightning wit
- [x] A management dashboard is actively being integrated into [ShockWallet](https://github.com/shocknet/wallet2)
- [x] Nostr native [CLINK](https://clinkme.dev) "offers"
- [x] Encrypted Push Notifications
- [_] Swap integration (in progress)
- [-] Swap integration (Partial, via admin dashboard only, quotes from Zeus LSP and Boltz)
- [ ] P2P "LSP" coordination for channel batching over Nostr
- [ ] High-Availabilty / Clustering

Expand Down Expand Up @@ -288,14 +288,16 @@ The wizard interface (coming soon for Start9/Umbrel) will make this graphical.

### Bootstrap Liquidity Provider

By default, Lightning.Pub uses a bootstrap liquidity provider that provides initial channel funding as a service credit until you can afford your own channels. Pub compares rates from top LSPs and automatically requests a channel when needed.
By default, Lightning.Pub connects to a bootstrap liquidity provider (another Pub over Nostr) for outbound routing when local LND channel liquidity is low. Pub compares rates from top LSPs and automatically requests a channel when needed.

Settings are loaded from environment variables or the `admin_settings` database table (env wins if both are set). The relevant keys are **`PROVIDER_NPROFILE`** and **`DISABLE_LIQUIDITY_PROVIDER`**

```bash
# Disable for full sovereignty
# Disable upstream provider checks (LND only for outbound payments)
DISABLE_LIQUIDITY_PROVIDER=true

# Or point to a different Pub instance
LIQUIDITY_PROVIDER_PUB=nprofile1...
# Optional: point at a different Pub instance (pubkey + relay are in the nprofile)
PROVIDER_NPROFILE=nprofile1qyd8wumn8ghj7um5wfn8y7fwwd5x7cmt9ehx2arhdaexkqpqwmk5tuqvafa6ckwc6zmaypyy3af3n4aeds2ql7m0ew42kzsn638q9s9z8p
```

### Custom Lightning Address Domain
Expand Down
5 changes: 5 additions & 0 deletions src/services/helpers/offerValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const assertValidOfferPriceSats = (priceSats: number): void => {
if (priceSats < 0) {
throw new Error("price_sats cannot be negative")
}
}
76 changes: 70 additions & 6 deletions src/services/helpers/safeOutboundFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,74 @@ export const isMetadataIp = (ip: string): boolean => {
return false
}

export const isLoopbackIPv4 = (ip: string): boolean => {
const parts = ip.split(".").map(Number)
if (parts.length !== 4 || parts.some(p => Number.isNaN(p) || p < 0 || p > 255)) {
return false
}
return parts[0] === 127
}

export const isPrivateIPv4 = (ip: string): boolean => {
const parts = ip.split(".").map(Number)
if (parts.length !== 4 || parts.some(p => Number.isNaN(p) || p < 0 || p > 255)) {
return false
}
const [a, b] = parts
if (a === 10) return true
if (a === 172 && b >= 16 && b <= 31) return true
if (a === 192 && b === 168) return true
if (a === 169 && b === 254) return true
if (a === 100 && b >= 64 && b <= 127) return true
return false
}

export const isLoopbackIPv6 = (ip: string): boolean => {
const normalized = ip.toLowerCase().split("%")[0]
if (normalized === "::1") return true
if (normalized.startsWith("::ffff:")) {
const mapped = normalized.slice("::ffff:".length)
if (mapped.includes(".")) {
return isLoopbackIPv4(mapped)
}
}
return false
}

export const isPrivateIPv6 = (ip: string): boolean => {
const normalized = ip.toLowerCase().split("%")[0]
if (normalized.startsWith("::ffff:")) {
const mapped = normalized.slice("::ffff:".length)
if (mapped.includes(".")) {
return isPrivateIPv4(mapped)
}
}
const firstHextet = normalized.split(":")[0]
if (firstHextet.startsWith("fc") || firstHextet.startsWith("fd")) return true
if (
firstHextet.startsWith("fe8") ||
firstHextet.startsWith("fe9") ||
firstHextet.startsWith("fea") ||
firstHextet.startsWith("feb")
) {
return true
}
return false
}

export const isBlockedCallbackIp = (ip: string): boolean => {
const version = isIP(ip)
if (version === 4) {
if (isLoopbackIPv4(ip)) return false
return isPrivateIPv4(ip) || isMetadataIPv4(ip)
}
if (version === 6) {
if (isLoopbackIPv6(ip)) return false
return isPrivateIPv6(ip) || isMetadataIPv6(ip)
}
return false
}

export const validateCallbackUrlForEgress = (url: URL): void => {
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new SafeOutboundFetchError("callback url protocol must be http or https")
Expand All @@ -74,11 +142,7 @@ export const validateCallbackUrlForEgress = (url: URL): void => {
if (blockedHostnames.has(host)) {
throw new SafeOutboundFetchError("callback url hostname is not allowed")
}
const ipVersion = isIP(host)
if (ipVersion === 4 && isMetadataIPv4(host)) {
throw new SafeOutboundFetchError("callback url resolves to a blocked address")
}
if (ipVersion === 6 && isMetadataIPv6(host)) {
if (isIP(host) !== 0 && isBlockedCallbackIp(host)) {
throw new SafeOutboundFetchError("callback url resolves to a blocked address")
}
}
Expand Down Expand Up @@ -113,7 +177,7 @@ const safeLookup = (
return
}
const records = (addresses as dns.LookupAddress[]).filter(
record => !isMetadataIp(record.address)
record => !isBlockedCallbackIp(record.address)
)
if (records.length === 0) {
const blocked = new SafeOutboundFetchError("callback url resolves to a blocked address")
Expand Down
61 changes: 27 additions & 34 deletions src/services/main/debitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ import { Ndebit, NdebitData, NdebitFailure, NdebitSuccess, RecurringDebitTimeUni
import {
debitAccessRulesToDebitRules, newNdebitResponse, debitRulesToDebitAccessRules,
nofferErrors, k1AlreadyProcessedReason, AuthRequiredRes, HandleNdebitRes, expirationRuleName,
frequencyRuleName, IntervalTypeToSeconds, unitToIntervalType
frequencyRuleName, IntervalTypeToSeconds, unitToIntervalType, ndebitFailure,
ValidateAccessRulesResult,
} from "./debitTypes.js";
import { decode as decodeBolt11 } from "light-bolt11-decoder";
import PaymentManager from "./paymentManager.js";

type k1Info = {
k1: string
Expand Down Expand Up @@ -43,11 +44,13 @@ export class DebitManager {
lnd: LND
k1Debouncers: Record<string, K1Debouncer> = {}
interval: NodeJS.Timer
paymentManager: PaymentManager
logger = getLogger({ component: 'DebitManager' })
constructor(storage: Storage, lnd: LND, applicationManager: ApplicationManager) {
constructor(storage: Storage, lnd: LND, applicationManager: ApplicationManager, paymentManager: PaymentManager) {
this.storage = storage
this.lnd = lnd
this.applicationManager = applicationManager
this.paymentManager = paymentManager
this.StartDebounceCleaner()
}

Expand Down Expand Up @@ -163,8 +166,8 @@ export class DebitManager {
const app = await this.storage.applicationStorage.GetApplication(ctx.app_id)
const appUser = await this.storage.applicationStorage.GetApplicationUser(app, ctx.app_user_id)
const validateResult = await this.validateAccessRules(access, app, appUser, invoice)
if (!validateResult) {
this.sendDebitResponse({ res: 'GFY', error: nofferErrors[1], code: 1 }, { pub: npub, id: request_id, appId: ctx.app_id })
if (!validateResult.ok) {
this.sendDebitResponse(validateResult.failure, { pub: npub, id: request_id, appId: ctx.app_id })
return
}
this.logger("🔍 [DEBIT REQUEST] Sending debit payment")
Expand Down Expand Up @@ -339,8 +342,8 @@ export class DebitManager {
return { status: 'fail', debitRes: { res: 'GFY', error: nofferErrors[1], code: 1 } }
}
const validateResult = await this.validateAccessRules(authorization, app, appUser, bolt11)
if (!validateResult) {
return { status: 'fail', debitRes: { res: 'GFY', error: nofferErrors[1], code: 1 } }
if (!validateResult.ok) {
return { status: 'fail', debitRes: validateResult.failure }
}
this.logger("🔍 [DEBIT REQUEST] Sending requested debit payment")
const { payment } = await this.sendDebitPayment(appId, appUserId, requestorPub, bolt11)
Expand All @@ -353,51 +356,41 @@ export class DebitManager {
return { payment }
}

validateAccessRules = async (access: DebitAccess, app: Application, appUser: ApplicationUser, bolt11: string): Promise<boolean> => {
const amt = decodeInvoiceAmount(bolt11)
if (amt === 0) {
return false
validateAccessRules = async (access: DebitAccess, app: Application, appUser: ApplicationUser, bolt11: string): Promise<ValidateAccessRulesResult> => {
const decoded = await this.lnd.DecodeInvoice(bolt11)
const amt = decoded.numSatoshis
if (amt <= 0) {
return { ok: false, failure: ndebitFailure(5) }
}
const { rules } = access
if (!rules) {
return true
return { ok: true }
}
if (rules[expirationRuleName]) {
const [expiration] = rules[expirationRuleName]
if (+expiration < Date.now()) {
if (+expiration < Math.floor(Date.now() / 1000)) {
await this.storage.debitStorage.RemoveDebitAccess(access.app_user_id, access.npub)
return false
return { ok: false, failure: ndebitFailure(3) }
}
}
if (rules[frequencyRuleName]) {
const isManaged = app.owner.user_id !== appUser.user.user_id
const expectedFee = this.paymentManager.getSendServiceFee(Types.UserOperationType.OUTGOING_INVOICE, amt, isManaged)
const [number, unit, max] = rules[frequencyRuleName]
const intervalType = unitToIntervalType(unit as RecurringDebitTimeUnit)
const seconds = IntervalTypeToSeconds(intervalType) * (+number)
const sinceUnix = Math.floor(Date.now() / 1000) - seconds
const payments = await this.storage.paymentStorage.GetUserDebitPayments(appUser.user.user_id, sinceUnix, access.npub)
let total = amt
let total = amt + expectedFee
for (const payment of payments) {
total += payment.paid_amount + payment.service_fees
}
if (total > +max) {
return false
}
}
return true
}
}

const decodeInvoiceAmount = (bolt11: string): number => {
try {
const decoded = decodeBolt11(bolt11)
for (const section of decoded.sections) {
if (section.name === 'amount') {
// Amount is in millisatoshis
return Math.floor(Number(section.value) / 1000)
const cap = +max
if (total > cap) {
this.logger("frequency cap exceeded", { total, cap, amt, paymentCount: payments.length })
return { ok: false, failure: ndebitFailure(5, { max: cap }) }
}
}
return 0
} catch (err: any) {
return 0
return { ok: true }
}
}
}
20 changes: 20 additions & 0 deletions src/services/main/debitTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,26 @@ export const nofferErrors = {

export const k1AlreadyProcessedReason = "K1 already processed"

export type NdebitFailureWithExtras = NdebitFailure & {
range?: { min: number, max: number }
}

export const ndebitFailure = (code: number, opts: { error?: string, max?: number } = {}): NdebitFailureWithExtras => {
const failure: NdebitFailureWithExtras = {
res: 'GFY',
code,
error: opts.error ?? nofferErrors[code as keyof typeof nofferErrors] ?? nofferErrors[1],
}
if (code === 5 && opts.max !== undefined) {
failure.range = { min: 1, max: opts.max }
}
return failure
}

export type ValidateAccessRulesResult =
| { ok: true }
| { ok: false, failure: NdebitFailureWithExtras }

export type AuthRequiredRes = { status: 'authRequired', liveDebitReq: Types.LiveDebitRequest, app: Application, appUser: ApplicationUser }
export type HandleNdebitRes = { status: 'fail', debitRes: NdebitFailure }
| { status: 'invoicePaid', app: Application, appUser: ApplicationUser, debitRes: NdebitSuccess }
Expand Down
Loading
Loading