Compare commits

..
20 Commits
Author SHA1 Message Date
dni ⚡andGitHub 3b2e28dd60 chore: update to v1.4.0-rc4 (#3675) 2025-12-22 09:27:16 +01:00
132192bc94 test: add boltz fundingsource to regtest (#3677)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-12-22 09:23:46 +01:00
281c3df826 fix: unlimited admin upload (#3679)
Co-authored-by: Arc <33088785+arcbtc@users.noreply.github.com>
2025-12-19 20:11:34 +00:00
Vlad StanandGitHub 08591f34c2 fix: extension spinner not stopping after install (#3680) 2025-12-19 20:01:04 +00:00
ArcandGitHub f0e8ae0f5c Adds support for stripe readers (#3678) 2025-12-19 08:23:56 +01:00
168cb726b1 Make funding source fields more explicit (#3676)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-12-18 13:44:22 +00:00
dni ⚡andGitHub 91ac245307 fix: release lnbits-boltz with proper tag (#3674) 2025-12-17 14:36:29 +01:00
dni ⚡andGitHub d098e2f710 chore: update to v1.4.0-rc3 (#3672) 2025-12-17 13:57:22 +01:00
dni ⚡andGitHub a0f65f4cda fix: Boltzclient fundingsource. use a hashed wallet_name (#3673) 2025-12-17 13:57:05 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
606ee215b4 chore(deps-dev): bump filelock from 3.18.0 to 3.20.1 (#3669)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-17 09:13:18 +01:00
dni ⚡andGitHub 7716f819f6 CI: create github prerelease and appimage (#3668) 2025-12-17 07:21:47 +01:00
arcbtc 8325d880cb Extra precaution
Just in case someone in lnbits org creates a test release on a fork
2025-12-16 13:45:52 +00:00
dni ⚡andGitHub b090470fc1 fix: CI getting release upload url (#3667) 2025-12-16 14:12:00 +01:00
dni ⚡andGitHub c7297d2e77 feat: add the ability to print qrcodes to <lnbits-qrcode> component (#3664) 2025-12-16 12:12:11 +00:00
dni ⚡andGitHub b6e111b21c fix: dont log routes.json 404 (#3665) 2025-12-16 13:10:02 +01:00
dni ⚡andGitHub 7747d7b741 fix: path check for ext pages with arguments (#3660) 2025-12-11 13:56:49 +01:00
Tiago VasconcelosandGitHub f3a5a8e002 fix: account dropdown (#3658) 2025-12-11 13:17:17 +01:00
Tiago VasconcelosandGitHub 157a6485b4 fix: restore the blur on drawer (#3654) 2025-12-11 13:16:32 +01:00
dni ⚡andGitHub 8867b27b09 fix: LOCALE global variable still used in extension (#3659) 2025-12-11 13:03:38 +01:00
dni ⚡andGitHub 68b607ecbc fix: add spacing to the logo (#3653) 2025-12-10 08:08:00 +01:00
34 changed files with 448 additions and 123 deletions
+7
View File
@@ -23,3 +23,10 @@ mypy.ini
package-lock.json
package.json
pytest.ini
.mypy_cache
.github
.pytest_cache
.vscode
bin
dist
-3
View File
@@ -18,7 +18,6 @@ ENABLE_LOG_TO_FILE=true
# https://loguru.readthedocs.io/en/stable/api/logger.html#file
LOG_ROTATION="100 MB"
LOG_RETENTION="3 months"
# for database cleanup commands
# CLEANUP_WALLETS_DAYS=90
@@ -187,7 +186,6 @@ BOLTZ_CLIENT_ENDPOINT=127.0.0.1:9002
BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroons/admin.macaroon"
# HEXSTRING instead of path also possible
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert"
BOLTZ_CLIENT_WALLET="lnbits"
# StrikeWallet
STRIKE_API_ENDPOINT=https://api.strike.me/v1
@@ -333,4 +331,3 @@ LNBITS_RESERVE_FEE_PERCENT=1.0
######################################
###### Logging and Development #######
######################################
+8 -1
View File
@@ -75,7 +75,14 @@ jobs:
strategy:
matrix:
python-version: ["3.10"]
backend-wallet-class: ["LndRestWallet", "LndWallet", "CoreLightningWallet", "CoreLightningRestWallet", "LNbitsWallet", "EclairWallet"]
backend-wallet-class:
- BoltzWallet
- LndRestWallet
- LndWallet
- CoreLightningWallet
- CoreLightningRestWallet
- LNbitsWallet
- EclairWallet
with:
custom-pytest: "uv run pytest tests/regtest"
python-version: ${{ matrix.python-version }}
+1
View File
@@ -62,3 +62,4 @@ jobs:
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
build-args: LNBITS_TAG=${{ inputs.tag }}
+4 -2
View File
@@ -40,8 +40,8 @@ jobs:
run: |
git clone https://github.com/lnbits/legend-regtest-enviroment.git docker
cd docker
chmod +x ./tests
./tests
chmod +x ./start-regtest
./start-regtest
sudo chmod -R a+rwx .
- name: Run pytest
@@ -63,6 +63,8 @@ jobs:
LNBITS_ENDPOINT: http://localhost:5001
LNBITS_KEY: "d08a3313322a4514af75d488bcc27eee"
ECLAIR_URL: http://127.0.0.1:8082
BOLTZ_CLIENT_ENDPOINT: 127.0.0.1:9002
BOLTZ_MNEMONIC: abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
LNBITS_MAX_OUTGOING_PAYMENT_AMOUNT_SATS: 1000000000
LNBITS_MAX_INCOMING_PAYMENT_AMOUNT_SATS: 1000000000
ECLAIR_PASS: lnbits
+30
View File
@@ -10,7 +10,29 @@ permissions:
jobs:
release:
runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
steps:
- uses: actions/checkout@v4
- name: Create github pre-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
gh release create "$tag" --prerelease --generate-notes --draft
- id: get_upload_url
name: Get upload url of Github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
docker:
if: github.repository == 'lnbits/lnbits'
uses: ./.github/workflows/docker.yml
with:
tag: ${{ github.ref_name }}
@@ -19,6 +41,7 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
pypi:
if: github.repository == 'lnbits/lnbits'
runs-on: ubuntu-24.04
steps:
- name: Install dependencies for building secp256k1
@@ -30,3 +53,10 @@ jobs:
uses: JRubics/poetry-publish@v1.15
with:
pypi_token: ${{ secrets.PYPI_API_KEY }}
appimage:
needs: [ release ]
uses: ./.github/workflows/appimage.yml
with:
tag_name: ${{ github.ref_name }}
upload_url: ${{ needs.release.outputs.upload_url }}
+12 -2
View File
@@ -14,7 +14,7 @@ jobs:
release:
runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
steps:
- uses: actions/checkout@v4
- name: Create github release
@@ -22,10 +22,18 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release create "$tag" --generate-notes --draft --json upload_url -q '.upload_url')
gh release create "$tag" --generate-notes --draft
- id: get_upload_url
name: Get upload url of Github release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.ref_name }}
run: |
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
docker:
if: github.repository == 'lnbits/lnbits'
needs: [ release ]
uses: ./.github/workflows/docker.yml
with:
@@ -35,6 +43,7 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
docker-latest:
if: github.repository == 'lnbits/lnbits'
needs: [ release ]
uses: ./.github/workflows/docker.yml
with:
@@ -44,6 +53,7 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
pypi:
if: github.repository == 'lnbits/lnbits'
runs-on: ubuntu-24.04
steps:
- name: Install dependencies for building secp256k1
+3 -2
View File
@@ -1,6 +1,7 @@
FROM boltz/boltz-client:latest AS boltz
ARG LNBITS_TAG=latest
FROM lnbits/lnbits:latest
FROM boltz/boltz-client:latest AS boltz
FROM lnbits/lnbits:${LNBITS_TAG}
COPY --from=boltz /bin/boltzd /bin/boltzcli /usr/local/bin/
RUN ls -l /usr/local/bin/boltzd
+1 -1
View File
@@ -16,7 +16,7 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
if file.content_type.lower() not in settings.lnbits_assets_allowed_mime_types:
raise ValueError(f"File type '{file.content_type}' not allowed.")
if user_id not in settings.lnbits_assets_no_limit_users:
if not settings.is_unlimited_assets_user(user_id):
user_assets_count = await get_user_assets_count(user_id)
if user_assets_count >= settings.lnbits_max_assets_per_user:
raise ValueError(
+2 -2
View File
@@ -337,7 +337,7 @@ async def api_payment(payment_hash, x_api_key: str | None = Header(None)):
return {"paid": False, "status": "failed"}
try:
status = await payment.check_status()
payment = await update_pending_payment(payment)
except Exception:
if wallet and wallet.id == payment.wallet_id:
return {"paid": False, "details": payment}
@@ -346,7 +346,7 @@ async def api_payment(payment_hash, x_api_key: str | None = Header(None)):
if wallet and wallet.id == payment.wallet_id:
return {
"paid": payment.success,
"status": f"{status!s}",
"status": f"{payment.status!s}",
"preimage": payment.preimage,
"details": payment,
}
+3 -1
View File
@@ -138,7 +138,9 @@ def register_exception_handlers(app: FastAPI): # noqa: C901
@app.exception_handler(404)
async def error_handler_404(request: Request, exc: HTTPException):
logger.error(f"404: {request.url.path} {exc.status_code}: {exc.detail}")
if not request.url.path.endswith("routes.json"):
logger.error(f"404: {request.url.path} {exc.status_code}: {exc.detail}")
if not _is_browser_request(request):
return JSONResponse(
+21
View File
@@ -35,6 +35,7 @@ class StripeTerminalOptions(BaseModel):
capture_method: Literal["automatic", "manual"] = "automatic"
metadata: dict[str, str] = Field(default_factory=dict)
reader_id: str | None = None
class StripeCheckoutOptions(BaseModel):
@@ -295,6 +296,15 @@ class StripeWallet(FiatProvider):
r.raise_for_status()
return r.json()
async def _process_terminal_payment_intent(
self, reader_id: str, payment_intent_id: str
) -> None:
data = {"payment_intent": payment_intent_id}
r = await self.client.post(
f"/v1/terminal/readers/{reader_id}/process_payment_intent", data=data
)
r.raise_for_status()
async def _create_checkout_invoice(
self,
amount_cents: int,
@@ -378,6 +388,17 @@ class StripeWallet(FiatProvider):
ok=False,
error_message="Error: missing PaymentIntent or client_secret",
)
if term.reader_id:
try:
await self._process_terminal_payment_intent(term.reader_id, pi_id)
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False,
error_message=(
"Error: unable to process PaymentIntent on reader"
),
)
return FiatInvoiceResponse(
ok=True, checking_id=pi_id, payment_request=client_secret
)
+6 -1
View File
@@ -308,6 +308,12 @@ class AssetSettings(LNbitsSettings):
lnbits_max_assets_per_user: int = Field(default=1, ge=0)
lnbits_assets_no_limit_users: list[str] = Field(default=[])
def is_unlimited_assets_user(self, user_id: str) -> bool:
return (
settings.is_admin_user(user_id)
or user_id in self.lnbits_assets_no_limit_users
)
class FeeSettings(LNbitsSettings):
lnbits_reserve_fee_min: int = Field(default=2000, ge=0)
@@ -601,7 +607,6 @@ class BreezLiquidSdkFundingSource(LNbitsSettings):
class BoltzFundingSource(LNbitsSettings):
boltz_client_endpoint: str | None = Field(default="127.0.0.1:9002")
boltz_client_macaroon: str | None = Field(default=None)
boltz_client_wallet: str | None = Field(default="lnbits")
boltz_client_password: str = Field(default="")
boltz_client_cert: str | None = Field(default=None)
boltz_mnemonic: str | None = Field(default=None)
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -217,6 +217,7 @@ body.body--dark .q-header,
body.body--dark .q-drawer {
--q-dark: rgba(29, 29, 29, 0.3);
background-color: var(--q-dark);
backdrop-filter: blur(6px) brightness(0.8);
}
:root {
@@ -89,8 +89,14 @@ window.app.component('lnbits-admin-funding-sources', {
lnd_rest_cert: 'Certificate',
lnd_rest_macaroon: 'Macaroon',
lnd_rest_macaroon_encrypted: 'Encrypted Macaroon',
lnd_rest_route_hints: 'Enable Route Hints',
lnd_rest_allow_self_payment: 'Allow Self Payment'
lnd_rest_route_hints: {
advanced: true,
label: 'Enable Route Hints'
},
lnd_rest_allow_self_payment: {
advanced: true,
label: 'Allow Self Payment'
}
}
],
[
@@ -160,16 +166,27 @@ window.app.component('lnbits-admin-funding-sources', {
'BoltzWallet',
'Boltz',
{
boltz_client_endpoint: 'Endpoint',
boltz_client_macaroon: 'Admin Macaroon path or hex',
boltz_client_cert: 'Certificate path or hex',
boltz_client_wallet: 'Wallet Name',
boltz_client_password: 'Wallet Password (can be empty)',
boltz_client_endpoint: {
label: 'Boltz client endpoint',
value: '127.0.0.1:9002'
},
boltz_client_macaroon: {
label: 'Admin Macaroon path or hex',
value: '/home/ubuntu/.boltz/macaroons/admin.macaroon'
},
boltz_client_cert: {
label: 'Certificate path or hex',
value: '/home/ubuntu/.boltz/tls.cert'
},
boltz_mnemonic: {
label: 'Liquid mnemonic (copy into greenwallet)',
readonly: true,
label: 'Liquid seed phrase',
hint: 'Boltz will fetch once connected, but you can change later (can be opened in a liquid wallet) ',
copy: true,
qrcode: true
},
boltz_client_password: {
label: 'Wallet Password (optional)',
advanced: true
}
}
],
@@ -13,6 +13,10 @@ window.app.component('lnbits-qrcode', {
type: Boolean,
default: false
},
print: {
type: Boolean,
default: false
},
showButtons: {
type: Boolean,
default: true
@@ -41,6 +45,41 @@ window.app.component('lnbits-qrcode', {
}
},
methods: {
printQrCode() {
const svg = this.$refs.qrCode.$el.outerHTML
const printWindow = window.open('', '_blank')
printWindow.document.write(`
<html>
<head>
<title>Print QR Code</title>
<style>
body {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
img {
position: absolute;
max-width: 51px;
width: 14%;
overflow: hidden;
background: #fff;
overflow: hidden;
padding: -1.2rem;
border-radius: -1.2rem;
}
</style>
</head>
<body>${svg}<img src="${this.logo}"></body>
</html>
`)
printWindow.document.close()
printWindow.focus()
printWindow.print()
printWindow.close()
},
clickQrCode(event) {
if (this.href === '') {
this.utils.copyText(this.value)
+4 -1
View File
@@ -38,7 +38,7 @@ const DynamicComponent = {
let route = RENDERED_ROUTE
// append trailing slash only on the root path `/path` -> `/path/`
if (route.split('/').length === 2) route += '/'
if (route !== this.$route.fullPath) {
if (route !== this.$route.path) {
console.log('Redirecting to non-vue route:', this.$route.fullPath)
window.location = this.$route.fullPath
return
@@ -138,6 +138,9 @@ window.router = VueRouter.createRouter({
routes
})
// BACKWARDS compatibility extensions
window.LOCALE = window.g.locale
window.i18n = new VueI18n.createI18n({
locale: window.g.locale,
fallbackLocale: 'en',
+2 -1
View File
@@ -86,6 +86,7 @@ window.PageExtensions = {
version: release.version
})
.then(response => {
this.selectedExtension.inProgress = false
const extension = this.extensions.find(
ext => ext.id === this.selectedExtension.id
)
@@ -100,7 +101,7 @@ window.PageExtensions = {
})
.catch(err => {
console.warn(err)
extension.inProgress = false
this.selectedExtension.inProgress = false
LNbits.utils.notifyApiError(err)
})
},
+1
View File
@@ -63,5 +63,6 @@ body.body--dark {
.q-drawer {
--q-dark: #{color.adjust(#1d1d1d, $alpha: -0.7)};
background-color: var(--q-dark);
backdrop-filter: blur(6px) brightness(0.8);
}
}
+1 -1
View File
@@ -534,7 +534,7 @@ include('components/lnbits-error.vue') %}
<q-tab name="bech32" icon="qr_code" label="bech32"></q-tab>
<q-tab name="lud17" icon="link" label="url (lud17)"></q-tab>
</q-tabs>
<lnbits-qrcode :value="lnurl" nfc="true"></lnbits-qrcode>
<lnbits-qrcode :value="lnurl" nfc="true" print="true"></lnbits-qrcode>
</div>
</template>
@@ -35,7 +35,9 @@
>
<div
class="row"
v-for="([key, prop], i) in Object.entries(fundingSources.get(fund))"
v-for="([key, prop], i) in Object.entries(
fundingSources.get(fund)
).filter(([, p]) => !p.advanced)"
:key="i"
>
<div class="col-12">
@@ -46,7 +48,7 @@
:type="hideInput ? 'password' : 'text'"
:label="prop.label"
:hint="prop.hint"
:readonly="prop.readonly || false"
:value="prop.value"
>
<q-btn
v-if="prop.copy"
@@ -69,6 +71,55 @@
</q-input>
</div>
</div>
<q-expansion-item
v-if="
Object.entries(fundingSources.get(fund)).some(([, p]) => p.advanced)
"
dense
expand-separator
icon="tune"
label="Advanced"
class="q-mt-sm"
>
<div
class="row"
v-for="([key, prop], i) in Object.entries(
fundingSources.get(fund)
).filter(([, p]) => p.advanced)"
:key="`adv-${i}`"
>
<div class="col-12">
<q-input
v-model="formData[key]"
filled
class="q-mt-sm"
:type="hideInput ? 'password' : 'text'"
:label="prop.label"
:hint="prop.hint"
:readonly="prop.readonly || false"
>
<q-btn
v-if="prop.copy"
@click="utils.copyText(formData[key])"
icon="content_copy"
class="cursor-pointer"
color="grey"
flat
dense
></q-btn>
<q-btn
v-if="prop.qrcode"
@click="showQRValue(formData[key])"
icon="qr_code"
class="cursor-pointer"
color="grey"
flat
dense
></q-btn>
</q-input>
</div>
</div>
</q-expansion-item>
</div>
</q-list>
<q-dialog v-model="showQRDialog">
+15 -11
View File
@@ -22,7 +22,7 @@
@click="g.visibleDrawer = !g.visibleDrawer"
></q-btn>
<q-toolbar-title>
<q-btn flat no-caps dense size="lg" type="a" href="/">
<q-btn flat no-caps dense class="q-mr-sm" size="lg" type="a" href="/">
<q-img
v-if="customLogoUrl"
height="30px"
@@ -73,32 +73,36 @@
<lnbits-language-dropdown></lnbits-language-dropdown>
<q-btn-dropdown
v-if="g.user || g.isUserAuthorized"
flat
rounded
size="sm"
class="q-pl-sm"
>
<q-btn-dropdown v-if="g.user" flat rounded size="sm" class="q-pl-sm">
<template v-slot:label>
<q-avatar v-if="g.user?.extra?.picture !== ''" size="18px">
<q-avatar
v-if="g.user?.extra?.picture && g.user?.extra?.picture !== ''"
size="18px"
>
<q-img :src="g.user?.extra?.picture"></q-img>
</q-avatar>
<q-avatar v-else icon="account_circle" size="18px"></q-avatar>
</template>
<q-list style="max-width: 200px">
<q-item>
<q-item-section avatar v-if="g.user?.extra?.picture !== ''">
<q-item-section
avatar
v-if="
g.user &&
g.user?.extra?.picture &&
g.user?.extra?.picture !== ''
"
>
<q-avatar size="md">
<img :src="g.user?.extra?.picture" />
</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label
caption
class="ellipsis"
v-text="displayName"
></q-item-label>
<q-item-label caption v-text="displayRole"></q-item-label>
</q-item-section>
</q-item>
<q-separator></q-separator>
@@ -44,6 +44,16 @@
>
<q-tooltip>Write NFC Tag</q-tooltip>
</q-btn>
<q-btn
v-if="print"
flat
dense
class="text-grey"
@click="printQrCode()"
icon="print"
>
<q-tooltip>Print</q-tooltip>
</q-btn>
<q-btn flat dense class="text-grey" icon="download" @click="downloadSVG">
<q-tooltip>Download SVG</q-tooltip>
</q-btn>
+137 -59
View File
@@ -5,7 +5,7 @@ from bolt11.decode import decode
from grpc.aio import AioRpcError
from loguru import logger
from lnbits.helpers import normalize_endpoint
from lnbits.helpers import normalize_endpoint, sha256s
from lnbits.settings import settings
from lnbits.wallets.boltz_grpc_files import boltzrpc_pb2, boltzrpc_pb2_grpc
from lnbits.wallets.lnd_grpc_files.lightning_pb2_grpc import grpc
@@ -38,21 +38,18 @@ class BoltzWallet(Wallet):
raise ValueError(
"cannot initialize BoltzWallet: missing boltz_client_endpoint"
)
if not settings.boltz_client_wallet:
raise ValueError(
"cannot initialize BoltzWallet: missing boltz_client_wallet"
)
self.endpoint = normalize_endpoint(
settings.boltz_client_endpoint, add_proto=True
)
self.metadata = None
if settings.boltz_client_macaroon:
self.metadata = [
("macaroon", load_macaroon(settings.boltz_client_macaroon))
]
else:
self.metadata = None
try:
macaroon = load_macaroon(settings.boltz_client_macaroon)
self.metadata = [("macaroon", macaroon)]
except Exception as e:
logger.error(f"BoltzWallet failed to load macaroon: {e}")
if settings.boltz_client_cert:
cert = open(settings.boltz_client_cert, "rb").read()
@@ -63,31 +60,21 @@ class BoltzWallet(Wallet):
self.rpc = boltzrpc_pb2_grpc.BoltzStub(channel)
self.wallet_id = 0
# Auto-create wallet if running in Docker mode
async def _init_boltz_wallet():
try:
wallet_name = settings.boltz_client_wallet or "lnbits"
mnemonic = await self._fetch_wallet(wallet_name)
if mnemonic:
logger.info(
"✅ Mnemonic found for Boltz wallet, saving to settings"
)
settings.boltz_mnemonic = mnemonic
from lnbits.core.crud.settings import set_settings_field
await set_settings_field("boltz_mnemonic", mnemonic)
else:
logger.warning("⚠️ No mnemonic returned from Boltz")
except Exception as e:
logger.error(f"❌ Failed to auto-create Boltz wallet: {e}")
self._init_wallet_task = asyncio.create_task(_init_boltz_wallet())
self.wallet_name = "lnbits"
self.wallet_ready = False
async def status(self) -> StatusResponse:
if self.wallet_ready is False:
self.wallet_ready = True
if settings.boltz_mnemonic: # restore wallet from mnemonic
await self._restore_boltz_wallet(
settings.boltz_mnemonic,
settings.boltz_client_password,
)
else: # create new wallet
await self._create_boltz_wallet()
try:
request = boltzrpc_pb2.GetWalletRequest(name=settings.boltz_client_wallet)
request = boltzrpc_pb2.GetWalletRequest(name=self.wallet_name)
response: boltzrpc_pb2.Wallet = await self.rpc.GetWallet(
request, metadata=self.metadata
)
@@ -126,12 +113,18 @@ class BoltzWallet(Wallet):
try:
response = await self.rpc.CreateReverseSwap(request, metadata=self.metadata)
except AioRpcError as exc:
logger.warning(exc)
return InvoiceResponse(ok=False, error_message=exc.details())
fee_msat = response.routing_fee_milli_sat
return InvoiceResponse(
ok=True, checking_id=response.id, payment_request=response.invoice
ok=True,
checking_id=response.id,
payment_request=response.invoice,
fee_msat=fee_msat,
)
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
pair = boltzrpc_pb2.Pair(**{"from": boltzrpc_pb2.LBTC})
try:
pair_info: boltzrpc_pb2.PairInfo
@@ -143,8 +136,9 @@ class BoltzWallet(Wallet):
if not invoice.amount_msat:
raise ValueError("amountless invoice")
service_fee: float = invoice.amount_msat * pair_info.fees.percentage / 100
estimate = service_fee + pair_info.fees.miner_fees * 1000
estimate = int(service_fee + pair_info.fees.miner_fees * 1000)
if estimate > fee_limit_msat:
error = f"fee of {estimate} msat exceeds limit of {fee_limit_msat} msat"
@@ -166,11 +160,12 @@ class BoltzWallet(Wallet):
if response.id == "":
# note that there is no way to provide a checking id here,
# but there is no need since it immediately is considered as successfull
return PaymentResponse(
ok=True,
checking_id=response.id,
logger.warning(
"Boltz invoice paid directly on liquid network using magic routing"
)
return PaymentResponse(ok=True, checking_id=invoice.payment_hash)
except AioRpcError as exc:
logger.warning(exc)
return PaymentResponse(ok=False, error_message=exc.details())
try:
@@ -180,10 +175,15 @@ class BoltzWallet(Wallet):
info_request, metadata=self.metadata
):
if info.swap.state == boltzrpc_pb2.SUCCESSFUL:
fee_msat = (info.swap.onchain_fee + info.swap.service_fee) * 1000
logger.debug(
f"Boltz swap successful, status: {info.swap.status}"
f"fee_msat: {fee_msat}"
)
return PaymentResponse(
ok=True,
checking_id=response.id,
fee_msat=(info.swap.onchain_fee + info.swap.service_fee) * 1000,
checking_id=invoice.payment_hash,
fee_msat=fee_msat,
preimage=info.swap.preimage,
)
elif info.swap.error != "":
@@ -192,21 +192,29 @@ class BoltzWallet(Wallet):
ok=False, error_message="stream stopped unexpectedly"
)
except AioRpcError as exc:
logger.warning(exc)
return PaymentResponse(ok=False, error_message=exc.details())
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
request = boltzrpc_pb2.GetSwapInfoRequest(id=checking_id)
response: boltzrpc_pb2.GetSwapInfoResponse = await self.rpc.GetSwapInfo(
boltzrpc_pb2.GetSwapInfoRequest(id=checking_id), metadata=self.metadata
request,
metadata=self.metadata,
)
swap = response.reverse_swap
except AioRpcError:
except AioRpcError as exc:
logger.warning(exc)
return PaymentPendingStatus()
if swap.state == boltzrpc_pb2.SwapState.SUCCESSFUL:
fee_msat = (
swap.service_fee + swap.onchain_fee
) * 1000 + swap.routing_fee_msat
logger.debug(
f"Boltz swap successful, status: {swap.status}, fee_msat: {fee_msat}"
)
return PaymentSuccessStatus(
fee_msat=(
(swap.service_fee + swap.onchain_fee) * 1000 + swap.routing_fee_msat
),
fee_msat=fee_msat,
preimage=swap.preimage,
)
elif swap.state == boltzrpc_pb2.SwapState.PENDING:
@@ -216,18 +224,23 @@ class BoltzWallet(Wallet):
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
try:
checking_id_bytes = bytes.fromhex(checking_id)
request = boltzrpc_pb2.GetSwapInfoRequest(payment_hash=checking_id_bytes)
response: boltzrpc_pb2.GetSwapInfoResponse = await self.rpc.GetSwapInfo(
boltzrpc_pb2.GetSwapInfoRequest(
payment_hash=bytes.fromhex(checking_id)
),
request,
metadata=self.metadata,
)
swap = response.swap
except AioRpcError:
except AioRpcError as exc:
logger.warning(exc)
return PaymentPendingStatus()
if swap.state == boltzrpc_pb2.SwapState.SUCCESSFUL:
fee_msat = (swap.service_fee + swap.onchain_fee) * 1000
logger.debug(
f"Boltz swap successful, status: {swap.status}, fee_msat: {fee_msat}"
)
return PaymentSuccessStatus(
fee_msat=(swap.service_fee + swap.onchain_fee) * 1000,
fee_msat=fee_msat,
preimage=swap.preimage,
)
elif swap.state == boltzrpc_pb2.SwapState.PENDING:
@@ -244,7 +257,16 @@ class BoltzWallet(Wallet):
request, metadata=self.metadata
):
reverse = info.reverse_swap
if reverse and reverse.state == boltzrpc_pb2.SUCCESSFUL:
if (
reverse
and reverse.state == boltzrpc_pb2.SUCCESSFUL
and reverse.status == "invoice.settled"
):
fee_msat = ((reverse.service_fee + reverse.onchain_fee) * 1000,)
logger.debug(
f"Boltz reverse swap settled: {reverse.id}, "
f"fee_msat: {fee_msat}"
)
yield reverse.id
except Exception as exc:
logger.error(
@@ -253,24 +275,80 @@ class BoltzWallet(Wallet):
)
await asyncio.sleep(5)
async def _fetch_wallet(self, wallet_name: str) -> str | None:
async def _check_wallet_exists(
self, wallet_name: str
) -> boltzrpc_pb2.Wallet | None:
try:
request = boltzrpc_pb2.GetWalletRequest(name=wallet_name)
response = await self.rpc.GetWallet(request, metadata=self.metadata)
logger.info(f"Wallet '{wallet_name}' already exists with ID {response.id}")
return settings.boltz_mnemonic
except AioRpcError as exc:
details = exc.details() or "unknown error"
if exc.code() != grpc.StatusCode.NOT_FOUND:
logger.error(f"Error checking wallet existence: {details}")
raise
return response
except AioRpcError:
return None
async def _delete_wallet(self, wallet_id: int) -> None:
logger.info(f"Deleting wallet '{wallet_id}'")
delete_request = boltzrpc_pb2.RemoveWalletRequest(id=wallet_id)
await self.rpc.RemoveWallet(delete_request, metadata=self.metadata)
async def _restore_wallet(self, wallet_name: str, mnemonic: str, password: str):
logger.info(f"Restoring wallet '{wallet_name}' from mnemonic")
credentials = boltzrpc_pb2.WalletCredentials(mnemonic=mnemonic)
params = boltzrpc_pb2.WalletParams(
name=wallet_name,
currency=boltzrpc_pb2.LBTC,
password=password,
)
restore_request = boltzrpc_pb2.ImportWalletRequest(
credentials=credentials, params=params
)
response = await self.rpc.ImportWallet(restore_request, metadata=self.metadata)
return response
async def _create_wallet(self, wallet_name: str, password: str) -> str:
logger.info(f"Creating new wallet '{wallet_name}'")
params = boltzrpc_pb2.WalletParams(
name=wallet_name,
currency=boltzrpc_pb2.LBTC,
password=settings.boltz_client_password,
password=password,
)
create_request = boltzrpc_pb2.CreateWalletRequest(params=params)
response = await self.rpc.CreateWallet(create_request, metadata=self.metadata)
return response.mnemonic
async def _restore_boltz_wallet(self, mnemonic: str, password: str):
try:
# delete the wallet with to name without hashing first if it exists
wallet = await self._check_wallet_exists(self.wallet_name)
if wallet:
await self._delete_wallet(wallet.id)
# recreate the wallet with the hashed name
self.wallet_name = sha256s(mnemonic + password)
wallet = await self._check_wallet_exists(self.wallet_name)
if wallet:
logger.success("✅ Boltz wallet exists.")
return
await self._restore_wallet(self.wallet_name, mnemonic, password)
logger.success("✅ Boltz wallet restored from existing mnemonic")
except Exception as e:
logger.error(f"❌ Failed to restore Boltz wallet: {e}")
async def _create_boltz_wallet(self):
try:
# delete the wallet with to name without hashing first if it exists
wallet = await self._check_wallet_exists(self.wallet_name)
if wallet:
await self._delete_wallet(wallet.id)
logger.info("No Mnemonic found for Boltz wallet, creating wallet...")
mnemonic = await self._create_wallet(
self.wallet_name, settings.boltz_client_password
)
logger.success("✅ Boltz wallet created successfully")
settings.boltz_mnemonic = mnemonic
from lnbits.core.crud.settings import set_settings_field
await set_settings_field("boltz_mnemonic", mnemonic)
except Exception as e:
logger.error(f"❌ Failed to create Boltz wallet: {e}")
Generated
+4 -9
View File
@@ -1333,21 +1333,16 @@ pyjwt = ">=2.10.1,<3.0.0"
[[package]]
name = "filelock"
version = "3.18.0"
version = "3.20.1"
description = "A platform independent file lock."
optional = false
python-versions = ">=3.9"
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de"},
{file = "filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2"},
{file = "filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a"},
{file = "filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c"},
]
[package.extras]
docs = ["furo (>=2024.8.6)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"]
testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-asyncio (>=0.25.2)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.28.1)"]
typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""]
[[package]]
name = "filetype"
version = "1.2.0"
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "lnbits"
version = "1.4.0-rc2"
version = "1.4.0-rc4"
requires-python = ">=3.10,<3.13"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
+2 -2
View File
@@ -331,8 +331,8 @@ def _settings_cleanup(settings: Settings):
settings.lnbits_allowed_users = []
settings.auth_allowed_methods = AuthMethods.all()
settings.auth_credetials_update_threshold = 120
settings.lnbits_reserve_fee_percent = 1
settings.lnbits_reserve_fee_min = 2000
settings.lnbits_reserve_fee_percent = 2
settings.lnbits_reserve_fee_min = 20000
settings.lnbits_service_fee = 0
settings.lnbits_reserve_fee_percent = 0
settings.lnbits_wallet_limit_daily_max_withdraw = 0
+22 -1
View File
@@ -6,6 +6,11 @@ from subprocess import PIPE, Popen, TimeoutExpired
from loguru import logger
from lnbits.wallets import get_funding_source
funding_source = get_funding_source()
is_boltz_wallet = funding_source.__class__.__name__ == "BoltzWallet"
docker_lightning_cli = [
"docker",
"exec",
@@ -27,6 +32,16 @@ docker_bitcoin_cli = [
]
docker_elements_cli = [
"docker",
"exec",
"lnbits-elementsd-1",
"elements-cli",
"-rpcport=18884",
"-chain=liquidregtest",
]
docker_lightning_unconnected_cli = [
"docker",
"exec",
@@ -50,7 +65,7 @@ docker_lightning_noroute_cli = [
def run_cmd(cmd: list) -> str:
timeout = 10
timeout = 30
process = Popen(cmd, stdout=PIPE, stderr=PIPE)
logger.debug(f"running command: {cmd}")
@@ -128,6 +143,12 @@ def mine_blocks(blocks: int = 1) -> str:
return run_cmd(cmd)
def mine_blocks_liquid(blocks: int = 1) -> str:
cmd = docker_elements_cli.copy()
cmd.extend(["-generate", str(blocks)])
return run_cmd(cmd)
def get_unconnected_node_uri() -> str:
cmd = docker_lightning_unconnected_cli.copy()
cmd.append("getinfo")
+12 -4
View File
@@ -18,6 +18,7 @@ from ..helpers import FakeError, is_fake, is_regtest
from .helpers import (
cancel_invoice,
get_real_invoice,
mine_blocks_liquid,
pay_real_invoice,
settle_invoice,
)
@@ -66,7 +67,7 @@ async def test_pay_real_invoice(
await asyncio.sleep(1)
balance = await get_node_balance_sats()
assert prev_balance - balance == 100
assert prev_balance - balance == 100 + abs(payment.fee // 1000)
@pytest.mark.anyio
@@ -153,7 +154,8 @@ async def test_create_real_invoice(client, adminkey_headers_from, inkey_headers_
async def on_paid(payment: Payment):
assert payment.checking_id == invoice["payment_hash"]
assert payment.payment_hash == invoice["payment_hash"]
assert payment.checking_id == invoice["checking_id"]
response = await client.get(
f'/api/v1/payments/{invoice["payment_hash"]}', headers=inkey_headers_from
@@ -164,7 +166,8 @@ async def test_create_real_invoice(client, adminkey_headers_from, inkey_headers_
await asyncio.sleep(1)
balance = await get_node_balance_sats()
assert balance - prev_balance == create_invoice.amount
fee = abs(payment_status.get("details", {}).get("fee", 0) // 1000)
assert balance - prev_balance == create_invoice.amount - fee
assert payment_status.get("preimage") is not None
@@ -200,6 +203,8 @@ async def test_pay_real_invoice_set_pending_and_check_state(
assert len(invoice["payment_hash"]) == 64
assert len(invoice["checking_id"]) > 0
mine_blocks_liquid(1)
await asyncio.sleep(1)
# check the payment status
response = await client.get(
f'/api/v1/payments/{invoice["payment_hash"]}', headers=inkey_headers_from
@@ -341,6 +346,7 @@ async def test_pay_hold_invoice_check_pending_and_fail_cancel_payment_task_in_me
assert preimage_hash == invoice_obj.payment_hash
cancel_invoice(preimage_hash)
mine_blocks_liquid(1)
# check if paid
await asyncio.sleep(1)
@@ -382,7 +388,9 @@ async def test_receive_real_invoice_set_pending_and_check_state(
assert not payment_status["paid"]
async def on_paid(payment: Payment):
assert payment.checking_id == invoice["payment_hash"]
assert payment.payment_hash == invoice["payment_hash"]
assert payment.checking_id == invoice["checking_id"]
response = await client.get(
f'/api/v1/payments/{invoice["payment_hash"]}', headers=inkey_headers_from
+10 -2
View File
@@ -7,6 +7,8 @@ from lnbits.core.services import (
from lnbits.wallets import get_funding_source
from lnbits.wallets.base import PaymentStatus
from .helpers import is_boltz_wallet
description = "test create invoice"
@@ -17,7 +19,10 @@ async def test_create_invoice(from_wallet):
amount=1000,
memo=description,
)
assert payment.preimage
# we cannot know the preimage of the swap yet
if not is_boltz_wallet:
assert payment.preimage
invoice = decode(payment.bolt11)
assert invoice.payment_hash == payment.payment_hash
@@ -35,7 +40,10 @@ async def test_create_internal_invoice(from_wallet):
payment = await create_invoice(
wallet_id=from_wallet.id, amount=1000, memo=description, internal=True
)
assert payment.preimage
# we cannot know the preimage of the swap yet
if not is_boltz_wallet:
assert payment.preimage
invoice = decode(payment.bolt11)
assert invoice.payment_hash == payment.payment_hash
+7 -2
View File
@@ -6,6 +6,8 @@ from lnbits.core.services import (
)
from lnbits.exceptions import PaymentError
from .helpers import is_boltz_wallet
description = "test pay invoice"
@@ -17,9 +19,12 @@ async def test_services_pay_invoice(to_wallet, real_invoice):
description=description,
)
assert payment
assert payment.status == PaymentState.SUCCESS
assert payment.memo == description
assert payment.preimage
if not is_boltz_wallet:
assert payment.status == PaymentState.SUCCESS
assert payment.preimage
else:
assert payment.status == PaymentState.PENDING
@pytest.mark.anyio
Generated
+1 -1
View File
@@ -1247,7 +1247,7 @@ wheels = [
[[package]]
name = "lnbits"
version = "1.4.0rc2"
version = "1.4.0rc4"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },