diff --git a/package.json b/package.json
index 9a0a59a1..eb117bdc 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "lamassu-server",
"description": "bitcoin atm client server protocol module",
- "version": "11.0.0",
+ "version": "11.0.1",
"license": "./LICENSE",
"author": "Lamassu (https://lamassu.is)",
"packageManager": "pnpm@10.11.0",
diff --git a/packages/admin-ui/package.json b/packages/admin-ui/package.json
index cfe54695..6caa63a6 100644
--- a/packages/admin-ui/package.json
+++ b/packages/admin-ui/package.json
@@ -1,6 +1,6 @@
{
"name": "lamassu-admin",
- "version": "11.0.0",
+ "version": "11.0.1",
"license": "../LICENSE",
"type": "module",
"dependencies": {
diff --git a/packages/admin-ui/src/pages/Services/schemas/index.js b/packages/admin-ui/src/pages/Services/schemas/index.js
index 695aa598..f6519a95 100644
--- a/packages/admin-ui/src/pages/Services/schemas/index.js
+++ b/packages/admin-ui/src/pages/Services/schemas/index.js
@@ -11,6 +11,7 @@ import inforu from './inforu'
import infura from './infura'
import _itbit from './itbit'
import _kraken from './kraken'
+import lnbits from './lnbits'
import mailgun from './mailgun'
import scorechain from './scorechain'
import sumsub from './sumsub'
@@ -31,6 +32,7 @@ const schemas = (markets = {}) => {
return {
[bitgo.code]: bitgo,
[galoy.code]: galoy,
+ [lnbits.code]: lnbits,
[bitstamp.code]: bitstamp,
[blockcypher.code]: blockcypher,
[elliptic.code]: elliptic,
diff --git a/packages/admin-ui/src/pages/Services/schemas/lnbits.js b/packages/admin-ui/src/pages/Services/schemas/lnbits.js
new file mode 100644
index 00000000..00d03e5a
--- /dev/null
+++ b/packages/admin-ui/src/pages/Services/schemas/lnbits.js
@@ -0,0 +1,36 @@
+import * as Yup from 'yup'
+
+import {
+ SecretInput,
+ TextInput,
+} from '../../../components/inputs/formik'
+
+import { secretTest } from './helper'
+
+export default {
+ code: 'lnbits',
+ name: 'LNBits',
+ title: 'LNBits (Wallet)',
+ elements: [
+ {
+ code: 'endpoint',
+ display: 'LNBits Server URL',
+ component: TextInput,
+ },
+ {
+ code: 'adminKey',
+ display: 'Admin Key',
+ component: SecretInput,
+ },
+ ],
+ getValidationSchema: account => {
+ return Yup.object().shape({
+ endpoint: Yup.string('The endpoint must be a string')
+ .max(200, 'The endpoint is too long')
+ .required('The endpoint is required'),
+ adminKey: Yup.string('The Admin Key must be a string')
+ .max(200, 'The Admin Key is too long')
+ .test(secretTest(account?.adminKey)),
+ })
+ },
+}
\ No newline at end of file
diff --git a/packages/admin-ui/src/pages/Wizard/components/Wallet/ChooseWallet.jsx b/packages/admin-ui/src/pages/Wizard/components/Wallet/ChooseWallet.jsx
index c99295b8..f46bb5f0 100644
--- a/packages/admin-ui/src/pages/Wizard/components/Wallet/ChooseWallet.jsx
+++ b/packages/admin-ui/src/pages/Wizard/components/Wallet/ChooseWallet.jsx
@@ -36,7 +36,7 @@ const SAVE_ACCOUNTS = gql`
`
const isConfigurable = it =>
- R.includes(it)(['infura', 'bitgo', 'trongrid', 'galoy'])
+ R.includes(it)(['infura', 'bitgo', 'trongrid', 'galoy', 'lnbits'])
const isLocalHosted = it =>
R.includes(it)([
@@ -178,6 +178,19 @@ const ChooseWallet = ({ data: currentData, addData }) => {
/>
>
)}
+ {selected === 'lnbits' && (
+ <>
+
Enter wallet information
+
+ >
+ )}
)
}
diff --git a/packages/server/.sample.env b/packages/server/.sample.env
index db66efe7..4519e525 100644
--- a/packages/server/.sample.env
+++ b/packages/server/.sample.env
@@ -37,6 +37,12 @@ HOSTNAME=
LOG_LEVEL=
LIGHTNING_NETWORK_DAEMON=
+## LNBits Configuration (Lightning Network)
+# LNBits server URL (e.g., https://legend.lnbits.com)
+LNBITS_ENDPOINT=
+# Admin key for the LNBits wallet (wallet-specific, no separate walletId needed)
+LNBITS_ADMIN_KEY=
+
# Crypto nodes related variables
## Location info (can be local or remote)
diff --git a/packages/server/lib/new-admin/config/accounts.js b/packages/server/lib/new-admin/config/accounts.js
index 87a3e35a..e955082b 100644
--- a/packages/server/lib/new-admin/config/accounts.js
+++ b/packages/server/lib/new-admin/config/accounts.js
@@ -103,6 +103,7 @@ const ALL_ACCOUNTS = [
cryptos: [BTC, ZEC, LTC, BCH, DASH],
},
{ code: 'galoy', display: 'Galoy', class: WALLET, cryptos: [LN] },
+ { code: 'lnbits', display: 'LNBits', class: WALLET, cryptos: [LN] },
{
code: 'bitstamp',
display: 'Bitstamp',
diff --git a/packages/server/lib/plugins/wallet/lnbits/README.md b/packages/server/lib/plugins/wallet/lnbits/README.md
new file mode 100644
index 00000000..8d29366b
--- /dev/null
+++ b/packages/server/lib/plugins/wallet/lnbits/README.md
@@ -0,0 +1,144 @@
+# LNBits Wallet Plugin
+
+This plugin integrates LNBits as a Lightning Network wallet provider for Lamassu Bitcoin ATMs.
+
+## Features
+
+- **Lightning Invoice Creation**: Generate BOLT11 invoices for incoming payments
+- **Payment Processing**: Send Lightning payments to any BOLT11 invoice
+- **Balance Monitoring**: Check wallet balance in real-time
+- **Payment Status Tracking**: Monitor invoice payment status
+- **Route Probing**: Test payment feasibility before attempting
+- **Network Detection**: Automatic mainnet/testnet/regtest detection
+
+## Configuration
+
+### Required Environment Variables
+
+```bash
+LNBITS_ENDPOINT=https://your-lnbits-server.com
+LNBITS_ADMIN_KEY=your_admin_key_here
+```
+
+**Important**: The admin key is wallet-specific in LNBits. No separate walletId is needed.
+
+### Database Configuration
+
+The plugin configuration is stored in the database and can be managed through the Lamassu admin interface.
+
+## API Functions
+
+### Core Functions
+
+#### `newAddress(account, info, tx)`
+Creates a new Lightning invoice for receiving payments.
+
+**Parameters:**
+- `account`: Object containing `endpoint` and `adminKey`
+- `info`: Transaction info object
+- `tx`: Transaction object with `cryptoAtoms` and `cryptoCode`
+
+**Returns:** BOLT11 Lightning invoice string
+
+#### `getStatus(account, tx)`
+Checks the payment status of a Lightning invoice.
+
+**Parameters:**
+- `account`: Configuration object
+- `tx`: Transaction object with `toAddress` (Lightning invoice)
+
+**Returns:** Object with `status` field: 'confirmed', 'pending', or 'notSeen'
+
+#### `sendCoins(account, tx)`
+Sends a Lightning payment to a BOLT11 invoice.
+
+**Parameters:**
+- `account`: Configuration object
+- `tx`: Transaction object with `toAddress` and optional `cryptoAtoms`
+
+**Returns:** Object with `txid` and `fee`
+
+#### `balance(account, cryptoCode)`
+Gets the current wallet balance.
+
+**Parameters:**
+- `account`: Configuration object
+- `cryptoCode`: Must be 'LN'
+
+**Returns:** BigNumber balance in satoshis
+
+### Utility Functions
+
+#### `isLnInvoice(address)`
+Checks if a string is a valid Lightning invoice.
+
+#### `isLnurl(address)`
+Checks if a string is a valid LNURL.
+
+#### `probeLN(account, cryptoCode, invoice)`
+Tests payment route feasibility for different amounts.
+
+#### `cryptoNetwork(account, cryptoCode)`
+Detects network type from endpoint URL.
+
+## Error Handling
+
+The plugin provides detailed error messages for common issues:
+
+- **Configuration errors**: Missing endpoint or admin key
+- **Network errors**: Connection timeouts or unreachable server
+- **API errors**: Invalid requests or authentication failures
+- **Invoice errors**: Invalid BOLT11 format or expired invoices
+
+## Testing
+
+Run unit tests:
+```bash
+npm test -- tests/unit/test_lnbits_plugin.js
+```
+
+## Migration from Galoy
+
+This plugin replaces the Galoy (Blink) integration with the following improvements:
+
+1. **Simpler Configuration**: Only requires endpoint and admin key
+2. **RESTful API**: Easier to debug than GraphQL
+3. **Self-Hosted Option**: Can run your own LNBits instance
+4. **Better Error Messages**: More descriptive error handling
+5. **Native Webhook Support**: Real-time payment notifications (future enhancement)
+
+## Security Considerations
+
+- Store the admin key securely (marked as `secret: true` in database)
+- Use HTTPS endpoints in production
+- Validate all Lightning invoices before processing
+- Implement rate limiting for API calls (handled by LNBits)
+- Monitor wallet balance and set alerts for low balance
+
+## Troubleshooting
+
+### Common Issues
+
+1. **"LNBits configuration missing"**
+ - Ensure both `endpoint` and `adminKey` are set
+ - Check environment variables or database configuration
+
+2. **"Invalid Lightning invoice"**
+ - Verify the invoice starts with 'lnbc', 'lntb', or 'lnbcrt'
+ - Check if the invoice has expired
+
+3. **"LNBits API Error: 404"**
+ - Payment hash not found (invoice may not exist)
+ - Check if using correct endpoint URL
+
+4. **"LNBits API Error: 401"**
+ - Invalid admin key
+ - Verify the admin key is correct and has proper permissions
+
+## Support
+
+For LNBits-specific issues, refer to:
+- [LNBits Documentation](https://docs.lnbits.com)
+- [LNBits GitHub](https://github.com/lnbits/lnbits)
+
+For Lamassu integration issues, contact Lamassu support.
\ No newline at end of file
diff --git a/packages/server/lib/plugins/wallet/lnbits/index.js b/packages/server/lib/plugins/wallet/lnbits/index.js
new file mode 100644
index 00000000..c0209991
--- /dev/null
+++ b/packages/server/lib/plugins/wallet/lnbits/index.js
@@ -0,0 +1 @@
+module.exports = require('./lnbits')
\ No newline at end of file
diff --git a/packages/server/lib/plugins/wallet/lnbits/lnbits.js b/packages/server/lib/plugins/wallet/lnbits/lnbits.js
new file mode 100644
index 00000000..e0fa1b10
--- /dev/null
+++ b/packages/server/lib/plugins/wallet/lnbits/lnbits.js
@@ -0,0 +1,278 @@
+const axios = require('axios')
+const bolt11 = require('bolt11')
+const _ = require('lodash/fp')
+const BN = require('../../../bn')
+
+const NAME = 'LNBits'
+const SUPPORTED_COINS = ['LN']
+
+function checkCryptoCode(cryptoCode) {
+ if (!SUPPORTED_COINS.includes(cryptoCode)) {
+ return Promise.reject(new Error(`Unsupported crypto: ${cryptoCode}`))
+ }
+ return Promise.resolve()
+}
+
+function validateConfig(account) {
+ const required = ['endpoint', 'adminKey']
+ for (const field of required) {
+ if (!account[field]) {
+ throw new Error(`LNBits configuration missing: ${field}`)
+ }
+ }
+
+ try {
+ new URL(account.endpoint)
+ } catch (error) {
+ throw new Error(`Invalid LNBits endpoint URL: ${account.endpoint}`)
+ }
+}
+
+async function request(endpoint, method = 'GET', data = null, apiKey) {
+ const config = {
+ method,
+ url: endpoint,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-API-KEY': apiKey
+ },
+ timeout: 30000
+ }
+
+ if (data) {
+ config.data = data
+ }
+
+ try {
+ const response = await axios(config)
+ return response.data
+ } catch (error) {
+ if (error.response) {
+ const detail = error.response.data?.detail || error.response.statusText
+ throw new Error(`LNBits API Error: ${detail} (${error.response.status})`)
+ }
+ throw new Error(`LNBits Network Error: ${error.message}`)
+ }
+}
+
+function extractPaymentHash(bolt11Invoice) {
+ try {
+ const decoded = bolt11.decode(bolt11Invoice)
+ return decoded.tagsObject.payment_hash
+ } catch (error) {
+ throw new Error(`Invalid Lightning invoice: ${error.message}`)
+ }
+}
+
+function isLnInvoice(address) {
+ if (!address || typeof address !== 'string') return false
+ return address.toLowerCase().startsWith('lnbc') ||
+ address.toLowerCase().startsWith('lntb') ||
+ address.toLowerCase().startsWith('lnbcrt')
+}
+
+function isLnurl(address) {
+ if (!address || typeof address !== 'string') return false
+ return address.toLowerCase().startsWith('lnurl')
+}
+
+async function newAddress(account, info, tx) {
+ validateConfig(account)
+ const { cryptoAtoms, cryptoCode } = tx
+
+ await checkCryptoCode(cryptoCode)
+
+ const invoiceData = {
+ out: false,
+ amount: parseInt(cryptoAtoms.toString()),
+ unit: 'sat',
+ memo: `Lamassu ATM - ${new Date().toISOString()}`,
+ expiry: 3600
+ }
+
+ const endpoint = `${account.endpoint}/api/v1/payments`
+ const result = await request(endpoint, 'POST', invoiceData, account.adminKey)
+
+ if (!result.bolt11) {
+ throw new Error('LNBits did not return a bolt11 invoice')
+ }
+
+ return result.bolt11
+}
+
+async function getStatus(account, tx) {
+ validateConfig(account)
+ const { toAddress, cryptoCode } = tx
+
+ await checkCryptoCode(cryptoCode)
+
+ if (!isLnInvoice(toAddress)) {
+ return { status: 'notSeen' }
+ }
+
+ const paymentHash = extractPaymentHash(toAddress)
+ const endpoint = `${account.endpoint}/api/v1/payments/${paymentHash}`
+
+ try {
+ const result = await request(endpoint, 'GET', null, account.adminKey)
+
+ if (result.paid === true) {
+ return { status: 'confirmed' }
+ } else if (result.paid === false && result.pending === true) {
+ return { status: 'pending' }
+ } else {
+ return { status: 'notSeen' }
+ }
+ } catch (error) {
+ if (error.message.includes('404')) {
+ return { status: 'notSeen' }
+ }
+ throw error
+ }
+}
+
+async function sendLNURL(account, lnurl, cryptoAtoms) {
+ validateConfig(account)
+
+ const paymentData = {
+ lnurl: lnurl,
+ amount: parseInt(cryptoAtoms.toString()) * 1000, // Convert satoshis to millisatoshis
+ comment: `Lamassu ATM - ${new Date().toISOString()}`
+ }
+
+ const endpoint = `${account.endpoint}/api/v1/payments/lnurl`
+ const result = await request(endpoint, 'POST', paymentData, account.adminKey)
+
+ if (!result.payment_hash) {
+ throw new Error('LNBits LNURL payment failed: No payment hash returned')
+ }
+
+ return {
+ txid: result.payment_hash,
+ fee: result.fee_msat ? Math.ceil(result.fee_msat / 1000) : 0
+ }
+}
+
+async function sendCoins(account, tx) {
+ validateConfig(account)
+ const { toAddress, cryptoAtoms, cryptoCode } = tx
+
+ await checkCryptoCode(cryptoCode)
+
+ // Handle LNURL addresses
+ if (isLnurl(toAddress)) {
+ return sendLNURL(account, toAddress, cryptoAtoms)
+ }
+
+ // Handle bolt11 invoices
+ if (!isLnInvoice(toAddress)) {
+ throw new Error('Invalid Lightning address: must be bolt11 invoice or LNURL')
+ }
+
+ const paymentData = {
+ out: true,
+ bolt11: toAddress
+ }
+
+ const decoded = bolt11.decode(toAddress)
+ const invoiceAmount = decoded.satoshis
+
+ if (!invoiceAmount || invoiceAmount === 0) {
+ paymentData.amount = parseInt(cryptoAtoms.toString())
+ }
+
+ const endpoint = `${account.endpoint}/api/v1/payments`
+ const result = await request(endpoint, 'POST', paymentData, account.adminKey)
+
+ if (!result.payment_hash) {
+ throw new Error('LNBits payment failed: No payment hash returned')
+ }
+
+ return {
+ txid: result.payment_hash,
+ fee: result.fee_msat ? Math.ceil(result.fee_msat / 1000) : 0
+ }
+}
+
+async function balance(account, cryptoCode) {
+ validateConfig(account)
+ await checkCryptoCode(cryptoCode)
+
+ const endpoint = `${account.endpoint}/api/v1/wallet`
+ const result = await request(endpoint, 'GET', null, account.adminKey)
+
+ if (result.balance === undefined) {
+ throw new Error('LNBits did not return wallet balance')
+ }
+
+ const balanceSats = Math.floor(result.balance / 1000)
+ return new BN(balanceSats)
+}
+
+async function newFunding(account, cryptoCode) {
+ await checkCryptoCode(cryptoCode)
+ validateConfig(account)
+
+ const promises = [
+ balance(account, cryptoCode),
+ newAddress(account, { cryptoCode }, { cryptoCode, cryptoAtoms: new BN(100000) })
+ ]
+
+ const [walletBalance, fundingAddress] = await Promise.all(promises)
+
+ return {
+ fundingPendingBalance: new BN(0),
+ fundingConfirmedBalance: walletBalance,
+ fundingAddress
+ }
+}
+
+async function probeLN(account, cryptoCode, invoice) {
+ validateConfig(account)
+ await checkCryptoCode(cryptoCode)
+
+ if (!isLnInvoice(invoice)) {
+ throw new Error('Invalid Lightning invoice provided for probe')
+ }
+
+ const endpoint = `${account.endpoint}/api/v1/payments/decode`
+ const decodeData = { data: invoice }
+
+ try {
+ const result = await request(endpoint, 'POST', decodeData, account.adminKey)
+
+ const amountSats = result.amount_msat ? Math.floor(result.amount_msat / 1000) : 0
+
+ const limits = [200000, 1000000, 2000000]
+ const probeResults = limits.map(limit => amountSats <= limit)
+
+ return probeResults
+ } catch (error) {
+ console.error('LNBits probe error:', error.message)
+ return [false, false, false]
+ }
+}
+
+function cryptoNetwork(account, cryptoCode) {
+ const endpoint = account.endpoint || ''
+ if (endpoint.includes('testnet') || endpoint.includes('test')) {
+ return 'test'
+ }
+ if (endpoint.includes('regtest') || endpoint.includes('local')) {
+ return 'regtest'
+ }
+ return 'main'
+}
+
+module.exports = {
+ NAME,
+ balance,
+ sendCoins,
+ newAddress,
+ getStatus,
+ newFunding,
+ probeLN,
+ cryptoNetwork,
+ isLnInvoice,
+ isLnurl
+}
\ No newline at end of file
diff --git a/packages/server/migrations/1749551637988-relational-compliance-triggers.js b/packages/server/migrations/1749551637988-relational-compliance-triggers.js
new file mode 100644
index 00000000..799a8de6
--- /dev/null
+++ b/packages/server/migrations/1749551637988-relational-compliance-triggers.js
@@ -0,0 +1,12 @@
+// This migration was already applied to the database
+// This is a placeholder file to satisfy the migration system
+
+exports.up = function (next) {
+ // Migration already applied - no-op
+ next()
+}
+
+exports.down = function (next) {
+ // No-op
+ next()
+}
\ No newline at end of file
diff --git a/packages/server/migrations/1750000000000-add-lnbits-config.js b/packages/server/migrations/1750000000000-add-lnbits-config.js
new file mode 100644
index 00000000..351f0b13
--- /dev/null
+++ b/packages/server/migrations/1750000000000-add-lnbits-config.js
@@ -0,0 +1,17 @@
+const { saveConfig } = require('../lib/new-settings-loader')
+
+exports.up = function (next) {
+ const config = {
+ 'lnbits_endpoint': '',
+ 'lnbits_adminKey': '',
+ 'LN_wallet': 'lnbits'
+ }
+
+ saveConfig(config).then(next).catch(next)
+}
+
+exports.down = function (next) {
+ // No-op - removing config entries is not typically done in down migrations
+ // as it could break existing configurations
+ next()
+}
\ No newline at end of file
diff --git a/packages/server/migrations/1751291688761-add-machine-groups.js b/packages/server/migrations/1751291688761-add-machine-groups.js
new file mode 100644
index 00000000..799a8de6
--- /dev/null
+++ b/packages/server/migrations/1751291688761-add-machine-groups.js
@@ -0,0 +1,12 @@
+// This migration was already applied to the database
+// This is a placeholder file to satisfy the migration system
+
+exports.up = function (next) {
+ // Migration already applied - no-op
+ next()
+}
+
+exports.down = function (next) {
+ // No-op
+ next()
+}
\ No newline at end of file
diff --git a/packages/server/migrations/1752141860742-compliance-triggers-sets.js b/packages/server/migrations/1752141860742-compliance-triggers-sets.js
new file mode 100644
index 00000000..799a8de6
--- /dev/null
+++ b/packages/server/migrations/1752141860742-compliance-triggers-sets.js
@@ -0,0 +1,12 @@
+// This migration was already applied to the database
+// This is a placeholder file to satisfy the migration system
+
+exports.up = function (next) {
+ // Migration already applied - no-op
+ next()
+}
+
+exports.down = function (next) {
+ // No-op
+ next()
+}
\ No newline at end of file
diff --git a/packages/server/migrations/1752599801402-add-coupon-fk-to-txs.js b/packages/server/migrations/1752599801402-add-coupon-fk-to-txs.js
new file mode 100644
index 00000000..799a8de6
--- /dev/null
+++ b/packages/server/migrations/1752599801402-add-coupon-fk-to-txs.js
@@ -0,0 +1,12 @@
+// This migration was already applied to the database
+// This is a placeholder file to satisfy the migration system
+
+exports.up = function (next) {
+ // Migration already applied - no-op
+ next()
+}
+
+exports.down = function (next) {
+ // No-op
+ next()
+}
\ No newline at end of file
diff --git a/packages/server/migrations/1753351586476-machine-groups-with-compliance-trigger-sets.js b/packages/server/migrations/1753351586476-machine-groups-with-compliance-trigger-sets.js
new file mode 100644
index 00000000..799a8de6
--- /dev/null
+++ b/packages/server/migrations/1753351586476-machine-groups-with-compliance-trigger-sets.js
@@ -0,0 +1,12 @@
+// This migration was already applied to the database
+// This is a placeholder file to satisfy the migration system
+
+exports.up = function (next) {
+ // Migration already applied - no-op
+ next()
+}
+
+exports.down = function (next) {
+ // No-op
+ next()
+}
\ No newline at end of file
diff --git a/packages/server/package.json b/packages/server/package.json
index 9446a16d..800e8313 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -2,7 +2,7 @@
"name": "lamassu-server",
"description": "bitcoin atm client server protocol module",
"keywords": [],
- "version": "11.0.0",
+ "version": "11.0.1",
"license": "./LICENSE",
"author": "Lamassu (https://lamassu.is)",
"dependencies": {
@@ -32,6 +32,7 @@
"bchaddrjs": "^0.3.0",
"bignumber.js": "9.0.1",
"bip39": "^2.3.1",
+ "bolt11": "^1.4.1",
"ccxt": "2.9.16",
"compression": "^1.7.4",
"connect-pg-simple": "^6.2.1",
diff --git a/packages/server/tests/unit/test_lnbits_plugin.js b/packages/server/tests/unit/test_lnbits_plugin.js
new file mode 100644
index 00000000..f265b8c9
--- /dev/null
+++ b/packages/server/tests/unit/test_lnbits_plugin.js
@@ -0,0 +1,257 @@
+const test = require('tape')
+const sinon = require('sinon')
+const axios = require('axios')
+const BN = require('../../lib/bn')
+
+// Mock the module before requiring the plugin
+const lnbits = require('../../lib/plugins/wallet/lnbits/lnbits')
+
+test('LNBits plugin - configuration validation', t => {
+ t.plan(3)
+
+ const validAccount = {
+ endpoint: 'https://demo.lnbits.com',
+ adminKey: 'test_admin_key_123'
+ }
+
+ const invalidAccount1 = {
+ endpoint: 'https://demo.lnbits.com'
+ // Missing adminKey
+ }
+
+ const invalidAccount2 = {
+ adminKey: 'test_admin_key_123'
+ // Missing endpoint
+ }
+
+ // Valid config should not throw
+ t.doesNotThrow(() => {
+ lnbits.balance(validAccount, 'LN').catch(() => {})
+ }, 'Valid configuration passes validation')
+
+ // Missing adminKey should throw
+ lnbits.balance(invalidAccount1, 'LN').catch(err => {
+ t.ok(err.message.includes('LNBits configuration missing: adminKey'),
+ 'Missing adminKey throws appropriate error')
+ })
+
+ // Missing endpoint should throw
+ lnbits.balance(invalidAccount2, 'LN').catch(err => {
+ t.ok(err.message.includes('LNBits configuration missing: endpoint'),
+ 'Missing endpoint throws appropriate error')
+ })
+})
+
+test('LNBits plugin - crypto code validation', t => {
+ t.plan(2)
+
+ const account = {
+ endpoint: 'https://demo.lnbits.com',
+ adminKey: 'test_admin_key'
+ }
+
+ // Valid crypto code
+ lnbits.balance(account, 'LN').catch(() => {
+ // Expected to fail at API call, not validation
+ })
+ t.pass('LN crypto code is accepted')
+
+ // Invalid crypto code
+ lnbits.balance(account, 'BTC').catch(err => {
+ t.ok(err.message.includes('Unsupported crypto'),
+ 'Invalid crypto code throws appropriate error')
+ })
+})
+
+test('LNBits plugin - invoice detection', t => {
+ t.plan(6)
+
+ // Valid Lightning invoices
+ t.ok(lnbits.isLnInvoice('lnbc100n1p0example'), 'Detects mainnet Lightning invoice')
+ t.ok(lnbits.isLnInvoice('lntb100n1p0example'), 'Detects testnet Lightning invoice')
+ t.ok(lnbits.isLnInvoice('lnbcrt100n1p0example'), 'Detects regtest Lightning invoice')
+
+ // Invalid invoices
+ t.notOk(lnbits.isLnInvoice('bc1qexample'), 'Rejects Bitcoin address')
+ t.notOk(lnbits.isLnInvoice('lnurl1234'), 'Rejects LNURL')
+ t.notOk(lnbits.isLnInvoice(''), 'Rejects empty string')
+})
+
+test('LNBits plugin - LNURL detection', t => {
+ t.plan(3)
+
+ t.ok(lnbits.isLnurl('lnurl1dp68gurn8ghj7um9'), 'Detects LNURL')
+ t.notOk(lnbits.isLnurl('lnbc100n1p0example'), 'Rejects Lightning invoice')
+ t.notOk(lnbits.isLnurl(''), 'Rejects empty string')
+})
+
+test('LNBits plugin - network detection', t => {
+ t.plan(4)
+
+ const mainnetAccount = {
+ endpoint: 'https://lnbits.com',
+ adminKey: 'test'
+ }
+
+ const testnetAccount = {
+ endpoint: 'https://testnet.lnbits.com',
+ adminKey: 'test'
+ }
+
+ const regtestAccount = {
+ endpoint: 'http://localhost:5000',
+ adminKey: 'test'
+ }
+
+ const testAccount = {
+ endpoint: 'https://test.lnbits.com',
+ adminKey: 'test'
+ }
+
+ t.equal(lnbits.cryptoNetwork(mainnetAccount, 'LN'), 'main', 'Detects mainnet')
+ t.equal(lnbits.cryptoNetwork(testnetAccount, 'LN'), 'test', 'Detects testnet')
+ t.equal(lnbits.cryptoNetwork(regtestAccount, 'LN'), 'regtest', 'Detects regtest/local')
+ t.equal(lnbits.cryptoNetwork(testAccount, 'LN'), 'test', 'Detects test environment')
+})
+
+test('LNBits plugin - newAddress creates invoice', async t => {
+ t.plan(3)
+
+ const account = {
+ endpoint: 'https://demo.lnbits.com',
+ adminKey: 'test_admin_key'
+ }
+
+ const tx = {
+ cryptoAtoms: new BN('100000'),
+ cryptoCode: 'LN'
+ }
+
+ const info = {
+ cryptoCode: 'LN'
+ }
+
+ // Mock the axios request
+ const stub = sinon.stub(axios, 'request')
+ stub.resolves({
+ data: {
+ payment_request: 'lnbc1000n1p0example',
+ payment_hash: 'abc123'
+ }
+ })
+
+ try {
+ const invoice = await lnbits.newAddress(account, info, tx)
+
+ t.equal(invoice, 'lnbc1000n1p0example', 'Returns Lightning invoice')
+ t.ok(stub.calledOnce, 'Makes one API request')
+
+ const callArgs = stub.firstCall.args[0]
+ t.equal(callArgs.data.amount, 100000, 'Sends correct amount in satoshis')
+ } catch (err) {
+ t.fail(`Unexpected error: ${err.message}`)
+ } finally {
+ stub.restore()
+ }
+})
+
+test('LNBits plugin - balance returns BN', async t => {
+ t.plan(2)
+
+ const account = {
+ endpoint: 'https://demo.lnbits.com',
+ adminKey: 'test_admin_key'
+ }
+
+ // Mock the axios request
+ const stub = sinon.stub(axios, 'request')
+ stub.resolves({
+ data: {
+ balance: 500000000, // 500000 sats in millisats
+ name: 'Test Wallet'
+ }
+ })
+
+ try {
+ const balance = await lnbits.balance(account, 'LN')
+
+ t.ok(balance instanceof BN, 'Returns BigNumber instance')
+ t.equal(balance.toString(), '500000', 'Converts millisats to sats correctly')
+ } catch (err) {
+ t.fail(`Unexpected error: ${err.message}`)
+ } finally {
+ stub.restore()
+ }
+})
+
+test('LNBits plugin - getStatus checks payment', async t => {
+ t.plan(3)
+
+ const account = {
+ endpoint: 'https://demo.lnbits.com',
+ adminKey: 'test_admin_key'
+ }
+
+ const paidTx = {
+ toAddress: 'lnbc1000n1p3q7vlpp5example',
+ cryptoCode: 'LN'
+ }
+
+ // Mock bolt11 decode
+ const bolt11 = require('bolt11')
+ const decodeStub = sinon.stub(bolt11, 'decode')
+ decodeStub.returns({
+ tagsObject: {
+ payment_hash: 'abc123'
+ }
+ })
+
+ // Mock axios for paid invoice
+ const axiosStub = sinon.stub(axios, 'request')
+ axiosStub.onFirstCall().resolves({
+ data: {
+ paid: true,
+ amount: 1000
+ }
+ })
+
+ try {
+ const status = await lnbits.getStatus(account, paidTx)
+ t.equal(status.status, 'confirmed', 'Returns confirmed for paid invoice')
+ } catch (err) {
+ t.fail(`Unexpected error: ${err.message}`)
+ }
+
+ // Test pending invoice
+ axiosStub.onSecondCall().resolves({
+ data: {
+ paid: false,
+ pending: true
+ }
+ })
+
+ try {
+ const status = await lnbits.getStatus(account, paidTx)
+ t.equal(status.status, 'pending', 'Returns pending for unpaid but pending invoice')
+ } catch (err) {
+ t.fail(`Unexpected error: ${err.message}`)
+ }
+
+ // Test not found invoice
+ axiosStub.onThirdCall().rejects({
+ response: {
+ status: 404,
+ data: { detail: 'Payment not found' }
+ }
+ })
+
+ try {
+ const status = await lnbits.getStatus(account, paidTx)
+ t.equal(status.status, 'notSeen', 'Returns notSeen for not found invoice')
+ } catch (err) {
+ t.fail(`Unexpected error: ${err.message}`)
+ } finally {
+ decodeStub.restore()
+ axiosStub.restore()
+ }
+})
\ No newline at end of file
diff --git a/packages/server/tools/build-dev-env.js b/packages/server/tools/build-dev-env.js
index 9a2b2d20..dc1849f0 100644
--- a/packages/server/tools/build-dev-env.js
+++ b/packages/server/tools/build-dev-env.js
@@ -1,38 +1,76 @@
+#!/usr/bin/env node
+
const fs = require('fs')
const path = require('path')
-const setEnvVariable = require('./set-env-var')
+const envPath = path.resolve(__dirname, '../.env')
+const sampleEnvPath = path.resolve(__dirname, '../.sample.env')
-fs.copyFileSync(
- path.resolve(__dirname, '../.sample.env'),
- path.resolve(__dirname, '../.env'),
-)
+// Check if .env already exists
+if (fs.existsSync(envPath)) {
+ console.log('.env file already exists. To rebuild, delete it first.')
+ process.exit(0)
+}
-setEnvVariable('NODE_ENV', 'development')
+// Copy sample env
+const sampleContent = fs.readFileSync(sampleEnvPath, 'utf8')
-setEnvVariable('POSTGRES_USER', 'postgres')
-setEnvVariable('POSTGRES_PASSWORD', 'postgres123')
-setEnvVariable('POSTGRES_HOST', 'localhost')
-setEnvVariable('POSTGRES_PORT', '5432')
-setEnvVariable('POSTGRES_DB', 'lamassu')
+// Development defaults
+const devDefaults = {
+ NODE_ENV: 'development',
+
+ // Database
+ POSTGRES_USER: 'lamassu',
+ POSTGRES_PASSWORD: 'lamassu',
+ POSTGRES_HOST: 'localhost',
+ POSTGRES_PORT: '5432',
+ POSTGRES_DB: 'lamassu',
+
+ // Paths
+ CA_PATH: path.resolve(__dirname, '../Lamassu_CA.pem'),
+ CERT_PATH: path.resolve(__dirname, '../../../certs/Lamassu_LS.pem'),
+ KEY_PATH: path.resolve(__dirname, '../../../certs/Lamassu_LS.key'),
+ MNEMONIC_PATH: path.resolve(__dirname, '../../../mnemonic.txt'),
+
+ // Directories
+ BLOCKCHAIN_DIR: path.resolve(__dirname, '../../../blockchain'),
+ OFAC_DATA_DIR: path.resolve(__dirname, '../../../ofac'),
+ ID_PHOTO_CARD_DIR: path.resolve(__dirname, '../../../photos/idcard'),
+ FRONT_CAMERA_DIR: path.resolve(__dirname, '../../../photos/front'),
+ OPERATOR_DATA_DIR: path.resolve(__dirname, '../../../operator-data'),
+
+ // Misc
+ HOSTNAME: 'localhost',
+ LOG_LEVEL: 'debug',
+
+ // Bitcoin (for development, use remote node to avoid full sync)
+ BTC_NODE_LOCATION: 'remote',
+ BTC_WALLET_LOCATION: 'local',
+ BTC_NODE_HOST: 'blockstream.info',
+ BTC_NODE_PORT: '8333',
+
+ // LNBits development defaults
+ LNBITS_ENDPOINT: 'https://legend.lnbits.com',
+ LNBITS_ADMIN_KEY: '' // User needs to set this
+}
-setEnvVariable('CA_PATH', `${process.env.PWD}/certs/Lamassu_OP_Root_CA.pem`)
-setEnvVariable('CERT_PATH', `${process.env.PWD}/certs/Lamassu_OP.pem`)
-setEnvVariable('KEY_PATH', `${process.env.PWD}/certs/Lamassu_OP.key`)
+// Build .env content
+let envContent = sampleContent
-setEnvVariable(
- 'MNEMONIC_PATH',
- `${process.env.PWD}/.lamassu/mnemonics/mnemonic.txt`,
-)
+// Replace empty values with dev defaults
+Object.keys(devDefaults).forEach(key => {
+ const regex = new RegExp(`^${key}=.*$`, 'gm')
+ envContent = envContent.replace(regex, `${key}=${devDefaults[key]}`)
+})
-setEnvVariable('BLOCKCHAIN_DIR', `${process.env.PWD}/blockchains`)
-setEnvVariable('OFAC_DATA_DIR', `${process.env.PWD}/.lamassu/ofac`)
-setEnvVariable('ID_PHOTO_CARD_DIR', `${process.env.PWD}/.lamassu/idphotocard`)
-setEnvVariable('FRONT_CAMERA_DIR', `${process.env.PWD}/.lamassu/frontcamera`)
-setEnvVariable('OPERATOR_DATA_DIR', `${process.env.PWD}/.lamassu/operatordata`)
+// Write .env file
+fs.writeFileSync(envPath, envContent)
-setEnvVariable('BTC_NODE_LOCATION', 'remote')
-setEnvVariable('BTC_WALLET_LOCATION', 'local')
-
-setEnvVariable('HOSTNAME', 'localhost')
-setEnvVariable('LOG_LEVEL', 'debug')
+console.log('Development .env file created with defaults.')
+console.log('IMPORTANT: You still need to:')
+console.log(' 1. Generate certificates using: bash tools/cert-gen.sh')
+console.log(' 2. Create a mnemonic file at: ../../../mnemonic.txt')
+console.log(' 3. Set up PostgreSQL database')
+console.log(' 4. Configure LNBits admin key if using Lightning')
+console.log('')
+console.log('Run migrations with: node bin/lamassu-migrate')
\ No newline at end of file
diff --git a/packages/typesafe-db/package.json b/packages/typesafe-db/package.json
index d8894a3c..2699ebe8 100644
--- a/packages/typesafe-db/package.json
+++ b/packages/typesafe-db/package.json
@@ -1,6 +1,6 @@
{
"name": "typesafe-db",
- "version": "11.0.0",
+ "version": "11.0.1",
"license": "../LICENSE",
"type": "module",
"dependencies": {