From cba8c02d6878c3b51585bc9cd0e2d699fa3cb456 Mon Sep 17 00:00:00 2001 From: Amperstrand <141745238+Amperstrand@users.noreply.github.com> Date: Tue, 8 Jul 2025 21:01:36 +0200 Subject: [PATCH] feat: Add support for the new CLNRest as a funding source (#2778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Amperstrand Co-authored-by: dni ⚡ Co-authored-by: Vlad Stan --- .env.example | 16 +- docs/guide/wallets.md | 19 + lnbits/settings.py | 14 + lnbits/static/bundle-components.min.js | 2 +- .../js/components/lnbits-funding-sources.js | 18 +- lnbits/wallets/__init__.py | 2 + lnbits/wallets/clnrest.py | 498 ++++++++++++++++++ 7 files changed, 566 insertions(+), 3 deletions(-) create mode 100644 lnbits/wallets/clnrest.py diff --git a/.env.example b/.env.example index c6557bdef..0de8de268 100644 --- a/.env.example +++ b/.env.example @@ -40,7 +40,7 @@ PORT=5000 ###################################### # which fundingsources are allowed in the admin ui -# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet" +# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet" LNBITS_BACKEND_WALLET_CLASS=VoidWallet # VoidWallet is just a fallback that works without any actual Lightning capabilities, @@ -61,6 +61,20 @@ CLICHE_ENDPOINT=ws://127.0.0.1:12000 SPARK_URL=http://localhost:9737/rpc SPARK_TOKEN=myaccesstoken +#CLNRest (using runes) +CLNREST_URL=https://127.0.0.1:3010 +CLNREST_CA=/home/lightningd/.lightning/bitcoin/ca.pem +CLNREST_CERT=/home/lightningd/.lightning/bitcoin/server.pem +# CLNREST_CA = cat ca.pem | awk '{printf "%s\\n", $0} END {printf "\n"}' +# CLNREST_CERT = cat server.pem | awk '{printf "%s\\n", $0} END {printf "\n"}' + +CLNREST_READONLY_RUNE=lightning-cli createrune restrictions='[["method=listfunds", "method=listpays", "method=listinvoices", "method=getinfo", "method=summary", "method=waitanyinvoice"]]' | jq -r .rune +CLNREST_INVOICE_RUNE=lightning-cli createrune restrictions='[["method=invoice"], ["pnameamount_msat<1000001"], ["pnamelabel^LNbits"], ["rate=60"]]' | jq -r .rune +CLNREST_PAY_RUNE=lightning-cli createrune restrictions='[["method=pay"], ["pinvbolt11_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune +#CLNREST_RENEPAY_RUNE=lightning-cli createrune restrictions='[["method=renepay"], ["pinvinvstring_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune +#CLNREST_LAST_PAY_INDEX='lightning-cli listinvoices | jq -r '.invoices | map(.created_index) | max' +#CLNREST_NODEID=lightning-cli getinfo | jq -r .id # only required for v23.08 + # CoreLightningWallet CORELIGHTNING_RPC="/home/bob/.lightning/bitcoin/lightning-rpc" diff --git a/docs/guide/wallets.md b/docs/guide/wallets.md index 1c84585b4..76dc64920 100644 --- a/docs/guide/wallets.md +++ b/docs/guide/wallets.md @@ -12,6 +12,23 @@ A backend wallet can be configured using the following LNbits environment variab You can [compare the LNbits compatible Lightning Network funding sources here](wallets.md). +### CLNRest (using [runes](https://docs.corelightning.org/reference/lightning-createrune)) + +[Core lightning Rest API docs](https://docs.corelightning.org/docs/rest) + +Should also work with the [Rust version of CLNRest](https://github.com/daywalker90/clnrest-rs) + +- `LNBITS_BACKEND_WALLET_CLASS`: **CLNRestWallet** +- `CLNREST_URL`: `https://127.0.0.1:3010` +- `CLNREST_CA`: `/home/lightningd/.lightning/bitcoin/ca.pem` (or the content of the `ca.pem` file) +- `CLNREST_CERT`: `/home/lightningd/.lightning/bitcoin/server.pem` (or the content of the `server.pem` file) +- `CLNREST_READONLY_RUNE`: `lightning-cli createrune restrictions='[["method=listfunds", "method=listpays", "method=listinvoices", "method=getinfo", "method=summary", "method=waitanyinvoice"]]' | jq -r .rune` +- `CLNREST_INVOICE_RUNE`: `lightning-cli createrune restrictions='[["method=invoice"], ["pnameamount_msat<1000001"], ["pnamelabel^LNbits"], ["rate=60"]]' | jq -r .rune` +- `CLNREST_PAY_RUNE`: `lightning-cli createrune restrictions='[["method=pay"], ["pinvbolt11_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune` +- `CLNREST_RENEPAY_RUNE`: `lightning-cli createrune restrictions='[["method=renepay"], ["pinvinvstring_amount<1001"], ["pnamelabel^LNbits"], ["rate=1"]]' | jq -r .rune` +- `CLNREST_LAST_PAY_INDEX`: `lightning-cli listinvoices | jq -r '.invoices | map(.created_index) | max' ` +- `CLNREST_NODEID`: `lightning-cli getinfo | jq -r .id` (only required for v23.08) + ### CoreLightning - `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningWallet** @@ -19,6 +36,8 @@ You can [compare the LNbits compatible Lightning Network funding sources here](w ### CoreLightning REST +This is the old REST interface that uses [Ride The Lightning/c-lightning-REST](https://github.com/Ride-The-Lightning/c-lightning-REST) + - `LNBITS_BACKEND_WALLET_CLASS`: **CoreLightningRestWallet** - `CORELIGHTNING_REST_URL`: http://127.0.0.1:8185/ - `CORELIGHTNING_REST_MACAROON`: /file/path/admin.macaroon or Base64/Hex diff --git a/lnbits/settings.py b/lnbits/settings.py index 1eb104b98..ff2fa13ff 100644 --- a/lnbits/settings.py +++ b/lnbits/settings.py @@ -449,6 +449,18 @@ class ClicheFundingSource(LNbitsSettings): cliche_endpoint: str | None = Field(default=None) +class CLNRestFundingSource(LNbitsSettings): + clnrest_url: str | None = Field(default=None) + clnrest_ca: str | None = Field(default=None) + clnrest_cert: str | None = Field(default=None) + clnrest_readonly_rune: str | None = Field(default=None) + clnrest_invoice_rune: str | None = Field(default=None) + clnrest_pay_rune: str | None = Field(default=None) + clnrest_renepay_rune: str | None = Field(default=None) + clnrest_last_pay_index: str | None = Field(default=None) + clnrest_nodeid: str | None = Field(default=None) + + class CoreLightningFundingSource(LNbitsSettings): corelightning_rpc: str | None = Field(default=None) corelightning_pay_command: str = Field(default="pay") @@ -608,6 +620,7 @@ class FundingSourcesSettings( FakeWalletFundingSource, LNbitsFundingSource, ClicheFundingSource, + CLNRestFundingSource, CoreLightningFundingSource, CoreLightningRestFundingSource, EclairFundingSource, @@ -933,6 +946,7 @@ class SuperUserSettings(LNbitsSettings): "BlinkWallet", "BreezSdkWallet", "BreezLiquidSdkWallet", + "CLNRestWallet", "CoreLightningRestWallet", "CoreLightningWallet", "EclairWallet", diff --git a/lnbits/static/bundle-components.min.js b/lnbits/static/bundle-components.min.js index f1a49c3cb..4c2317b52 100644 --- a/lnbits/static/bundle-components.min.js +++ b/lnbits/static/bundle-components.min.js @@ -1 +1 @@ -window.app.component("lnbits-funding-sources",{template:"#lnbits-funding-sources",mixins:[window.windowMixin],props:["form-data","allowed-funding-sources"],methods:{getFundingSourceLabel(t){const e=this.rawFundingSources.find((e=>e[0]===t));return e?e[1]:t},showQRValue(t){this.qrValue=t,this.showQRDialog=!0}},computed:{fundingSources(){let t=[];for(const[e,i,s]of this.rawFundingSources){const i={};if(null!==s)for(let[t,e]of Object.entries(s))i[t]="string"==typeof e?{label:e,value:null}:e||{};t.push([e,i])}return new Map(t)},sortedAllowedFundingSources(){return this.allowedFundingSources.sort()}},data:()=>({hideInput:!0,showQRDialog:!1,qrValue:"",rawFundingSources:[["VoidWallet","Void Wallet",null],["FakeWallet","Fake Wallet",{fake_wallet_secret:"Secret",lnbits_denomination:'"sats" or 3 Letter Custom Denomination'}],["CoreLightningWallet","Core Lightning",{corelightning_rpc:"Endpoint",corelightning_pay_command:"Custom Pay Command"}],["CoreLightningRestWallet","Core Lightning Rest",{corelightning_rest_url:"Endpoint",corelightning_rest_cert:"Certificate",corelightning_rest_macaroon:"Macaroon"}],["LndRestWallet","Lightning Network Daemon (LND Rest)",{lnd_rest_endpoint:"Endpoint",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"}],["LndWallet","Lightning Network Daemon (LND)",{lnd_grpc_endpoint:"Endpoint",lnd_grpc_cert:"Certificate",lnd_grpc_port:"Port",lnd_grpc_admin_macaroon:"Admin Macaroon",lnd_grpc_macaroon_encrypted:"Encrypted Macaroon"}],["LnTipsWallet","LN.Tips",{lntips_api_endpoint:"Endpoint",lntips_api_key:"API Key"}],["LNPayWallet","LN Pay",{lnpay_api_endpoint:"Endpoint",lnpay_api_key:"API Key",lnpay_wallet_key:"Wallet Key"}],["EclairWallet","Eclair (ACINQ)",{eclair_url:"URL",eclair_pass:"Password"}],["LNbitsWallet","LNbits",{lnbits_endpoint:"Endpoint",lnbits_key:"Admin Key"}],["BlinkWallet","Blink",{blink_api_endpoint:"Endpoint",blink_ws_endpoint:"WebSocket",blink_token:"Key"}],["AlbyWallet","Alby",{alby_api_endpoint:"Endpoint",alby_access_token:"Key"}],["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_mnemonic:{label:"Liquid mnemonic (copy into greenwallet)",readonly:!0,copy:!0,qrcode:!0}}],["ZBDWallet","ZBD",{zbd_api_endpoint:"Endpoint",zbd_api_key:"Key"}],["PhoenixdWallet","Phoenixd",{phoenixd_api_endpoint:"Endpoint",phoenixd_api_password:"Key"}],["OpenNodeWallet","OpenNode",{opennode_api_endpoint:"Endpoint",opennode_key:"Key"}],["ClicheWallet","Cliche (NBD)",{cliche_endpoint:"Endpoint"}],["SparkWallet","Spark",{spark_url:"Endpoint",spark_token:"Token"}],["NWCWallet","Nostr Wallet Connect",{nwc_pairing_url:"Pairing URL"}],["BreezSdkWallet","Breez SDK",{breez_api_key:"Breez API Key",breez_greenlight_seed:"Greenlight Seed",breez_greenlight_device_key:"Greenlight Device Key",breez_greenlight_device_cert:"Greenlight Device Cert",breez_greenlight_invite_code:"Greenlight Invite Code"}],["StrikeWallet","Strike (alpha)",{strike_api_endpoint:"API Endpoint",strike_api_key:"API Key"}],["BreezLiquidSdkWallet","Breez Liquid SDK",{breez_liquid_api_key:"Breez API Key (can be empty)",breez_liquid_seed:"Liquid seed phrase",breez_liquid_fee_offset_sat:"Offset amount in sats to increase fee limit"}]]})}),window.app.component("lnbits-extension-settings-form",{name:"lnbits-extension-settings-form",template:"#lnbits-extension-settings-form",props:["options","adminkey","endpoint"],methods:{async updateSettings(){if(!this.settings)return Quasar.Notify.create({message:"No settings to update",type:"negative"});try{const{data:t}=await LNbits.api.request("PUT",this.endpoint,this.adminkey,this.settings);this.settings=t}catch(t){LNbits.utils.notifyApiError(t)}},async getSettings(){try{const{data:t}=await LNbits.api.request("GET",this.endpoint,this.adminkey);this.settings=t}catch(t){LNbits.utils.notifyApiError(t)}},async resetSettings(){LNbits.utils.confirmDialog("Are you sure you want to reset the settings?").onOk((async()=>{try{await LNbits.api.request("DELETE",this.endpoint,this.adminkey),await this.getSettings()}catch(t){LNbits.utils.notifyApiError(t)}}))}},async created(){await this.getSettings()},data:()=>({settings:void 0})}),window.app.component("lnbits-extension-settings-btn-dialog",{template:"#lnbits-extension-settings-btn-dialog",name:"lnbits-extension-settings-btn-dialog",props:["options","adminkey","endpoint"],data:()=>({show:!1})}),window.app.component("payment-list",{name:"payment-list",template:"#payment-list",props:["update","lazy","wallet"],mixins:[window.windowMixin],data(){return{denomination:LNBITS_DENOMINATION,payments:[],paymentsTable:{columns:[{name:"time",align:"left",label:this.$t("memo")+"/"+this.$t("date"),field:"date",sortable:!0},{name:"amount",align:"right",label:this.$t("amount")+" ("+LNBITS_DENOMINATION+")",field:"sat",sortable:!0}],pagination:{rowsPerPage:10,page:1,sortBy:"time",descending:!0,rowsNumber:10},search:"",filter:{"status[ne]":"failed"},loading:!1},searchDate:{from:null,to:null},searchStatus:{success:!0,pending:!0,failed:!1,incoming:!0,outgoing:!0},exportTagName:"",exportPaymentTagList:[],paymentsCSV:{columns:[{name:"pending",align:"left",label:"Pending",field:"pending"},{name:"memo",align:"left",label:this.$t("memo"),field:"memo"},{name:"time",align:"left",label:this.$t("date"),field:"date",sortable:!0},{name:"amount",align:"right",label:this.$t("amount")+" ("+LNBITS_DENOMINATION+")",field:"sat",sortable:!0},{name:"fee",align:"right",label:this.$t("fee")+" (m"+LNBITS_DENOMINATION+")",field:"fee"},{name:"tag",align:"right",label:this.$t("tag"),field:"tag"},{name:"payment_hash",align:"right",label:this.$t("payment_hash"),field:"payment_hash"},{name:"payment_proof",align:"right",label:this.$t("payment_proof"),field:"payment_proof"},{name:"webhook",align:"right",label:this.$t("webhook"),field:"webhook"},{name:"fiat_currency",align:"right",label:"Fiat Currency",field:t=>t.extra.wallet_fiat_currency},{name:"fiat_amount",align:"right",label:"Fiat Amount",field:t=>t.extra.wallet_fiat_amount}],loading:!1}}},computed:{currentWallet(){return this.wallet||this.g.wallet},filteredPayments(){const t=this.paymentsTable.search;return t&&""!==t?LNbits.utils.search(this.payments,t):this.payments},paymentsOmitter(){return this.$q.screen.lt.md&&this.mobileSimple?this.payments.length>0?[this.payments[0]]:[]:this.payments},pendingPaymentsExist(){return-1!==this.payments.findIndex((t=>t.pending))}},methods:{searchByDate(){"string"==typeof this.searchDate&&(this.searchDate={from:this.searchDate,to:this.searchDate}),this.searchDate.from&&(this.paymentsTable.filter["time[ge]"]=this.searchDate.from+"T00:00:00"),this.searchDate.to&&(this.paymentsTable.filter["time[le]"]=this.searchDate.to+"T23:59:59"),this.fetchPayments()},clearDateSeach(){this.searchDate={from:null,to:null},delete this.paymentsTable.filter["time[ge]"],delete this.paymentsTable.filter["time[le]"],this.fetchPayments()},fetchPayments(t){this.$emit("filter-changed",{...this.paymentsTable.filter});const e=LNbits.utils.prepareFilterQuery(this.paymentsTable,t);return LNbits.api.getPayments(this.currentWallet,e).then((t=>{this.paymentsTable.loading=!1,this.paymentsTable.pagination.rowsNumber=t.data.total,this.payments=t.data.data.map((t=>LNbits.map.payment(t)))})).catch((t=>{this.paymentsTable.loading=!1,g.user.admin?this.fetchPaymentsAsAdmin(this.currentWallet.id,e):LNbits.utils.notifyApiError(t)}))},fetchPaymentsAsAdmin(t,e){return e=(e||"")+"&wallet_id="+t,LNbits.api.request("GET","/api/v1/payments/all/paginated?"+e).then((t=>{this.paymentsTable.loading=!1,this.paymentsTable.pagination.rowsNumber=t.data.total,this.payments=t.data.data.map((t=>LNbits.map.payment(t)))})).catch((t=>{this.paymentsTable.loading=!1,LNbits.utils.notifyApiError(t)}))},checkPayment(t){LNbits.api.getPayment(this.g.wallet,t).then((t=>{this.update=!this.update,"success"==t.data.status&&Quasar.Notify.create({type:"positive",message:this.$t("payment_successful")}),"pending"==t.data.status&&Quasar.Notify.create({type:"info",message:this.$t("payment_pending")})})).catch(LNbits.utils.notifyApiError)},paymentTableRowKey:t=>t.payment_hash+t.amount,exportCSV(t=!1){const e=this.paymentsTable.pagination,i={sortby:e.sortBy??"time",direction:e.descending?"desc":"asc"},s=new URLSearchParams(i);LNbits.api.getPayments(this.g.wallet,s).then((e=>{let i=e.data.data.map(LNbits.map.payment),s=this.paymentsCSV.columns;if(t){this.exportPaymentTagList.length&&(i=i.filter((t=>this.exportPaymentTagList.includes(t.tag))));const t=Object.keys(i.reduce(((t,e)=>({...t,...e.details})),{})).map((t=>({name:t,align:"right",label:t.charAt(0).toUpperCase()+t.slice(1).replace(/([A-Z])/g," $1"),field:e=>e.details[t],format:t=>"object"==typeof t?JSON.stringify(t):t})));s=this.paymentsCSV.columns.concat(t)}LNbits.utils.exportCSV(s,i,this.g.wallet.name+"-payments")}))},addFilterTag(){if(!this.exportTagName)return;const t=this.exportTagName.trim();this.exportPaymentTagList=this.exportPaymentTagList.filter((e=>e!==t)),this.exportPaymentTagList.push(t),this.exportTagName=""},removeExportTag(t){this.exportPaymentTagList=this.exportPaymentTagList.filter((e=>e!==t))},formatCurrency(t,e){try{return LNbits.utils.formatCurrency(t,e)}catch(e){return console.error(e),`${t} ???`}},handleFilterChanged(){const{success:t,pending:e,failed:i,incoming:s,outgoing:n}=this.searchStatus;delete this.paymentsTable.filter["status[ne]"],delete this.paymentsTable.filter["status[eq]"],t&&e&&i||(t&&e?this.paymentsTable.filter["status[ne]"]="failed":t&&i?this.paymentsTable.filter["status[ne]"]="pending":i&&e?this.paymentsTable.filter["status[ne]"]="success":t?this.paymentsTable.filter["status[eq]"]="success":e?this.paymentsTable.filter["status[eq]"]="pending":i&&(this.paymentsTable.filter["status[eq]"]="failed")),delete this.paymentsTable.filter["amount[ge]"],delete this.paymentsTable.filter["amount[le]"],s&&n||(s?this.paymentsTable.filter["amount[ge]"]=0:n&&(this.paymentsTable.filter["amount[le]"]=0))}},watch:{"paymentsTable.search":{handler(){const t={};this.paymentsTable.search&&(t.search=this.paymentsTable.search),this.fetchPayments()}},lazy(t){!0===t&&this.fetchPayments()},update(){this.fetchPayments()},"g.updatePayments"(){this.fetchPayments()},"g.wallet":{handler(t){this.fetchPayments()},deep:!0}},created(){void 0===this.lazy&&this.fetchPayments()}}),window.app.component(QrcodeVue),window.app.component("lnbits-extension-rating",{template:"#lnbits-extension-rating",name:"lnbits-extension-rating",props:["rating"]}),window.app.component("lnbits-fsat",{template:"{{ fsat }}",props:{amount:{type:Number,default:0}},computed:{fsat(){return LNbits.utils.formatSat(this.amount)}}}),window.app.component("lnbits-wallet-list",{mixins:[window.windowMixin],template:"#lnbits-wallet-list",props:["balance"],data:()=>({activeWallet:null,balance:0,showForm:!1,walletName:"",LNBITS_DENOMINATION:LNBITS_DENOMINATION}),methods:{createWallet(){LNbits.api.createWallet(this.g.user.wallets[0],this.walletName)}},created(){document.addEventListener("updateWalletBalance",this.updateWalletBalance)}}),window.app.component("lnbits-extension-list",{mixins:[window.windowMixin],template:"#lnbits-extension-list",data:()=>({extensions:[],searchTerm:""}),watch:{"g.user.extensions":{handler(t){this.loadExtensions()},deep:!0}},computed:{userExtensions(){return this.updateUserExtensions(this.searchTerm)}},methods:{async loadExtensions(){try{const{data:t}=await LNbits.api.request("GET","/api/v1/extension");this.extensions=t.map((t=>LNbits.map.extension(t))).sort(((t,e)=>t.name.localeCompare(e.name)))}catch(t){LNbits.utils.notifyApiError(t)}},updateUserExtensions(t){const e=window.location.pathname,i=this.g.user.extensions;return this.extensions.filter((t=>i.includes(t.code))).filter((e=>!t||`${e.code} ${e.name} ${e.short_description} ${e.url}`.toLocaleLowerCase().includes(t.toLocaleLowerCase()))).map((t=>(t.isActive=e.startsWith(t.url),t)))}},async created(){await this.loadExtensions()}}),window.app.component("lnbits-manage",{mixins:[window.windowMixin],template:"#lnbits-manage",props:["showAdmin","showNode","showExtensions","showUsers","showAudit"],methods:{isActive:t=>window.location.pathname===t},data:()=>({extensions:[]})}),window.app.component("lnbits-payment-details",{mixins:[window.windowMixin],template:"#lnbits-payment-details",props:["payment"],mixins:[window.windowMixin],data:()=>({LNBITS_DENOMINATION:LNBITS_DENOMINATION}),computed:{hasPreimage(){return this.payment.preimage&&"0000000000000000000000000000000000000000000000000000000000000000"!==this.payment.preimage},hasExpiry(){return!!this.payment.expiry},hasSuccessAction(){return this.hasPreimage&&this.payment.extra&&this.payment.extra.success_action},webhookStatusColor(){return this.payment.webhook_status>=300||this.payment.webhook_status<0?"red-10":this.payment.webhook_status?"green-10":"cyan-7"},webhookStatusText(){return this.payment.webhook_status?this.payment.webhook_status:"not sent yet"},hasTag(){return this.payment.extra&&!!this.payment.extra.tag},extras(){if(!this.payment.extra)return[];let t=_.omit(this.payment.extra,["tag","success_action"]);return Object.keys(t).map((e=>({key:e,value:t[e]})))}}}),window.app.component("lnbits-lnurlpay-success-action",{mixins:[window.windowMixin],template:"#lnbits-lnurlpay-success-action",props:["payment","success_action"],data(){return{decryptedValue:this.success_action.ciphertext}},mounted(){if("aes"!==this.success_action.tag)return null;decryptLnurlPayAES(this.success_action,this.payment.preimage).then((t=>{this.decryptedValue=t}))}}),window.app.component("lnbits-qrcode",{mixins:[window.windowMixin],template:"#lnbits-qrcode",components:{QrcodeVue:QrcodeVue},props:{value:{type:String,required:!0},options:Object},data:()=>({custom:{margin:3,width:350,size:350,logo:LNBITS_QR_LOGO}}),created(){this.custom={...this.custom,...this.options}}}),window.app.component("lnbits-notifications-btn",{template:"#lnbits-notifications-btn",mixins:[window.windowMixin],props:["pubkey"],data:()=>({isSupported:!1,isSubscribed:!1,isPermissionGranted:!1,isPermissionDenied:!1}),methods:{urlB64ToUint8Array(t){const e=(t+"=".repeat((4-t.length%4)%4)).replace(/\-/g,"+").replace(/_/g,"/"),i=atob(e),s=new Uint8Array(i.length);for(let t=0;te!==t)),this.$q.localStorage.set("lnbits.webpush.subscribedUsers",JSON.stringify(e))},isUserSubscribed(t){return(JSON.parse(this.$q.localStorage.getItem("lnbits.webpush.subscribedUsers"))||[]).includes(t)},subscribe(){this.isSupported&&!this.isPermissionDenied&&(Notification.requestPermission().then((t=>{this.isPermissionGranted="granted"===t,this.isPermissionDenied="denied"===t})).catch(console.log),navigator.serviceWorker.ready.then((t=>{navigator.serviceWorker.getRegistration().then((t=>{t.pushManager.getSubscription().then((e=>{if(null===e||!this.isUserSubscribed(this.g.user.id)){const e={applicationServerKey:this.urlB64ToUint8Array(this.pubkey),userVisibleOnly:!0};t.pushManager.subscribe(e).then((t=>{LNbits.api.request("POST","/api/v1/webpush",null,{subscription:JSON.stringify(t)}).then((t=>{this.saveUserSubscribed(t.data.user),this.isSubscribed=!0})).catch(LNbits.utils.notifyApiError)}))}})).catch(console.log)}))})))},unsubscribe(){navigator.serviceWorker.ready.then((t=>{t.pushManager.getSubscription().then((t=>{t&&LNbits.api.request("DELETE","/api/v1/webpush?endpoint="+btoa(t.endpoint),null).then((()=>{this.removeUserSubscribed(this.g.user.id),this.isSubscribed=!1})).catch(LNbits.utils.notifyApiError)}))})).catch(console.log)},checkSupported(){let t="https:"===window.location.protocol,e="serviceWorker"in navigator,i="Notification"in window,s="PushManager"in window;return this.isSupported=t&&e&&i&&s,this.isSupported||console.log("Notifications disabled because requirements are not met:",{HTTPS:t,"Service Worker API":e,"Notification API":i,"Push API":s}),this.isSupported},async updateSubscriptionStatus(){await navigator.serviceWorker.ready.then((t=>{t.pushManager.getSubscription().then((t=>{this.isSubscribed=!!t&&this.isUserSubscribed(this.g.user.id)}))})).catch(console.log)}},created(){this.isPermissionDenied="denied"===Notification.permission,this.checkSupported()&&this.updateSubscriptionStatus()}}),window.app.component("lnbits-dynamic-fields",{template:"#lnbits-dynamic-fields",mixins:[window.windowMixin],props:["options","modelValue"],data:()=>({formData:null,rules:[t=>!!t||"Field is required"]}),methods:{applyRules(t){return t?this.rules:[]},buildData(t,e={}){return t.reduce(((t,i)=>(i.options?.length?t[i.name]=this.buildData(i.options,e[i.name]):t[i.name]=e[i.name]??i.default,t)),{})},handleValueChanged(){this.$emit("update:model-value",this.formData)}},created(){this.formData=this.buildData(this.options,this.modelValue)}}),window.app.component("lnbits-dynamic-chips",{template:"#lnbits-dynamic-chips",mixins:[window.windowMixin],props:["modelValue"],data:()=>({chip:"",chips:[]}),methods:{addChip(){this.chip&&(this.chips.push(this.chip),this.chip="",this.$emit("update:model-value",this.chips.join(",")))},removeChip(t){this.chips.splice(t,1),this.$emit("update:model-value",this.chips.join(","))}},created(){"string"==typeof this.modelValue?this.chips=this.modelValue.split(","):this.chips=[...this.modelValue]}}),window.app.component("lnbits-update-balance",{template:"#lnbits-update-balance",mixins:[window.windowMixin],props:["wallet_id","small_btn"],computed:{denomination:()=>LNBITS_DENOMINATION,admin:()=>user.super_user},data:()=>({credit:0}),methods:{updateBalance(t){LNbits.api.updateBalance(t.value,this.wallet_id).then((e=>{if(!0!==e.data.success)throw new Error(e.data);credit=parseInt(t.value),Quasar.Notify.create({type:"positive",message:this.$t("credit_ok",{amount:credit}),icon:null}),this.credit=0,t.value=0,t.set()})).catch(LNbits.utils.notifyApiError)}}}),window.app.component("user-id-only",{template:"#user-id-only",mixins:[window.windowMixin],props:{allowed_new_users:Boolean,authAction:String,authMethod:String,usr:String,wallet:String},data(){return{user:this.usr,walletName:this.wallet}},methods:{showLogin(t){this.$emit("show-login",t)},showRegister(t){this.$emit("show-register",t)},loginUsr(){this.$emit("update:usr",this.user),this.$emit("login-usr")},createWallet(){this.$emit("update:wallet",this.walletName),this.$emit("create-wallet")}},computed:{showInstantLogin(){return"username-password"!==this.authMethod||"register"!==this.authAction}},created(){}}),window.app.component("username-password",{template:"#username-password",mixins:[window.windowMixin],props:{allowed_new_users:Boolean,authMethods:Array,authAction:String,username:String,password_1:String,password_2:String,resetKey:String},data(){return{oauth:["nostr-auth-nip98","google-auth","github-auth","keycloak-auth"],username:this.userName,password:this.password_1,passwordRepeat:this.password_2,reset_key:this.resetKey,keycloakOrg:LNBITS_AUTH_KEYCLOAK_ORG||"Keycloak",keycloakIcon:LNBITS_AUTH_KEYCLOAK_ICON}},methods:{login(){this.$emit("update:userName",this.username),this.$emit("update:password_1",this.password),this.$emit("login")},register(){this.$emit("update:userName",this.username),this.$emit("update:password_1",this.password),this.$emit("update:password_2",this.passwordRepeat),this.$emit("register")},reset(){this.$emit("update:resetKey",this.reset_key),this.$emit("update:password_1",this.password),this.$emit("update:password_2",this.passwordRepeat),this.$emit("reset")},validateUsername:t=>new RegExp("^(?=[a-zA-Z0-9._]{2,20}$)(?!.*[_.]{2})[^_.].*[^_.]$").test(t),async signInWithNostr(){try{const t=await this.createNostrToken();if(!t)return;resp=await LNbits.api.loginByProvider("nostr",{Authorization:t},{}),window.location.href="/wallet"}catch(t){console.warn(t);const e=t?.response?.data?.detail||`${t}`;Quasar.Notify.create({type:"negative",message:"Failed to sign in with Nostr.",caption:e})}},async createNostrToken(){try{if(!window.nostr?.signEvent)return void Quasar.Notify.create({type:"negative",message:"No Nostr signing app detected.",caption:'Is "window.nostr" present?'});const t=`${window.location}nostr`,e="POST",i=await NostrTools.nip98.getToken(t,e,(t=>async function(t){try{const{data:e}=await LNbits.api.getServerHealth();return t.created_at=e.server_time,await window.nostr.signEvent(t)}catch(t){console.error(t),Quasar.Notify.create({type:"negative",message:"Failed to sign nostr event.",caption:`${t}`})}}(t)),!0);if(!await NostrTools.nip98.validateToken(i,t,e))throw new Error("Invalid signed token!");return i}catch(t){console.warn(t),Quasar.Notify.create({type:"negative",message:"Failed create Nostr event.",caption:`${t}`})}}},computed:{showOauth(){return this.oauth.some((t=>this.authMethods.includes(t)))}},created(){}}),window.app.component("separator-text",{template:"#separator-text",props:{text:String,uppercase:{type:Boolean,default:!1},color:{type:String,default:"grey"}}});const DynamicComponent={props:{fetchUrl:{type:String,required:!0},scripts:{type:Array,default:()=>[]}},data:()=>({keys:[]}),async mounted(){await this.loadDynamicContent()},methods:{loadScript:async t=>new Promise(((e,i)=>{const s=document.querySelector(`script[src="${t}"]`);s&&s.remove();const n=document.createElement("script");n.src=t,n.async=!0,n.onload=e,n.onerror=()=>i(new Error(`Failed to load script: ${t}`)),document.head.appendChild(n)})),async loadDynamicContent(){this.$q.loading.show();try{const t=this.fetchUrl.split("#")[0],e=await fetch(t,{credentials:"include",headers:{Accept:"text/html","X-Requested-With":"XMLHttpRequest"}}),i=await e.text(),s=new DOMParser,n=s.parseFromString(i,"text/html").querySelector("#window-vars-script");n&&new Function(n.innerHTML)(),await this.loadScript("/static/js/base.js");for(const t of this.scripts)await this.loadScript(t);const a=this.$router.currentRoute.value.meta.previousRouteName;a&&window.app._context.components[a]&&delete window.app._context.components[a];const o=`${this.$route.name}PageLogic`,r=window[o];if(!r)throw new Error(`Component logic '${o}' not found. Ensure it is defined in the script.`);r.mixins=r.mixins||[],window.windowMixin&&r.mixins.push(window.windowMixin),window.app.component(this.$route.name,{...r,template:i}),delete window[o],this.$forceUpdate()}catch(t){console.error("Error loading dynamic content:",t)}finally{this.$q.loading.hide()}}},watch:{$route(t,e){routes.map((t=>t.name)).includes(t.name)?(this.$router.currentRoute.value.meta.previousRouteName=e.name,this.loadDynamicContent()):console.log(`Route '${t.name}' is not valid. Leave this one to Fastapi.`)}},template:'\n \n '},routes=[{path:"/wallet",name:"Wallet",component:DynamicComponent,props:t=>{let e="/wallet";if(Object.keys(t.query).length>0){e+="?";for(const[i,s]of Object.entries(t.query))e+=`${i}=${s}&`;e=e.slice(0,-1)}return{fetchUrl:e,scripts:["/static/js/wallet.js"]}}},{path:"/admin",name:"Admin",component:DynamicComponent,props:{fetchUrl:"/admin",scripts:["/static/js/admin.js"]}},{path:"/users",name:"Users",component:DynamicComponent,props:{fetchUrl:"/users",scripts:["/static/js/users.js"]}},{path:"/audit",name:"Audit",component:DynamicComponent,props:{fetchUrl:"/audit",scripts:["/static/js/audit.js"]}},{path:"/payments",name:"Payments",component:DynamicComponent,props:{fetchUrl:"/payments",scripts:["/static/js/payments.js"]}},{path:"/extensions",name:"Extensions",component:DynamicComponent,props:{fetchUrl:"/extensions",scripts:["/static/js/extensions.js"]}},{path:"/account",name:"Account",component:DynamicComponent,props:{fetchUrl:"/account",scripts:["/static/js/account.js"]}},{path:"/wallets",name:"Wallets",component:DynamicComponent,props:{fetchUrl:"/wallets",scripts:["/static/js/wallets.js"]}},{path:"/node",name:"Node",component:DynamicComponent,props:{fetchUrl:"/node",scripts:["/static/js/node.js"]}}];window.router=VueRouter.createRouter({history:VueRouter.createWebHistory(),routes:routes}),window.app.mixin({computed:{isVueRoute(){const t=window.location.pathname,e=window.router.resolve(t);return e?.matched?.length>0}}}),window.app.use(VueQrcodeReader),window.app.use(Quasar,{config:{loading:{spinner:Quasar.QSpinnerBars}}}),window.app.use(window.i18n),window.app.provide("g",g),window.app.use(window.router),window.app.component("DynamicComponent",DynamicComponent),window.app.mount("#vue"); +window.app.component("lnbits-funding-sources",{template:"#lnbits-funding-sources",mixins:[window.windowMixin],props:["form-data","allowed-funding-sources"],methods:{getFundingSourceLabel(e){const t=this.rawFundingSources.find((t=>t[0]===e));return t?t[1]:e},showQRValue(e){this.qrValue=e,this.showQRDialog=!0}},computed:{fundingSources(){let e=[];for(const[t,i,s]of this.rawFundingSources){const i={};if(null!==s)for(let[e,t]of Object.entries(s))i[e]="string"==typeof t?{label:t,value:null}:t||{};e.push([t,i])}return new Map(e)},sortedAllowedFundingSources(){return this.allowedFundingSources.sort()}},data:()=>({hideInput:!0,showQRDialog:!1,qrValue:"",rawFundingSources:[["VoidWallet","Void Wallet",null],["FakeWallet","Fake Wallet",{fake_wallet_secret:"Secret",lnbits_denomination:'"sats" or 3 Letter Custom Denomination'}],["CLNRestWallet","Core Lightning Rest (plugin)",{clnrest_url:"Endpoint",clnrest_ca:"ca.pem",clnrest_cert:"server.pem",clnrest_readonly_rune:"Rune used for readonly requests",clnrest_invoice_rune:"Rune used for creating invoices",clnrest_pay_rune:"Rune used for paying invoices using pay",clnrest_renepay_rune:"Rune used for paying invoices using renepay",clnrest_last_pay_index:"Ignores any invoices paid prior to or including this index. 0 is equivalent to not specifying and negative value is invalid.",clnrest_nodeid:"Node id"}],["CoreLightningWallet","Core Lightning",{corelightning_rpc:"Endpoint",corelightning_pay_command:"Custom Pay Command"}],["CoreLightningRestWallet","Core Lightning Rest (legacy)",{corelightning_rest_url:"Endpoint",corelightning_rest_cert:"Certificate",corelightning_rest_macaroon:"Macaroon"}],["LndRestWallet","Lightning Network Daemon (LND Rest)",{lnd_rest_endpoint:"Endpoint",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"}],["LndWallet","Lightning Network Daemon (LND)",{lnd_grpc_endpoint:"Endpoint",lnd_grpc_cert:"Certificate",lnd_grpc_port:"Port",lnd_grpc_admin_macaroon:"Admin Macaroon",lnd_grpc_macaroon_encrypted:"Encrypted Macaroon"}],["LnTipsWallet","LN.Tips",{lntips_api_endpoint:"Endpoint",lntips_api_key:"API Key"}],["LNPayWallet","LN Pay",{lnpay_api_endpoint:"Endpoint",lnpay_api_key:"API Key",lnpay_wallet_key:"Wallet Key"}],["EclairWallet","Eclair (ACINQ)",{eclair_url:"URL",eclair_pass:"Password"}],["LNbitsWallet","LNbits",{lnbits_endpoint:"Endpoint",lnbits_key:"Admin Key"}],["BlinkWallet","Blink",{blink_api_endpoint:"Endpoint",blink_ws_endpoint:"WebSocket",blink_token:"Key"}],["AlbyWallet","Alby",{alby_api_endpoint:"Endpoint",alby_access_token:"Key"}],["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_mnemonic:{label:"Liquid mnemonic (copy into greenwallet)",readonly:!0,copy:!0,qrcode:!0}}],["ZBDWallet","ZBD",{zbd_api_endpoint:"Endpoint",zbd_api_key:"Key"}],["PhoenixdWallet","Phoenixd",{phoenixd_api_endpoint:"Endpoint",phoenixd_api_password:"Key"}],["OpenNodeWallet","OpenNode",{opennode_api_endpoint:"Endpoint",opennode_key:"Key"}],["ClicheWallet","Cliche (NBD)",{cliche_endpoint:"Endpoint"}],["SparkWallet","Spark",{spark_url:"Endpoint",spark_token:"Token"}],["NWCWallet","Nostr Wallet Connect",{nwc_pairing_url:"Pairing URL"}],["BreezSdkWallet","Breez SDK",{breez_api_key:"Breez API Key",breez_greenlight_seed:"Greenlight Seed",breez_greenlight_device_key:"Greenlight Device Key",breez_greenlight_device_cert:"Greenlight Device Cert",breez_greenlight_invite_code:"Greenlight Invite Code"}],["StrikeWallet","Strike (alpha)",{strike_api_endpoint:"API Endpoint",strike_api_key:"API Key"}],["BreezLiquidSdkWallet","Breez Liquid SDK",{breez_liquid_api_key:"Breez API Key (can be empty)",breez_liquid_seed:"Liquid seed phrase",breez_liquid_fee_offset_sat:"Offset amount in sats to increase fee limit"}]]})}),window.app.component("lnbits-extension-settings-form",{name:"lnbits-extension-settings-form",template:"#lnbits-extension-settings-form",props:["options","adminkey","endpoint"],methods:{async updateSettings(){if(!this.settings)return Quasar.Notify.create({message:"No settings to update",type:"negative"});try{const{data:e}=await LNbits.api.request("PUT",this.endpoint,this.adminkey,this.settings);this.settings=e}catch(e){LNbits.utils.notifyApiError(e)}},async getSettings(){try{const{data:e}=await LNbits.api.request("GET",this.endpoint,this.adminkey);this.settings=e}catch(e){LNbits.utils.notifyApiError(e)}},async resetSettings(){LNbits.utils.confirmDialog("Are you sure you want to reset the settings?").onOk((async()=>{try{await LNbits.api.request("DELETE",this.endpoint,this.adminkey),await this.getSettings()}catch(e){LNbits.utils.notifyApiError(e)}}))}},async created(){await this.getSettings()},data:()=>({settings:void 0})}),window.app.component("lnbits-extension-settings-btn-dialog",{template:"#lnbits-extension-settings-btn-dialog",name:"lnbits-extension-settings-btn-dialog",props:["options","adminkey","endpoint"],data:()=>({show:!1})}),window.app.component("payment-list",{name:"payment-list",template:"#payment-list",props:["update","lazy","wallet"],mixins:[window.windowMixin],data(){return{denomination:LNBITS_DENOMINATION,payments:[],paymentsTable:{columns:[{name:"time",align:"left",label:this.$t("memo")+"/"+this.$t("date"),field:"date",sortable:!0},{name:"amount",align:"right",label:this.$t("amount")+" ("+LNBITS_DENOMINATION+")",field:"sat",sortable:!0}],pagination:{rowsPerPage:10,page:1,sortBy:"time",descending:!0,rowsNumber:10},search:"",filter:{"status[ne]":"failed"},loading:!1},searchDate:{from:null,to:null},searchStatus:{success:!0,pending:!0,failed:!1,incoming:!0,outgoing:!0},exportTagName:"",exportPaymentTagList:[],paymentsCSV:{columns:[{name:"pending",align:"left",label:"Pending",field:"pending"},{name:"memo",align:"left",label:this.$t("memo"),field:"memo"},{name:"time",align:"left",label:this.$t("date"),field:"date",sortable:!0},{name:"amount",align:"right",label:this.$t("amount")+" ("+LNBITS_DENOMINATION+")",field:"sat",sortable:!0},{name:"fee",align:"right",label:this.$t("fee")+" (m"+LNBITS_DENOMINATION+")",field:"fee"},{name:"tag",align:"right",label:this.$t("tag"),field:"tag"},{name:"payment_hash",align:"right",label:this.$t("payment_hash"),field:"payment_hash"},{name:"payment_proof",align:"right",label:this.$t("payment_proof"),field:"payment_proof"},{name:"webhook",align:"right",label:this.$t("webhook"),field:"webhook"},{name:"fiat_currency",align:"right",label:"Fiat Currency",field:e=>e.extra.wallet_fiat_currency},{name:"fiat_amount",align:"right",label:"Fiat Amount",field:e=>e.extra.wallet_fiat_amount}],loading:!1}}},computed:{currentWallet(){return this.wallet||this.g.wallet},filteredPayments(){const e=this.paymentsTable.search;return e&&""!==e?LNbits.utils.search(this.payments,e):this.payments},paymentsOmitter(){return this.$q.screen.lt.md&&this.mobileSimple?this.payments.length>0?[this.payments[0]]:[]:this.payments},pendingPaymentsExist(){return-1!==this.payments.findIndex((e=>e.pending))}},methods:{searchByDate(){"string"==typeof this.searchDate&&(this.searchDate={from:this.searchDate,to:this.searchDate}),this.searchDate.from&&(this.paymentsTable.filter["time[ge]"]=this.searchDate.from+"T00:00:00"),this.searchDate.to&&(this.paymentsTable.filter["time[le]"]=this.searchDate.to+"T23:59:59"),this.fetchPayments()},clearDateSeach(){this.searchDate={from:null,to:null},delete this.paymentsTable.filter["time[ge]"],delete this.paymentsTable.filter["time[le]"],this.fetchPayments()},fetchPayments(e){this.$emit("filter-changed",{...this.paymentsTable.filter});const t=LNbits.utils.prepareFilterQuery(this.paymentsTable,e);return LNbits.api.getPayments(this.currentWallet,t).then((e=>{this.paymentsTable.loading=!1,this.paymentsTable.pagination.rowsNumber=e.data.total,this.payments=e.data.data.map((e=>LNbits.map.payment(e)))})).catch((e=>{this.paymentsTable.loading=!1,g.user.admin?this.fetchPaymentsAsAdmin(this.currentWallet.id,t):LNbits.utils.notifyApiError(e)}))},fetchPaymentsAsAdmin(e,t){return t=(t||"")+"&wallet_id="+e,LNbits.api.request("GET","/api/v1/payments/all/paginated?"+t).then((e=>{this.paymentsTable.loading=!1,this.paymentsTable.pagination.rowsNumber=e.data.total,this.payments=e.data.data.map((e=>LNbits.map.payment(e)))})).catch((e=>{this.paymentsTable.loading=!1,LNbits.utils.notifyApiError(e)}))},checkPayment(e){LNbits.api.getPayment(this.g.wallet,e).then((e=>{this.update=!this.update,"success"==e.data.status&&Quasar.Notify.create({type:"positive",message:this.$t("payment_successful")}),"pending"==e.data.status&&Quasar.Notify.create({type:"info",message:this.$t("payment_pending")})})).catch(LNbits.utils.notifyApiError)},paymentTableRowKey:e=>e.payment_hash+e.amount,exportCSV(e=!1){const t=this.paymentsTable.pagination,i={sortby:t.sortBy??"time",direction:t.descending?"desc":"asc"},s=new URLSearchParams(i);LNbits.api.getPayments(this.g.wallet,s).then((t=>{let i=t.data.data.map(LNbits.map.payment),s=this.paymentsCSV.columns;if(e){this.exportPaymentTagList.length&&(i=i.filter((e=>this.exportPaymentTagList.includes(e.tag))));const e=Object.keys(i.reduce(((e,t)=>({...e,...t.details})),{})).map((e=>({name:e,align:"right",label:e.charAt(0).toUpperCase()+e.slice(1).replace(/([A-Z])/g," $1"),field:t=>t.details[e],format:e=>"object"==typeof e?JSON.stringify(e):e})));s=this.paymentsCSV.columns.concat(e)}LNbits.utils.exportCSV(s,i,this.g.wallet.name+"-payments")}))},addFilterTag(){if(!this.exportTagName)return;const e=this.exportTagName.trim();this.exportPaymentTagList=this.exportPaymentTagList.filter((t=>t!==e)),this.exportPaymentTagList.push(e),this.exportTagName=""},removeExportTag(e){this.exportPaymentTagList=this.exportPaymentTagList.filter((t=>t!==e))},formatCurrency(e,t){try{return LNbits.utils.formatCurrency(e,t)}catch(t){return console.error(t),`${e} ???`}},handleFilterChanged(){const{success:e,pending:t,failed:i,incoming:s,outgoing:n}=this.searchStatus;delete this.paymentsTable.filter["status[ne]"],delete this.paymentsTable.filter["status[eq]"],e&&t&&i||(e&&t?this.paymentsTable.filter["status[ne]"]="failed":e&&i?this.paymentsTable.filter["status[ne]"]="pending":i&&t?this.paymentsTable.filter["status[ne]"]="success":e?this.paymentsTable.filter["status[eq]"]="success":t?this.paymentsTable.filter["status[eq]"]="pending":i&&(this.paymentsTable.filter["status[eq]"]="failed")),delete this.paymentsTable.filter["amount[ge]"],delete this.paymentsTable.filter["amount[le]"],s&&n||(s?this.paymentsTable.filter["amount[ge]"]=0:n&&(this.paymentsTable.filter["amount[le]"]=0))}},watch:{"paymentsTable.search":{handler(){const e={};this.paymentsTable.search&&(e.search=this.paymentsTable.search),this.fetchPayments()}},lazy(e){!0===e&&this.fetchPayments()},update(){this.fetchPayments()},"g.updatePayments"(){this.fetchPayments()},"g.wallet":{handler(e){this.fetchPayments()},deep:!0}},created(){void 0===this.lazy&&this.fetchPayments()}}),window.app.component(QrcodeVue),window.app.component("lnbits-extension-rating",{template:"#lnbits-extension-rating",name:"lnbits-extension-rating",props:["rating"]}),window.app.component("lnbits-fsat",{template:"{{ fsat }}",props:{amount:{type:Number,default:0}},computed:{fsat(){return LNbits.utils.formatSat(this.amount)}}}),window.app.component("lnbits-wallet-list",{mixins:[window.windowMixin],template:"#lnbits-wallet-list",props:["balance"],data:()=>({activeWallet:null,balance:0,showForm:!1,walletName:"",LNBITS_DENOMINATION:LNBITS_DENOMINATION}),methods:{createWallet(){LNbits.api.createWallet(this.g.user.wallets[0],this.walletName)}},created(){document.addEventListener("updateWalletBalance",this.updateWalletBalance)}}),window.app.component("lnbits-extension-list",{mixins:[window.windowMixin],template:"#lnbits-extension-list",data:()=>({extensions:[],searchTerm:""}),watch:{"g.user.extensions":{handler(e){this.loadExtensions()},deep:!0}},computed:{userExtensions(){return this.updateUserExtensions(this.searchTerm)}},methods:{async loadExtensions(){try{const{data:e}=await LNbits.api.request("GET","/api/v1/extension");this.extensions=e.map((e=>LNbits.map.extension(e))).sort(((e,t)=>e.name.localeCompare(t.name)))}catch(e){LNbits.utils.notifyApiError(e)}},updateUserExtensions(e){const t=window.location.pathname,i=this.g.user.extensions;return this.extensions.filter((e=>i.includes(e.code))).filter((t=>!e||`${t.code} ${t.name} ${t.short_description} ${t.url}`.toLocaleLowerCase().includes(e.toLocaleLowerCase()))).map((e=>(e.isActive=t.startsWith(e.url),e)))}},async created(){await this.loadExtensions()}}),window.app.component("lnbits-manage",{mixins:[window.windowMixin],template:"#lnbits-manage",props:["showAdmin","showNode","showExtensions","showUsers","showAudit"],methods:{isActive:e=>window.location.pathname===e},data:()=>({extensions:[]})}),window.app.component("lnbits-payment-details",{mixins:[window.windowMixin],template:"#lnbits-payment-details",props:["payment"],mixins:[window.windowMixin],data:()=>({LNBITS_DENOMINATION:LNBITS_DENOMINATION}),computed:{hasPreimage(){return this.payment.preimage&&"0000000000000000000000000000000000000000000000000000000000000000"!==this.payment.preimage},hasExpiry(){return!!this.payment.expiry},hasSuccessAction(){return this.hasPreimage&&this.payment.extra&&this.payment.extra.success_action},webhookStatusColor(){return this.payment.webhook_status>=300||this.payment.webhook_status<0?"red-10":this.payment.webhook_status?"green-10":"cyan-7"},webhookStatusText(){return this.payment.webhook_status?this.payment.webhook_status:"not sent yet"},hasTag(){return this.payment.extra&&!!this.payment.extra.tag},extras(){if(!this.payment.extra)return[];let e=_.omit(this.payment.extra,["tag","success_action"]);return Object.keys(e).map((t=>({key:t,value:e[t]})))}}}),window.app.component("lnbits-lnurlpay-success-action",{mixins:[window.windowMixin],template:"#lnbits-lnurlpay-success-action",props:["payment","success_action"],data(){return{decryptedValue:this.success_action.ciphertext}},mounted(){if("aes"!==this.success_action.tag)return null;decryptLnurlPayAES(this.success_action,this.payment.preimage).then((e=>{this.decryptedValue=e}))}}),window.app.component("lnbits-qrcode",{mixins:[window.windowMixin],template:"#lnbits-qrcode",components:{QrcodeVue:QrcodeVue},props:{value:{type:String,required:!0},options:Object},data:()=>({custom:{margin:3,width:350,size:350,logo:LNBITS_QR_LOGO}}),created(){this.custom={...this.custom,...this.options}}}),window.app.component("lnbits-notifications-btn",{template:"#lnbits-notifications-btn",mixins:[window.windowMixin],props:["pubkey"],data:()=>({isSupported:!1,isSubscribed:!1,isPermissionGranted:!1,isPermissionDenied:!1}),methods:{urlB64ToUint8Array(e){const t=(e+"=".repeat((4-e.length%4)%4)).replace(/\-/g,"+").replace(/_/g,"/"),i=atob(t),s=new Uint8Array(i.length);for(let e=0;et!==e)),this.$q.localStorage.set("lnbits.webpush.subscribedUsers",JSON.stringify(t))},isUserSubscribed(e){return(JSON.parse(this.$q.localStorage.getItem("lnbits.webpush.subscribedUsers"))||[]).includes(e)},subscribe(){this.isSupported&&!this.isPermissionDenied&&(Notification.requestPermission().then((e=>{this.isPermissionGranted="granted"===e,this.isPermissionDenied="denied"===e})).catch(console.log),navigator.serviceWorker.ready.then((e=>{navigator.serviceWorker.getRegistration().then((e=>{e.pushManager.getSubscription().then((t=>{if(null===t||!this.isUserSubscribed(this.g.user.id)){const t={applicationServerKey:this.urlB64ToUint8Array(this.pubkey),userVisibleOnly:!0};e.pushManager.subscribe(t).then((e=>{LNbits.api.request("POST","/api/v1/webpush",null,{subscription:JSON.stringify(e)}).then((e=>{this.saveUserSubscribed(e.data.user),this.isSubscribed=!0})).catch(LNbits.utils.notifyApiError)}))}})).catch(console.log)}))})))},unsubscribe(){navigator.serviceWorker.ready.then((e=>{e.pushManager.getSubscription().then((e=>{e&&LNbits.api.request("DELETE","/api/v1/webpush?endpoint="+btoa(e.endpoint),null).then((()=>{this.removeUserSubscribed(this.g.user.id),this.isSubscribed=!1})).catch(LNbits.utils.notifyApiError)}))})).catch(console.log)},checkSupported(){let e="https:"===window.location.protocol,t="serviceWorker"in navigator,i="Notification"in window,s="PushManager"in window;return this.isSupported=e&&t&&i&&s,this.isSupported||console.log("Notifications disabled because requirements are not met:",{HTTPS:e,"Service Worker API":t,"Notification API":i,"Push API":s}),this.isSupported},async updateSubscriptionStatus(){await navigator.serviceWorker.ready.then((e=>{e.pushManager.getSubscription().then((e=>{this.isSubscribed=!!e&&this.isUserSubscribed(this.g.user.id)}))})).catch(console.log)}},created(){this.isPermissionDenied="denied"===Notification.permission,this.checkSupported()&&this.updateSubscriptionStatus()}}),window.app.component("lnbits-dynamic-fields",{template:"#lnbits-dynamic-fields",mixins:[window.windowMixin],props:["options","modelValue"],data:()=>({formData:null,rules:[e=>!!e||"Field is required"]}),methods:{applyRules(e){return e?this.rules:[]},buildData(e,t={}){return e.reduce(((e,i)=>(i.options?.length?e[i.name]=this.buildData(i.options,t[i.name]):e[i.name]=t[i.name]??i.default,e)),{})},handleValueChanged(){this.$emit("update:model-value",this.formData)}},created(){this.formData=this.buildData(this.options,this.modelValue)}}),window.app.component("lnbits-dynamic-chips",{template:"#lnbits-dynamic-chips",mixins:[window.windowMixin],props:["modelValue"],data:()=>({chip:"",chips:[]}),methods:{addChip(){this.chip&&(this.chips.push(this.chip),this.chip="",this.$emit("update:model-value",this.chips.join(",")))},removeChip(e){this.chips.splice(e,1),this.$emit("update:model-value",this.chips.join(","))}},created(){"string"==typeof this.modelValue?this.chips=this.modelValue.split(","):this.chips=[...this.modelValue]}}),window.app.component("lnbits-update-balance",{template:"#lnbits-update-balance",mixins:[window.windowMixin],props:["wallet_id","small_btn"],computed:{denomination:()=>LNBITS_DENOMINATION,admin:()=>user.super_user},data:()=>({credit:0}),methods:{updateBalance(e){LNbits.api.updateBalance(e.value,this.wallet_id).then((t=>{if(!0!==t.data.success)throw new Error(t.data);credit=parseInt(e.value),Quasar.Notify.create({type:"positive",message:this.$t("credit_ok",{amount:credit}),icon:null}),this.credit=0,e.value=0,e.set()})).catch(LNbits.utils.notifyApiError)}}}),window.app.component("user-id-only",{template:"#user-id-only",mixins:[window.windowMixin],props:{allowed_new_users:Boolean,authAction:String,authMethod:String,usr:String,wallet:String},data(){return{user:this.usr,walletName:this.wallet}},methods:{showLogin(e){this.$emit("show-login",e)},showRegister(e){this.$emit("show-register",e)},loginUsr(){this.$emit("update:usr",this.user),this.$emit("login-usr")},createWallet(){this.$emit("update:wallet",this.walletName),this.$emit("create-wallet")}},computed:{showInstantLogin(){return"username-password"!==this.authMethod||"register"!==this.authAction}},created(){}}),window.app.component("username-password",{template:"#username-password",mixins:[window.windowMixin],props:{allowed_new_users:Boolean,authMethods:Array,authAction:String,username:String,password_1:String,password_2:String,resetKey:String},data(){return{oauth:["nostr-auth-nip98","google-auth","github-auth","keycloak-auth"],username:this.userName,password:this.password_1,passwordRepeat:this.password_2,reset_key:this.resetKey,keycloakOrg:LNBITS_AUTH_KEYCLOAK_ORG||"Keycloak",keycloakIcon:LNBITS_AUTH_KEYCLOAK_ICON}},methods:{login(){this.$emit("update:userName",this.username),this.$emit("update:password_1",this.password),this.$emit("login")},register(){this.$emit("update:userName",this.username),this.$emit("update:password_1",this.password),this.$emit("update:password_2",this.passwordRepeat),this.$emit("register")},reset(){this.$emit("update:resetKey",this.reset_key),this.$emit("update:password_1",this.password),this.$emit("update:password_2",this.passwordRepeat),this.$emit("reset")},validateUsername:e=>new RegExp("^(?=[a-zA-Z0-9._]{2,20}$)(?!.*[_.]{2})[^_.].*[^_.]$").test(e),async signInWithNostr(){try{const e=await this.createNostrToken();if(!e)return;resp=await LNbits.api.loginByProvider("nostr",{Authorization:e},{}),window.location.href="/wallet"}catch(e){console.warn(e);const t=e?.response?.data?.detail||`${e}`;Quasar.Notify.create({type:"negative",message:"Failed to sign in with Nostr.",caption:t})}},async createNostrToken(){try{if(!window.nostr?.signEvent)return void Quasar.Notify.create({type:"negative",message:"No Nostr signing app detected.",caption:'Is "window.nostr" present?'});const e=`${window.location}nostr`,t="POST",i=await NostrTools.nip98.getToken(e,t,(e=>async function(e){try{const{data:t}=await LNbits.api.getServerHealth();return e.created_at=t.server_time,await window.nostr.signEvent(e)}catch(e){console.error(e),Quasar.Notify.create({type:"negative",message:"Failed to sign nostr event.",caption:`${e}`})}}(e)),!0);if(!await NostrTools.nip98.validateToken(i,e,t))throw new Error("Invalid signed token!");return i}catch(e){console.warn(e),Quasar.Notify.create({type:"negative",message:"Failed create Nostr event.",caption:`${e}`})}}},computed:{showOauth(){return this.oauth.some((e=>this.authMethods.includes(e)))}},created(){}}),window.app.component("separator-text",{template:"#separator-text",props:{text:String,uppercase:{type:Boolean,default:!1},color:{type:String,default:"grey"}}});const DynamicComponent={props:{fetchUrl:{type:String,required:!0},scripts:{type:Array,default:()=>[]}},data:()=>({keys:[]}),async mounted(){await this.loadDynamicContent()},methods:{loadScript:async e=>new Promise(((t,i)=>{const s=document.querySelector(`script[src="${e}"]`);s&&s.remove();const n=document.createElement("script");n.src=e,n.async=!0,n.onload=t,n.onerror=()=>i(new Error(`Failed to load script: ${e}`)),document.head.appendChild(n)})),async loadDynamicContent(){this.$q.loading.show();try{const e=this.fetchUrl.split("#")[0],t=await fetch(e,{credentials:"include",headers:{Accept:"text/html","X-Requested-With":"XMLHttpRequest"}}),i=await t.text(),s=new DOMParser,n=s.parseFromString(i,"text/html").querySelector("#window-vars-script");n&&new Function(n.innerHTML)(),await this.loadScript("/static/js/base.js");for(const e of this.scripts)await this.loadScript(e);const a=this.$router.currentRoute.value.meta.previousRouteName;a&&window.app._context.components[a]&&delete window.app._context.components[a];const o=`${this.$route.name}PageLogic`,r=window[o];if(!r)throw new Error(`Component logic '${o}' not found. Ensure it is defined in the script.`);r.mixins=r.mixins||[],window.windowMixin&&r.mixins.push(window.windowMixin),window.app.component(this.$route.name,{...r,template:i}),delete window[o],this.$forceUpdate()}catch(e){console.error("Error loading dynamic content:",e)}finally{this.$q.loading.hide()}}},watch:{$route(e,t){routes.map((e=>e.name)).includes(e.name)?(this.$router.currentRoute.value.meta.previousRouteName=t.name,this.loadDynamicContent()):console.log(`Route '${e.name}' is not valid. Leave this one to Fastapi.`)}},template:'\n \n '},routes=[{path:"/wallet",name:"Wallet",component:DynamicComponent,props:e=>{let t="/wallet";if(Object.keys(e.query).length>0){t+="?";for(const[i,s]of Object.entries(e.query))t+=`${i}=${s}&`;t=t.slice(0,-1)}return{fetchUrl:t,scripts:["/static/js/wallet.js"]}}},{path:"/admin",name:"Admin",component:DynamicComponent,props:{fetchUrl:"/admin",scripts:["/static/js/admin.js"]}},{path:"/users",name:"Users",component:DynamicComponent,props:{fetchUrl:"/users",scripts:["/static/js/users.js"]}},{path:"/audit",name:"Audit",component:DynamicComponent,props:{fetchUrl:"/audit",scripts:["/static/js/audit.js"]}},{path:"/payments",name:"Payments",component:DynamicComponent,props:{fetchUrl:"/payments",scripts:["/static/js/payments.js"]}},{path:"/extensions",name:"Extensions",component:DynamicComponent,props:{fetchUrl:"/extensions",scripts:["/static/js/extensions.js"]}},{path:"/account",name:"Account",component:DynamicComponent,props:{fetchUrl:"/account",scripts:["/static/js/account.js"]}},{path:"/wallets",name:"Wallets",component:DynamicComponent,props:{fetchUrl:"/wallets",scripts:["/static/js/wallets.js"]}},{path:"/node",name:"Node",component:DynamicComponent,props:{fetchUrl:"/node",scripts:["/static/js/node.js"]}}];window.router=VueRouter.createRouter({history:VueRouter.createWebHistory(),routes:routes}),window.app.mixin({computed:{isVueRoute(){const e=window.location.pathname,t=window.router.resolve(e);return t?.matched?.length>0}}}),window.app.use(VueQrcodeReader),window.app.use(Quasar,{config:{loading:{spinner:Quasar.QSpinnerBars}}}),window.app.use(window.i18n),window.app.provide("g",g),window.app.use(window.router),window.app.component("DynamicComponent",DynamicComponent),window.app.mount("#vue"); diff --git a/lnbits/static/js/components/lnbits-funding-sources.js b/lnbits/static/js/components/lnbits-funding-sources.js index 00b02b559..9beb3e9f1 100644 --- a/lnbits/static/js/components/lnbits-funding-sources.js +++ b/lnbits/static/js/components/lnbits-funding-sources.js @@ -48,6 +48,22 @@ window.app.component('lnbits-funding-sources', { lnbits_denomination: '"sats" or 3 Letter Custom Denomination' } ], + [ + 'CLNRestWallet', + 'Core Lightning Rest (plugin)', + { + clnrest_url: 'Endpoint', + clnrest_ca: 'ca.pem', + clnrest_cert: 'server.pem', + clnrest_readonly_rune: 'Rune used for readonly requests', + clnrest_invoice_rune: 'Rune used for creating invoices', + clnrest_pay_rune: 'Rune used for paying invoices using pay', + clnrest_renepay_rune: 'Rune used for paying invoices using renepay', + clnrest_last_pay_index: + 'Ignores any invoices paid prior to or including this index. 0 is equivalent to not specifying and negative value is invalid.', + clnrest_nodeid: 'Node id' + } + ], [ 'CoreLightningWallet', 'Core Lightning', @@ -58,7 +74,7 @@ window.app.component('lnbits-funding-sources', { ], [ 'CoreLightningRestWallet', - 'Core Lightning Rest', + 'Core Lightning Rest (legacy)', { corelightning_rest_url: 'Endpoint', corelightning_rest_cert: 'Certificate', diff --git a/lnbits/wallets/__init__.py b/lnbits/wallets/__init__.py index 7b6377a9f..83851436e 100644 --- a/lnbits/wallets/__init__.py +++ b/lnbits/wallets/__init__.py @@ -12,6 +12,7 @@ from .boltz import BoltzWallet from .breez import BreezSdkWallet from .breez_liquid import BreezLiquidSdkWallet from .cliche import ClicheWallet +from .clnrest import CLNRestWallet from .corelightning import CoreLightningWallet # The following import is intentional to keep backwards compatibility @@ -60,6 +61,7 @@ __all__ = [ "BoltzWallet", "BreezLiquidSdkWallet", "BreezSdkWallet", + "CLNRestWallet", "CLightningWallet", "ClicheWallet", "CoreLightningRestWallet", diff --git a/lnbits/wallets/clnrest.py b/lnbits/wallets/clnrest.py new file mode 100644 index 000000000..5a2d84d09 --- /dev/null +++ b/lnbits/wallets/clnrest.py @@ -0,0 +1,498 @@ +import asyncio +import base64 +import json +import os +import ssl +import uuid +from collections.abc import AsyncGenerator +from typing import Optional +from urllib.parse import urlparse + +import httpx +from bolt11 import Bolt11Exception +from bolt11.decode import decode +from loguru import logger + +from lnbits.exceptions import UnsupportedError +from lnbits.helpers import normalize_endpoint +from lnbits.settings import settings +from lnbits.utils.crypto import random_secret_and_hash + +from .base import ( + InvoiceResponse, + PaymentPendingStatus, + PaymentResponse, + PaymentStatus, + PaymentSuccessStatus, + StatusResponse, + Wallet, +) + + +class CLNRestWallet(Wallet): + def __init__(self): + if not settings.clnrest_url: + raise ValueError("Cannot initialize CLNRestWallet: missing CLNREST_URL") + + if not settings.clnrest_readonly_rune: + raise ValueError( + "cannot initialize CLNRestWallet: " "missing clnrest_readonly_rune" + ) + + self.url = normalize_endpoint(settings.clnrest_url) + + if not settings.clnrest_nodeid: + logger.info("missing CLNREST_NODEID, but this is only needed for v23.08") + + self.base_headers = { + "accept": "application/json", + "User-Agent": settings.user_agent, + "Content-Type": "application/json", + } + + if settings.clnrest_nodeid is not None: + self.base_headers["nodeid"] = settings.clnrest_nodeid + + # Ensure the readonly rune is set + if settings.clnrest_readonly_rune is not None: + self.readonly_headers = { + **self.base_headers, + "rune": settings.clnrest_readonly_rune, + } + else: + logger.warning( + "Readonly rune 'CLNREST_READONLY_RUNE' is required but not set." + ) + + if settings.clnrest_invoice_rune is not None: + self.invoice_headers = { + **self.base_headers, + "rune": settings.clnrest_invoice_rune, + } + else: + logger.warning( + "Will be unable to create any invoices without " + "setting 'CLNREST_INVOICE_RUNE[:4]'" + ) + + if settings.clnrest_pay_rune is not None: + self.pay_headers = {**self.base_headers, "rune": settings.clnrest_pay_rune} + else: + logger.warning( + "Will be unable to call pay endpoint without setting 'CLNREST_PAY_RUNE'" + ) + + if settings.clnrest_renepay_rune is not None: + self.renepay_headers = { + **self.base_headers, + "rune": settings.clnrest_renepay_rune, + } + else: + logger.warning( + "Will be unable to call renepay endpoint without " + "setting 'CLNREST_RENEPAY_RUNE'" + ) + + # https://docs.corelightning.org/reference/lightning-pay + # -32602: Invalid bolt11: Prefix bc is not for regtest + # -1: Catchall nonspecific error. + ## 201: Already paid + # 203: Permanent failure at destination. + # 205: Unable to find a route. + # 206: Route too expensive. + # 207: Invoice expired. + # 210: Payment timed out without a payment in progress. + # 401: Unauthorized. Probably a rune issue + + self.pay_failure_error_codes = [-32602, 203, 205, 206, 207, 210, 401] + self.client = self._create_client() + self.last_pay_index = settings.clnrest_last_pay_index + self.statuses = { + "paid": True, + "complete": True, + "failed": False, + "pending": None, + } + + async def cleanup(self): + try: + await self.client.aclose() + except RuntimeError as exc: + logger.warning(f"Error closing wallet connection: {exc}") + + async def status(self) -> StatusResponse: + try: + logger.debug("REQUEST to /v1/listfunds") + + r = await self.client.post( + "/v1/listfunds", timeout=15, headers=self.readonly_headers + ) + r.raise_for_status() + + response_data = r.json() + + if not response_data: + logger.warning("Received empty response data") + return StatusResponse("no data", 0) + + channels = response_data.get("channels", []) + total_our_amount_msat = sum( + channel.get("our_amount_msat", 0) for channel in channels + ) + + return StatusResponse(None, total_our_amount_msat) + + except json.JSONDecodeError as exc: + logger.warning(f"JSON decode error: {exc!s}") + return StatusResponse(f"Failed to decode JSON response from {self.url}", 0) + + except httpx.ReadTimeout: + logger.warning( + "Timeout error: The server did not respond in time. This can happen if " + "the server is running HTTPS but the client is using HTTP." + ) + return StatusResponse( + "Unable to connect to 'v1/listfunds' due to timeout", 0 + ) + + except (httpx.ConnectError, httpx.RequestError) as exc: + logger.warning(f"Connection error: {exc}") + return StatusResponse("Unable to connect to 'v1/listfunds'", 0) + + except httpx.HTTPStatusError as exc: + logger.warning( + f"HTTP error: {exc.response.status_code} {exc.response.reason_phrase} " + f"while accessing {exc.request.url}" + ) + return StatusResponse( + f"Failed with HTTP {exc.response.status_code} on 'v1/listfunds'", 0 + ) + except Exception as exc: + logger.warning(exc) + return StatusResponse(f"Unable to connect to {self.url}.", 0) + + async def create_invoice( + self, + amount: int, + memo: Optional[str] = None, + description_hash: Optional[bytes] = None, + unhashed_description: Optional[bytes] = None, + **kwargs, + ) -> InvoiceResponse: + + if not settings.clnrest_invoice_rune: + return InvoiceResponse( + ok=False, error_message="Unable to invoice without an invoice rune" + ) + + data: dict = { + "amount_msat": int(amount * 1000), + "description": memo, + "label": _generate_label(), + } + + if description_hash and not unhashed_description: + raise UnsupportedError( + "'description_hash' unsupported by CoreLightningRest, " + "provide 'unhashed_description'" + ) + + if unhashed_description: + data["description"] = unhashed_description.decode() + + if kwargs.get("expiry"): + data["expiry"] = kwargs["expiry"] + + if kwargs.get("preimage"): + data["preimage"] = kwargs["preimage"] + else: + preimage, _ = random_secret_and_hash() + data["preimage"] = preimage + + try: + r = await self.client.post( + "/v1/invoice", + json=data, + headers=self.invoice_headers, + ) + r.raise_for_status() + response_data = r.json() + + if "error" in response_data: + error_message = response_data["error"] + logger.debug(f"Error creating invoice: {error_message}") + return InvoiceResponse(ok=False, error_message=error_message) + + if "payment_hash" not in response_data or "bolt11" not in response_data: + return InvoiceResponse( + ok=False, error_message="Server error: 'missing required fields'" + ) + return InvoiceResponse( + ok=True, + checking_id=response_data["payment_hash"], + payment_request=response_data["bolt11"], + preimage=data["preimage"], + ) + + except json.JSONDecodeError as exc: + logger.warning(exc) + return InvoiceResponse( + ok=False, error_message="Server error: 'invalid json response'" + ) + except Exception as exc: + logger.warning(f"Unable to connect to {self.url}: {exc}") + return InvoiceResponse( + ok=False, error_message=f"Unable to connect to {self.url}." + ) + + async def pay_invoice( + self, + bolt11: str, + fee_limit_msat: int, + **_, + ) -> PaymentResponse: + + try: + invoice = decode(bolt11) + except Bolt11Exception as exc: + return PaymentResponse(ok=False, error_message=str(exc)) + + if not invoice.amount_msat or invoice.amount_msat <= 0: + return PaymentResponse( + ok=False, error_message="0 amount invoices are not allowed" + ) + + if not settings.clnrest_pay_rune and not settings.clnrest_renepay_rune: + return PaymentResponse( + ok=False, + error_message="Unable to pay invoice without a pay or renepay rune", + ) + + data = { + "label": _generate_label(), + "description": invoice.description, + "maxfee": fee_limit_msat, + } + + if settings.clnrest_renepay_rune: + endpoint = "/v1/renepay" + headers = self.renepay_headers + data["invstring"] = bolt11 + else: + endpoint = "/v1/pay" + headers = self.pay_headers + data["bolt11"] = bolt11 + + try: + r = await self.client.post( + endpoint, + json=data, + headers=headers, + timeout=None, + ) + + r.raise_for_status() + data = r.json() + + if "payment_preimage" not in data: + error_message = data.get("error", "No payment preimage in response") + logger.warning(error_message) + return PaymentResponse(error_message=error_message) + + return PaymentResponse( + ok=self.statuses.get(data["status"]), + checking_id=data["payment_hash"], + fee_msat=data["amount_sent_msat"] - data["amount_msat"], + preimage=data["payment_preimage"], + ) + except httpx.HTTPStatusError as exc: + try: + data = exc.response.json() + error = data.get("error", {}) + error_code = int(error.get("code", 0)) + error_message = error.get("message", "Unknown error") + if error_code in self.pay_failure_error_codes: + return PaymentResponse(ok=False, error_message=error_message) + else: + return PaymentResponse(error_message=error_message) + except Exception: + error_message = f"Error parsing response from {self.url}: {exc!s}" + logger.warning(error_message) + return PaymentResponse(error_message=error_message) + except Exception as exc: + logger.info(f"Failed to pay invoice {bolt11}") + logger.warning(exc) + error_message = f"Unable to connect to {self.url}." + return PaymentResponse(error_message=error_message) + + async def get_invoice_status(self, checking_id: str) -> PaymentStatus: + data: dict = {"payment_hash": checking_id} + + try: + r = await self.client.post( + "/v1/listinvoices", + json=data, + headers=self.readonly_headers, + ) + r.raise_for_status() + data = r.json() + if r.is_error or "error" in data or data.get("invoices") is None: + logger.warning(f"error in cln response '{checking_id}'") + return PaymentPendingStatus() + status = self.statuses.get(data["invoices"][0]["status"]) + fee_msat = data["invoices"][0].get("amount_received_msat", 0) - data[ + "invoices" + ][0].get("amount_msat", 0) + return PaymentStatus( + paid=status, + preimage=data["invoices"][0].get("preimage"), + fee_msat=fee_msat, + ) + except Exception as exc: + logger.warning(f"Error getting invoice status: {exc}") + return PaymentPendingStatus() + + async def get_payment_status(self, checking_id: str) -> PaymentStatus: + try: + data: dict = {"payment_hash": checking_id} + r = await self.client.post( + "/v1/listpays", + json=data, + headers=self.readonly_headers, + ) + r.raise_for_status() + data = r.json() + + if r.is_error or "error" in data: + logger.warning(f"API response error: {data}") + return PaymentPendingStatus() + + pays_list = data.get("pays", []) + if not pays_list: + logger.warning(f"No payments found for payment hash {checking_id}.") + return PaymentPendingStatus() + + if len(pays_list) != 1: + logger.warning( + f"Expected one payment status, but found {len(pays_list)}" + ) + return PaymentPendingStatus() + + pay = pays_list[-1] + + if pay["status"] == "complete": + fee_msat = pay["amount_sent_msat"] - pay["amount_msat"] + return PaymentSuccessStatus(fee_msat=fee_msat, preimage=pay["preimage"]) + + except Exception as exc: + logger.warning(f"Error getting payment status: {exc}") + + return PaymentPendingStatus() + + async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: + while settings.lnbits_running: + try: + waitanyinvoice_timeout = None + request_timeout = httpx.Timeout( + connect=5.0, read=waitanyinvoice_timeout, write=60.0, pool=60.0 + ) + data: dict = { + "lastpay_index": self.last_pay_index, + "timeout": waitanyinvoice_timeout, + } + url = "/v1/waitanyinvoice" + async with self.client.stream( + "POST", + url, + json=data, + headers=self.readonly_headers, + timeout=request_timeout, + ) as r: + async for line in r.aiter_lines(): + inv = json.loads(line) + if "error" in inv and "message" in inv["error"]: + logger.warning("Error in paid_invoices_stream:", inv) + raise Exception(inv["error"]["message"]) + try: + paid = inv["status"] == "paid" + self.last_pay_index = inv["pay_index"] + if not paid: + continue + except Exception as exc: + logger.warning(exc) + continue + logger.debug(f"paid invoice: {inv}") + + if "payment_hash" in inv: + payment_hash_from_waitanyinvoice = inv["payment_hash"] + yield payment_hash_from_waitanyinvoice + + except Exception as exc: + logger.debug( + f"lost connection to corelightning-rest invoices stream: '{exc}', " + "reconnecting..." + ) + await asyncio.sleep(0.02) + + def _create_client(self) -> httpx.AsyncClient: + """Create an HTTP client with specified headers and SSL configuration.""" + + parsed_url = urlparse(self.url) + + # Validate the URL scheme + if parsed_url.scheme == "http": + if parsed_url.hostname in ("localhost", "127.0.0.1", "::1"): + logger.warning("Not using SSL for connection to CLNRestWallet") + else: + raise ValueError( + "Insecure HTTP connections are only allowed for localhost or " + "equivalent IP addresses. Set CLNREST_URL to https:// for external " + "connections or use localhost." + ) + return httpx.AsyncClient(base_url=self.url) + + if parsed_url.scheme == "https": + logger.info(f"Using SSL to connect to {self.url}") + + # Check for CA certificate + if not settings.clnrest_ca: + logger.warning( + "No CA certificate provided for CLNRestWallet. " + "This setup requires a CA certificate for server authentication " + "and trust. Set CLNREST_CA to the CA certificate file path or the " + "contents of the certificate." + ) + raise ValueError("CA certificate is required for secure communication.") + + logger.info(f"CA Certificate provided: {settings.clnrest_ca}") + + # Create an SSL context and load the CA certificate + ssl_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + + # Load CA certificate + if os.path.isfile(settings.clnrest_ca): + logger.info(f"Using CA certificate file: {settings.clnrest_ca}") + ssl_context.load_verify_locations(cafile=settings.clnrest_ca) + else: + logger.info("Using CA certificate from PEM string: ") + ca_content = settings.clnrest_ca.replace("\\n", "\n") + logger.info(ca_content) + ssl_context.load_verify_locations(cadata=ca_content) + + # Optional: Disable hostname checking if necessary + # especially for ip addresses + ssl_context.check_hostname = False + + # Create the HTTP client without a client certificate + client = httpx.AsyncClient(base_url=self.url, verify=ssl_context) + + return client + + else: + raise ValueError("CLNREST_URL must start with http:// or https://") + + +def _generate_label() -> str: + """Generate a unique label for the invoice.""" + random_uuid = base64.urlsafe_b64encode(uuid.uuid4().bytes).rstrip(b"=").decode() + return f"LNbits_{random_uuid}"