feat: add LNBits wallet plugin integration
- Introduced LNBits as a Lightning Network wallet provider for Lamassu ATMs. - Added configuration options for LNBits in the environment variables. - Implemented core functionalities including invoice creation, payment processing, balance monitoring, and payment status tracking. - Created unit tests for the LNBits plugin to ensure functionality and error handling. - Updated development environment setup to include LNBits configuration.
This commit is contained in:
parent
2f0cc901eb
commit
fc761844b7
7 changed files with 757 additions and 28 deletions
144
packages/server/lib/plugins/wallet/lnbits/README.md
Normal file
144
packages/server/lib/plugins/wallet/lnbits/README.md
Normal file
|
|
@ -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.
|
||||
1
packages/server/lib/plugins/wallet/lnbits/index.js
Normal file
1
packages/server/lib/plugins/wallet/lnbits/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
module.exports = require('./lnbits')
|
||||
247
packages/server/lib/plugins/wallet/lnbits/lnbits.js
Normal file
247
packages/server/lib/plugins/wallet/lnbits/lnbits.js
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
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.payment_request) {
|
||||
throw new Error('LNBits did not return a payment request')
|
||||
}
|
||||
|
||||
return result.payment_request
|
||||
}
|
||||
|
||||
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 sendCoins(account, tx) {
|
||||
validateConfig(account)
|
||||
const { toAddress, cryptoAtoms, cryptoCode } = tx
|
||||
|
||||
await checkCryptoCode(cryptoCode)
|
||||
|
||||
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 {
|
||||
fundingAddress,
|
||||
fundingAddressQr: fundingAddress,
|
||||
confirmed: walletBalance.gte(0),
|
||||
confirmedBalance: walletBalance.toString()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue