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:
padreug 2025-09-12 21:06:00 +02:00
parent 2f0cc901eb
commit fc761844b7
7 changed files with 757 additions and 28 deletions

View file

@ -37,6 +37,12 @@ HOSTNAME=
LOG_LEVEL= LOG_LEVEL=
LIGHTNING_NETWORK_DAEMON= 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 # Crypto nodes related variables
## Location info (can be local or remote) ## Location info (can be local or remote)

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

View file

@ -0,0 +1 @@
module.exports = require('./lnbits')

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

View file

@ -0,0 +1,36 @@
const db = require('./db')
exports.up = function (next) {
const sql = `
INSERT INTO user_config (name, display_name, type, data_type, config_type, enabled, secret, options)
VALUES
('lnbitsEndpoint', 'LNBits Server URL', 'text', 'string', 'wallets', false, false, null),
('lnbitsAdminKey', 'LNBits Admin Key', 'text', 'string', 'wallets', false, true, null)
ON CONFLICT (name) DO NOTHING;
-- Add LNBits as a valid wallet option for Lightning Network
INSERT INTO user_config (name, display_name, type, data_type, config_type, enabled, secret, options)
VALUES
('LN_wallet', 'Lightning Network Wallet', 'text', 'string', 'wallets', true, false,
'[{"code": "lnbits", "display": "LNBits"}, {"code": "galoy", "display": "Galoy (Blink)"}, {"code": "bitcoind", "display": "Bitcoin Core"}]')
ON CONFLICT (name)
DO UPDATE SET options = EXCLUDED.options
WHERE user_config.options NOT LIKE '%lnbits%';
`
db.multi(sql, next)
}
exports.down = function (next) {
const sql = `
DELETE FROM user_config
WHERE name IN ('lnbitsEndpoint', 'lnbitsAdminKey');
-- Remove LNBits from wallet options
UPDATE user_config
SET options = REPLACE(options, ', {"code": "lnbits", "display": "LNBits"}', '')
WHERE name = 'LN_wallet';
`
db.multi(sql, next)
}

View file

@ -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()
}
})

View file

@ -1,38 +1,76 @@
#!/usr/bin/env node
const fs = require('fs') const fs = require('fs')
const path = require('path') 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( // Check if .env already exists
path.resolve(__dirname, '../.sample.env'), if (fs.existsSync(envPath)) {
path.resolve(__dirname, '../.env'), 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') // Development defaults
setEnvVariable('POSTGRES_PASSWORD', 'postgres123') const devDefaults = {
setEnvVariable('POSTGRES_HOST', 'localhost') NODE_ENV: 'development',
setEnvVariable('POSTGRES_PORT', '5432')
setEnvVariable('POSTGRES_DB', 'lamassu')
setEnvVariable('CA_PATH', `${process.env.PWD}/certs/Lamassu_OP_Root_CA.pem`) // Database
setEnvVariable('CERT_PATH', `${process.env.PWD}/certs/Lamassu_OP.pem`) POSTGRES_USER: 'lamassu',
setEnvVariable('KEY_PATH', `${process.env.PWD}/certs/Lamassu_OP.key`) POSTGRES_PASSWORD: 'lamassu',
POSTGRES_HOST: 'localhost',
POSTGRES_PORT: '5432',
POSTGRES_DB: 'lamassu',
setEnvVariable( // Paths
'MNEMONIC_PATH', CA_PATH: path.resolve(__dirname, '../Lamassu_CA.pem'),
`${process.env.PWD}/.lamassu/mnemonics/mnemonic.txt`, 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'),
setEnvVariable('BLOCKCHAIN_DIR', `${process.env.PWD}/blockchains`) // Directories
setEnvVariable('OFAC_DATA_DIR', `${process.env.PWD}/.lamassu/ofac`) BLOCKCHAIN_DIR: path.resolve(__dirname, '../../../blockchain'),
setEnvVariable('ID_PHOTO_CARD_DIR', `${process.env.PWD}/.lamassu/idphotocard`) OFAC_DATA_DIR: path.resolve(__dirname, '../../../ofac'),
setEnvVariable('FRONT_CAMERA_DIR', `${process.env.PWD}/.lamassu/frontcamera`) ID_PHOTO_CARD_DIR: path.resolve(__dirname, '../../../photos/idcard'),
setEnvVariable('OPERATOR_DATA_DIR', `${process.env.PWD}/.lamassu/operatordata`) FRONT_CAMERA_DIR: path.resolve(__dirname, '../../../photos/front'),
OPERATOR_DATA_DIR: path.resolve(__dirname, '../../../operator-data'),
setEnvVariable('BTC_NODE_LOCATION', 'remote') // Misc
setEnvVariable('BTC_WALLET_LOCATION', 'local') HOSTNAME: 'localhost',
LOG_LEVEL: 'debug',
setEnvVariable('HOSTNAME', 'localhost') // Bitcoin (for development, use remote node to avoid full sync)
setEnvVariable('LOG_LEVEL', 'debug') 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
}
// Build .env content
let envContent = sampleContent
// Replace empty values with dev defaults
Object.keys(devDefaults).forEach(key => {
const regex = new RegExp(`^${key}=.*$`, 'gm')
envContent = envContent.replace(regex, `${key}=${devDefaults[key]}`)
})
// Write .env file
fs.writeFileSync(envPath, envContent)
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')