Repository: lnbits/lnbits
Pull request #3150: feat: Add Strike Wallet Integration
Author: login: santyr
Created: 2025-05-09T17:37:04Z | Merged: 2025-05-20T08:51:05Z

This pull request introduces a new wallet integration for LNbits, enabling users to leverage the Strike API for Lightning Network payments.

**Key Features:**

*   **Strike API Integration:** Implements a `StrikeWallet` class that interacts with the Strike API to manage Lightning invoices and payments.
*   **Core Wallet Functionality:** Supports essential wallet operations, including:
    *   Retrieving wallet balance.
    *   Creating Lightning invoices (receive requests).
    *   Paying Lightning invoices.
    *   Checking the status of invoices and payments.
*   **Rate Limiting and Retries:** Incorporates rate limiting to comply with Strike API usage policies and implements retry mechanisms to handle transient errors.
*   **Error Handling:** Includes robust error handling to manage API errors and other potential issues.
*   **Asynchronous Implementation:**  Uses `asyncio` and `httpx` for efficient asynchronous operation within the LNbits environment.
*   **Configuration:**  Relies on LNbits settings for API endpoint and key configuration.

**Implementation Details:**

*   The `StrikeWallet` class inherits from the LNbits `Wallet` base class and implements the required interface methods.
*   Rate limiting is achieved using a `TokenBucket` to control request frequency to different API endpoint categories.

This new wallet integration expands the options available to LNbits users, providing a convenient way to manage Lightning payments through the Strike platform.



Diff:
diff --git a/.env.example b/.env.example
index cc35859631..1464bc5f7d 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"
+# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet"
 
 LNBITS_BACKEND_WALLET_CLASS=VoidWallet
 # VoidWallet is just a fallback that works without any actual Lightning capabilities,
@@ -105,6 +105,10 @@ BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroon" # or HEXSTRING
 BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" # or HEXSTRING
 BOLTZ_CLIENT_WALLET="lnbits"
 
+# StrikeWallet
+STRIKE_API_ENDPOINT=https://api.strike.me/v1
+STRIKE_API_KEY=YOUR_STRIKE_API_KEY
+
 # ZBDWallet
 ZBD_API_ENDPOINT=https://api.zebedee.io/v0/
 ZBD_API_KEY=ZBD_ACCESS_TOKEN
diff --git a/lnbits/settings.py b/lnbits/settings.py
index ca92bf32c7..6c7e26c751 100644
--- a/lnbits/settings.py
+++ b/lnbits/settings.py
@@ -556,6 +556,13 @@ class BoltzFundingSource(LNbitsSettings):
     boltz_client_cert: str | None = Field(default=None)
 
 
+class StrikeFundingSource(LNbitsSettings):
+    strike_api_endpoint: str | None = Field(
+        default="https://api.strike.me/v1", env="STRIKE_API_ENDPOINT"
+    )
+    strike_api_key: str | None = Field(default=None, env="STRIKE_API_KEY")
+
+
 class LightningSettings(LNbitsSettings):
     lightning_invoice_expiry: int = Field(default=3600, gt=0)
 
@@ -580,6 +587,7 @@ class FundingSourcesSettings(
     LnTipsFundingSource,
     NWCFundingSource,
     BreezSdkFundingSource,
+    StrikeFundingSource,
 ):
     lnbits_backend_wallet_class: str = Field(default="VoidWallet")
     # How long to wait for the payment to be confirmed before returning a pending status
@@ -863,6 +871,7 @@ class SuperUserSettings(LNbitsSettings):
             "VoidWallet",
             "ZBDWallet",
             "NWCWallet",
+            "StrikeWallet",
         ]
     )
 
diff --git a/lnbits/static/bundle-components.min.js b/lnbits/static/bundle-components.min.js
index 07702df075..619f6c45e9 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}},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]={label:e,value:null};t.push([e,i])}return new Map(t)},sortedAllowedFundingSources(){return this.allowedFundingSources.sort()}},data:()=>({hideInput:!0,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"}],["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"}]]})}),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:"<span>{{ fsat }}</span>",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","showPayments"],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:1,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;t<i.length;++t)s[t]=i.charCodeAt(t);return s},toggleNotifications(){this.isSubscribed?this.unsubscribe():this.subscribe()},saveUserSubscribed(t){let e=JSON.parse(this.$q.localStorage.getItem("lnbits.webpush.subscribedUsers"))||[];e.includes(t)||e.push(t),this.$q.localStorage.set("lnbits.webpush.subscribedUsers",JSON.stringify(e))},removeUserSubscribed(t){let e=JSON.parse(this.$q.localStorage.getItem("lnbits.webpush.subscribedUsers"))||[];e=e.filter((e=>e!==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}},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      <component :is="$route.name"></component>\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:"/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(t){const e=this.rawFundingSources.find((e=>e[0]===t));return e?e[1]:t}},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]={label:e,value:null};t.push([e,i])}return new Map(t)},sortedAllowedFundingSources(){return this.allowedFundingSources.sort()}},data:()=>({hideInput:!0,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"}],["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"}]]})}),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:"<span>{{ fsat }}</span>",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","showPayments"],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:1,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;t<i.length;++t)s[t]=i.charCodeAt(t);return s},toggleNotifications(){this.isSubscribed?this.unsubscribe():this.subscribe()},saveUserSubscribed(t){let e=JSON.parse(this.$q.localStorage.getItem("lnbits.webpush.subscribedUsers"))||[];e.includes(t)||e.push(t),this.$q.localStorage.set("lnbits.webpush.subscribedUsers",JSON.stringify(e))},removeUserSubscribed(t){let e=JSON.parse(this.$q.localStorage.getItem("lnbits.webpush.subscribedUsers"))||[];e=e.filter((e=>e!==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}},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      <component :is="$route.name"></component>\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:"/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");
diff --git a/lnbits/static/js/components/lnbits-funding-sources.js b/lnbits/static/js/components/lnbits-funding-sources.js
index 06a074958e..7a53108f41 100644
--- a/lnbits/static/js/components/lnbits-funding-sources.js
+++ b/lnbits/static/js/components/lnbits-funding-sources.js
@@ -197,6 +197,14 @@ window.app.component('lnbits-funding-sources', {
             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'
+          }
         ]
       ]
     }
diff --git a/lnbits/wallets/__init__.py b/lnbits/wallets/__init__.py
index 5bf0095ded..a1076a484f 100644
--- a/lnbits/wallets/__init__.py
+++ b/lnbits/wallets/__init__.py
@@ -28,6 +28,7 @@
 from .opennode import OpenNodeWallet
 from .phoenixd import PhoenixdWallet
 from .spark import SparkWallet
+from .strike import StrikeWallet
 from .void import VoidWallet
 from .zbd import ZBDWallet
 
@@ -72,6 +73,7 @@ def get_funding_source() -> Wallet:
     "OpenNodeWallet",
     "PhoenixdWallet",
     "SparkWallet",
+    "StrikeWallet",
     "VoidWallet",
     "ZBDWallet",
 ]
diff --git a/lnbits/wallets/strike.py b/lnbits/wallets/strike.py
new file mode 100644
index 0000000000..4d53351bce
--- /dev/null
+++ b/lnbits/wallets/strike.py
@@ -0,0 +1,546 @@
+import asyncio
+import time
+from decimal import Decimal
+from typing import Any, AsyncGenerator, Dict, Optional
+
+import httpx
+from loguru import logger
+
+from lnbits.settings import settings
+
+from .base import (
+    InvoiceResponse,
+    PaymentFailedStatus,
+    PaymentPendingStatus,
+    PaymentResponse,
+    PaymentStatus,
+    PaymentSuccessStatus,
+    StatusResponse,
+    Wallet,
+)
+
+
+class TokenBucket:
+    """
+    Token bucket rate limiter for Strike API endpoints.
+    """
+
+    def __init__(self, rate: int, period_seconds: int):
+        """
+        Initialize a token bucket.
+
+        Args:
+            rate: Max requests allowed in the period
+            period_seconds: Time period in seconds.
+        """
+        self.rate = rate
+        self.period = period_seconds
+        self.tokens = rate
+        self.last_refill = time.monotonic()
+        self.lock = asyncio.Lock()
+
+    async def consume(self) -> None:
+        """Wait until a token is available and consume it."""
+        async with self.lock:
+            # Refill tokens based on elapsed time
+            now = time.monotonic()
+            elapsed = now - self.last_refill
+
+            if elapsed > 0:
+                new_tokens = int((elapsed / self.period) * self.rate)
+                self.tokens = min(self.rate, self.tokens + new_tokens)
+                self.last_refill = now
+
+            # If no tokens available, calculate wait time and wait for a token
+            if self.tokens < 1:
+                # Calculate time needed for one token
+                wait_time = (self.period / self.rate) * (1 - self.tokens)
+                await asyncio.sleep(wait_time)
+
+                # After waiting, update time and add one token
+                self.last_refill = time.monotonic()
+                self.tokens = 1
+
+            # Consume a token (will be 0 or more after consumption)
+            self.tokens -= 1
+
+
+class StrikeWallet(Wallet):
+    """
+    https://developer.strike.me/api
+    A minimal LNbits wallet backend for Strike.
+    """
+
+    # --------------------------------------------------------------------- #
+    # construction / teardown                                               #
+    # --------------------------------------------------------------------- #
+
+    def __init__(self):
+        if not settings.strike_api_endpoint:
+            raise ValueError("Missing strike_api_endpoint")
+        if not settings.strike_api_key:
+            raise ValueError("Missing strike_api_key")
+
+        super().__init__()
+
+        # throttle
+        self._sem = asyncio.Semaphore(value=20)
+
+        # Rate limiters for different API endpoints
+        # Invoice/payment operations: 250 requests / 1 minute
+        self._invoice_limiter = TokenBucket(250, 60)
+        self._payment_limiter = TokenBucket(250, 60)
+        # All other operations: 1,000 requests / 10 minutes
+        self._general_limiter = TokenBucket(1000, 600)
+
+        self.client = httpx.AsyncClient(
+            base_url=self.normalize_endpoint(settings.strike_api_endpoint),
+            headers={
+                "Authorization": f"Bearer {settings.strike_api_key}",
+                "Content-Type": "application/json",
+                "Accept": "application/json",
+                "User-Agent": settings.user_agent,
+            },
+            timeout=httpx.Timeout(connect=5.0, read=40.0, write=10.0, pool=None),
+            transport=httpx.AsyncHTTPTransport(
+                limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
+                retries=0,  # we handle retries ourselves
+            ),
+        )
+
+        # runtime state
+        self.pending_invoices: list[str] = []  # Keep it as a list
+        self.pending_payments: Dict[str, str] = {}
+        self.failed_payments: Dict[str, str] = {}
+
+        # balance cache
+        self._cached_balance: Optional[int] = None
+        self._cached_balance_ts: float = 0.0
+        self._cache_ttl = 30  # seconds
+
+    async def cleanup(self):
+        try:
+            await self.client.aclose()
+        except Exception:
+            logger.warning("Error closing Strike client")
+
+    # --------------------------------------------------------------------- #
+    # low-level request helpers                                             #
+    # --------------------------------------------------------------------- #
+
+    async def _req(self, method: str, path: str, /, **kw) -> httpx.Response:
+        """Make a Strike HTTP call with:
+        One Strike HTTP call with
+            • rate limiting based on endpoint type
+            • concurrency throttle
+            • exponential back-off + jitter
+            • explicit retry on 429/5xx
+            • latency logging
+        """
+        # Apply the appropriate rate limiter based on the endpoint path.
+        if path.startswith("/invoices") or path.startswith("/receive-requests"):
+            await self._invoice_limiter.consume()
+        elif path.startswith("/payment-quotes"):
+            await self._payment_limiter.consume()
+        else:
+            await self._general_limiter.consume()
+
+        async with self._sem:
+            return await self.client.request(method, path, **kw)
+
+    # Typed wrappers - so call-sites stay tidy.
+    async def _get(self, path: str, **kw) -> httpx.Response:  # GET request.
+        return await self._req("GET", path, **kw)
+
+    async def _post(self, path: str, **kw) -> httpx.Response:
+        return await self._req("POST", path, **kw)
+
+    async def _patch(self, path: str, **kw) -> httpx.Response:
+        return await self._req("PATCH", path, **kw)
+
+    # --------------------------------------------------------------------- #
+    # LNbits wallet API implementation                                      #
+    # --------------------------------------------------------------------- #
+
+    async def status(self) -> StatusResponse:
+        """
+        Return wallet balance (millisatoshis) with a 30-second cache.
+        """
+        now = time.time()
+        if (
+            self._cached_balance is not None
+            and now - self._cached_balance_ts < self._cache_ttl
+        ):
+            return StatusResponse(None, self._cached_balance)
+
+        try:
+            r = await self._get("/balances")
+            r.raise_for_status()
+            data = r.json()
+            balances = data.get("data", []) if isinstance(data, dict) else data
+            btc = next((b for b in balances if b.get("currency") == "BTC"), None)
+            if btc and "available" in btc:
+                available_btc = Decimal(btc["available"])  # Get available BTC amount.
+                msats = int(
+                    available_btc * Decimal(1e11)
+                )  # Convert BTC to millisatoshis.
+                self._cached_balance = msats
+                self._cached_balance_ts = now
+                return StatusResponse(None, msats)
+
+            return StatusResponse(None, 0)
+
+        except Exception as e:
+            logger.warning(e)
+            return StatusResponse("Connection error", 0)
+
+    async def create_invoice(
+        self,
+        amount: int,
+        memo: Optional[str] = None,
+        description_hash: Optional[bytes] = None,
+        unhashed_description: bytes | None = None,  # Add this parameter
+        **kwargs,
+    ) -> InvoiceResponse:
+        try:
+            btc_amt = (Decimal(amount) / Decimal(1e8)).quantize(
+                Decimal("0.00000001")
+            )  # Convert amount from millisatoshis to BTC.
+            payload: Dict[str, Any] = {
+                "bolt11": {
+                    "amount": {
+                        "currency": "BTC",
+                        "amount": str(btc_amt),
+                    },
+                    "description": memo or "",
+                },
+                "targetCurrency": "BTC",
+            }
+            if description_hash:
+                payload["bolt11"]["descriptionHash"] = description_hash.hex()
+
+            r = await self._post(
+                "/receive-requests",
+                json=payload,
+            )
+            r.raise_for_status()
+            resp = r.json()
+            invoice_id = resp.get("receiveRequestId")
+            bolt11 = resp.get("bolt11", {}).get("invoice")
+            if not invoice_id or not bolt11:
+                return InvoiceResponse(
+                    ok=False, error_message="Invalid invoice response"
+                )
+
+            self.pending_invoices.append(invoice_id)
+            return InvoiceResponse(
+                ok=True, checking_id=invoice_id, payment_request=bolt11
+            )
+        except httpx.HTTPStatusError as e:
+            logger.warning(e)
+            msg = e.response.json().get("message", e.response.text)
+            return InvoiceResponse(ok=False, error_message=f"Strike API error: {msg}")
+        except Exception as e:
+            logger.warning(e)
+            return InvoiceResponse(ok=False, error_message="Connection error")
+
+    async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
+        try:
+            # 1) Create a payment quote.
+            q = await self._post(
+                "/payment-quotes/lightning",
+                json={"lnInvoice": bolt11},
+            )
+            q.raise_for_status()
+            quote_id = q.json().get("paymentQuoteId")
+            if not quote_id:
+                return PaymentResponse(
+                    ok=False, error_message="Strike: missing payment quote Id"
+                )
+
+            # 2) Execute the payment quote.
+            e = await self._patch(f"/payment-quotes/{quote_id}/execute")
+            e.raise_for_status()
+
+            data = e.json() if e.content else {}
+            payment_id = data.get("paymentId")
+            state = data.get("state", "").upper()
+
+            # Network fee → msat.
+            fee_obj = data.get("lightningNetworkFee") or data.get("totalFee") or {}
+            fee_btc = Decimal(fee_obj.get("amount", "0"))
+            fee_msat = int(fee_btc * Decimal(1e11))  # millisatoshis.
+
+            if state in {"SUCCEEDED", "COMPLETED"}:
+                preimage = data.get("preimage") or data.get("preImage")
+                return PaymentResponse(
+                    ok=True,
+                    checking_id=payment_id,
+                    fee_msat=fee_msat,
+                    preimage=preimage,
+                )
+
+            failed_states = {
+                "CANCELED",
+                "FAILED",
+                "TIMED_OUT",
+            }
+            if state in failed_states:
+                return PaymentResponse(
+                    ok=False, checking_id=payment_id, error_message=f"State: {state}"
+                )
+
+            # Store mapping for later polling.
+            if payment_id:
+                # todo: this will be lost on server restart
+                self.pending_payments[payment_id] = quote_id
+
+            # Treat all other states as pending (including unknown states).
+            return PaymentResponse(ok=None, checking_id=payment_id)
+
+        except Exception as e:
+            logger.warning(e)
+            # Keep pending. Not sure if the payment went trough or not.
+            return PaymentResponse(ok=None, error_message="Connection error")
+
+    async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
+        try:
+            r = await self._get(f"/receive-requests/{checking_id}/receives")
+
+            if r.status_code == 200:
+                data = r.json()
+                items = data.get("items", [])
+
+                if not items:
+                    # Still pending.
+                    return PaymentPendingStatus()
+
+                for item in items:
+                    if item.get("state") == "COMPLETED":
+                        preimage = None
+                        lightning_data = item.get("lightning")
+                        if lightning_data:
+                            preimage = lightning_data.get(
+                                "preimage"
+                            ) or lightning_data.get("preImage")
+
+                        return PaymentSuccessStatus(fee_msat=0, preimage=preimage)
+
+                return PaymentPendingStatus()
+
+            if r.status_code == 404:
+                logger.warning(f"Payment '{checking_id}' not found. Marking as failed.")
+                return PaymentFailedStatus(False)
+
+            r.raise_for_status()
+            return PaymentPendingStatus()
+
+        except httpx.HTTPStatusError as e:
+            logger.warning(
+                f"HTTPStatusError in get_invoice_status for checking_id {checking_id} "
+                f"on URL {e.request.url}: {e.response.status_code} - {e.response.text}"
+            )
+            # Default to Pending to allow retries by paid_invoices_stream.
+            return PaymentPendingStatus()
+        except Exception as e:
+            logger.warning(e)
+            return PaymentPendingStatus()
+
+    async def get_payment_status(self, checking_id: str) -> PaymentStatus:
+        quote_id = self.pending_payments.get(checking_id)
+
+        try:
+            # Attempt 1: Use quote_id if available (from in-memory store)
+            if quote_id:
+                status = await self._get_payment_status_by_quote_id(
+                    checking_id, quote_id
+                )
+                if status:
+                    return status
+        except Exception as e:
+            logger.warning(e)
+            logger.debug(f"Error while fetching payment by quote id {checking_id}.")
+
+        try:
+            # Attempt 2: Fallback - Use paymentId (checking_id) directly.
+            return await self._get_payment_status_by_checking_id(checking_id)
+        except Exception as e:
+            logger.warning(e)
+            logger.debug(f"Error while fetching payment {checking_id}.")
+            return PaymentPendingStatus()
+
+    async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
+        """
+        Poll Strike for invoice settlement while respecting the documented API limits.
+
+        Uses dynamic adjustment of polling frequency based on activity.
+        """
+        min_poll, max_poll = 1, 15
+        # 1,000 requests / 10 minutes = ~100 requests/minute.
+        rate_limit = 100
+        sleep_s = min_poll
+        # Main loop for polling invoices.
+        self._running = True
+
+        while self._running and settings.lnbits_running:
+            loop_start = time.time()
+            had_activity = False
+
+            req_budget = max(
+                1, rate_limit * sleep_s // 60
+            )  # Calculate request budget based on sleep time.
+            processed = 0
+
+            for inv in list(self.pending_invoices):
+                if processed >= req_budget:  # If request budget is exhausted.
+                    break
+                status = await self.get_invoice_status(inv)
+                processed += 1
+
+                if status.success or status.failed:
+                    self.pending_invoices.remove(inv)
+                    if status.success:
+                        had_activity = True
+                        yield inv
+
+            # Dynamic adjustment of polling frequency based on activity.
+            sleep_s = (
+                max(min_poll, sleep_s // 2)
+                if had_activity
+                else min(max_poll, sleep_s * 2)
+            )
+
+            # Sleep to respect rate limits.
+            elapsed = time.time() - loop_start
+            # Ensure we respect the rate limit, even with dynamic adjustment.
+            min_sleep_for_rate = processed * 60 / rate_limit - elapsed
+            await asyncio.sleep(max(sleep_s, min_sleep_for_rate, 0))
+
+    # ------------------------------------------------------------------ #
+    # misc Strike helpers                                                #
+    # ------------------------------------------------------------------ #
+
+    async def get_invoices(
+        self,
+        filters: Optional[str] = None,
+        orderby: Optional[str] = None,
+        skip: Optional[int] = None,
+        top: Optional[int] = None,
+    ) -> Dict[str, Any]:
+        try:
+            params: Dict[str, Any] = {}
+            if filters:
+                params["$filter"] = filters
+            if orderby:
+                params["$orderby"] = orderby
+            if skip is not None:
+                params["$skip"] = skip
+            if top is not None:
+                params["$top"] = top
+            r = await self._get(
+                "/invoices", params=params
+            )  # Get invoices from Strike API.
+            r.raise_for_status()
+            return r.json()
+        except Exception:
+            logger.warning("Error in get_invoices()")
+            return {"error": "unable to fetch invoices"}
+
+    async def _get_payment_status_by_quote_id(
+        self, checking_id: str, quote_id: str
+    ) -> Optional[PaymentStatus]:
+        resp = await self._get(f"/payment-quotes/{quote_id}")
+        resp.raise_for_status()
+
+        data = resp.json()
+        state = data.get("state", "").upper()
+        preimage = data.get("preimage") or data.get("preImage")
+
+        fee_msat = 0
+        fee_obj = data.get("lightningNetworkFee") or data.get("totalFee")
+        if fee_obj and fee_obj.get("amount") and fee_obj.get("currency"):
+            amount_str = fee_obj.get("amount")
+            currency_str = fee_obj.get("currency").upper()
+            try:
+                if currency_str == "BTC":
+                    fee_btc_decimal = Decimal(amount_str)
+                    fee_msat = int(fee_btc_decimal * Decimal(1e11))
+                elif currency_str == "SAT":
+                    fee_sat_decimal = Decimal(amount_str)
+                    fee_msat = int(fee_sat_decimal * 1000)
+            except Exception as e:
+                logger.warning(e)
+                logger.warning(
+                    f"Fee parse error. Quote: '{quote_id}'. "
+                    f"Payment '{checking_id}'."
+                )
+                fee_msat = 0
+
+        if state in {"SUCCEEDED", "COMPLETED"}:
+            self.pending_payments.pop(checking_id, None)
+            return PaymentSuccessStatus(fee_msat=fee_msat, preimage=preimage)
+        if state == "FAILED":
+            self.pending_payments.pop(checking_id, None)
+            return PaymentFailedStatus()
+
+        return None
+
+    async def _get_payment_status_by_checking_id(
+        self, checking_id: str
+    ) -> PaymentStatus:
+        r_payment = await self._get(f"/payments/{checking_id}")
+
+        if r_payment.status_code == 200:
+            data = r_payment.json()
+            state = data.get("state", "").upper()
+            preimage = None
+            fee_msat = 0
+
+            if state in {"SUCCEEDED", "COMPLETED"}:
+                self.pending_payments.pop(checking_id, None)
+                return PaymentSuccessStatus(fee_msat=fee_msat, preimage=preimage)
+            if state == "FAILED":
+                self.pending_payments.pop(checking_id, None)
+                return PaymentFailedStatus()
+
+            return PaymentPendingStatus()
+
+        if r_payment.status_code == 400:
+            try:
+                error_data = r_payment.json()
+                # Check for Strike's specific validation
+                # error structure for paymentId format
+                if error_data.get("data", {}).get("code") == "INVALID_DATA":
+                    validation_errors = error_data.get("data", {}).get(
+                        "validationErrors", {}
+                    )
+                    if "paymentId" in validation_errors:
+                        for err_detail in validation_errors["paymentId"]:
+                            is_invalid = err_detail.get(
+                                "code"
+                            ) == "INVALID_DATA" and "is not valid." in err_detail.get(
+                                "message", ""
+                            )
+                            if not is_invalid:
+                                continue
+                            logger.error(
+                                f"Payment '{checking_id}' not a valid Strike payment. "
+                                f"Marked as failed. Response: {r_payment.text}"
+                            )
+                            self.pending_payments.pop(checking_id, None)
+                            return PaymentFailedStatus()
+            except Exception as e:
+                logger.warning(e)
+
+            return PaymentPendingStatus()
+
+        if r_payment.status_code == 404:
+            logger.warning(f"Payment {checking_id} not found. Marking as failed.")
+            self.pending_payments.pop(checking_id, None)
+            return PaymentFailedStatus()
+
+        logger.debug(
+            f"Error fetching payment {checking_id} directly: "
+            f"{r_payment.status_code} - {r_payment.text}"
+        )
+        return PaymentPendingStatus()


Reviews:
user: login: motorina0
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-09T17:56:38Z
user: login: motorina0
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-09T18:06:51Z
user: login: santyr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T18:11:28Z
user: login: dni
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-11T02:59:01Z
user: login: dni
body: looks pretty good! only thing i would add is having a preimage when creating an invoice.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-11T02:59:31Z
user: login: santyr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-11T12:59:23Z
user: login: dni
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-11T20:05:55Z
user: login: santyr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-11T20:58:36Z
user: login: dni
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-11T22:10:55Z
user: login: dni
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-11T22:17:13Z
user: login: santyr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-11T22:43:45Z
user: login: dni
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-12T14:22:09Z
user: login: motorina0
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-14T11:07:02Z
user: login: motorina0
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-14T11:20:51Z
user: login: santyr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T14:31:16Z
user: login: santyr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T14:53:11Z
user: login: dni
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T09:17:12Z
user: login: motorina0
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-16T11:34:30Z
user: login: motorina0
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-20T08:26:36Z

Review comments:
diff_hunk: @@ -0,0 +1,467 @@
+import asyncio
+import random
+import time
+import traceback
+import uuid
+from decimal import Decimal
+from typing import Any, AsyncGenerator, Dict, List, Optional
+
+import httpx
+from loguru import logger
+
+from lnbits.settings import settings
+from .base import (
+    InvoiceResponse,
+    PaymentPendingStatus,
+    PaymentResponse,
+    PaymentStatus,
+    PaymentSuccessStatus,
+    StatusResponse,
+    Wallet,
+    PaymentFailedStatus,
+)
+
+
+class TokenBucket:
+    """
+    Token bucket rate limiter for Strike API endpoints.
+    """
+    def __init__(self, rate: int, period_seconds: int):
+        """
+        Initialize a token bucket.
+        
+        Args:
+            rate: Max requests allowed in the period
+            period_seconds: Time period in seconds.
+        """
+        self.rate = rate
+        self.period = period_seconds
+        self.tokens = rate
+        self.last_refill = time.monotonic()
+        self.lock = asyncio.Lock()
+    
+    async def consume(self) -> None:
+        """Wait until a token is available and consume it."""
+        async with self.lock:
+            # Refill tokens based on elapsed time
+            now = time.monotonic()
+            elapsed = now - self.last_refill
+            
+            if elapsed > 0:
+                new_tokens = (elapsed / self.period) * self.rate
+                self.tokens = min(self.rate, self.tokens + new_tokens)
+                self.last_refill = now
+            
+            # If no tokens available, calculate wait time
+            if self.tokens < 1:
+                wait_time = (self.period / self.rate) * (1 - self.tokens)
+                await asyncio.sleep(wait_time)
+                self.tokens = 0  # Reset after waiting
+            
+            # Consume a token
+            self.tokens -= 1
+
+
+class StrikeWallet(Wallet):
+    """
+    https://developer.strike.me/api
+    A minimal LNbits wallet backend for Strike.
+    """
+
+    # --------------------------------------------------------------------- #
+    # construction / teardown                                               #
+    # --------------------------------------------------------------------- #
+
+    def __init__(self):
+        if not settings.strike_api_endpoint:
+            raise ValueError("Missing strike_api_endpoint")
+        if not settings.strike_api_key:
+            raise ValueError("Missing strike_api_key")
+
+        super().__init__()
+
+        # tuneables
+        self._MAX_PARALLEL_REQUESTS = 20
+        self._MAX_RETRIES = 3
+        self._RETRY_STATUS = {429, 500, 502, 503, 504}
+        self._RETRY_BACKOFF_BASE = 2  # seconds (exponential)
+
+        # throttle
+        self._sem = asyncio.Semaphore(self._MAX_PARALLEL_REQUESTS)
+
+        # Rate limiters for different API endpoints
+        # Invoice/payment operations: 250 requests / 1 minute
+        self._invoice_limiter = TokenBucket(250, 60)
+        self._payment_limiter = TokenBucket(250, 60)
+        # All other operations: 1,000 requests / 10 minutes
+        self._general_limiter = TokenBucket(1000, 600)
+
+        self.client = httpx.AsyncClient(
+            base_url=self.normalize_endpoint(settings.strike_api_endpoint),
+            headers={
+                "Authorization": f"Bearer {settings.strike_api_key}",
+                "Content-Type": "application/json",
+                "Accept": "application/json",
+                "User-Agent": settings.user_agent,
+            },
+            timeout=httpx.Timeout(connect=5.0, read=40.0, write=10.0, pool=None),
+            transport=httpx.AsyncHTTPTransport(
+                limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
+                retries=0,  # we handle retries ourselves
+            ),
+        )
+
+        # runtime state
+        self.pending_invoices: list[str] = []  # Keep it as a list
+        self.pending_payments: Dict[str, str] = {}
+        self.failed_payments: Dict[str, str] = {}
+
+        # balance cache
+        self._cached_balance: Optional[int] = None
+        self._cached_balance_ts: float = 0.0
+        self._cache_ttl = 30  # seconds
+
+    async def cleanup(self):
+        try:
+            await self.client.aclose()
+        except Exception:
+            logger.exception("Error closing Strike client")
+
+    # --------------------------------------------------------------------- #
+    # low-level request helpers                                             #
+    # --------------------------------------------------------------------- #
+
+    async def _req(self, method: str, path: str, /, **kw) -> httpx.Response:
+        """Make a Strike HTTP call with:
+        One Strike HTTP call with
+            • rate limiting based on endpoint type
+            • concurrency throttle
+            • exponential back-off + jitter
+            • explicit retry on 429/5xx
+            • latency logging
+        """
+        # Apply the appropriate rate limiter based on the endpoint path.
+        if path.startswith("/invoices") or path.startswith("/receive-requests"):
+            await self._invoice_limiter.consume()
+        elif path.startswith("/payment-quotes"):
+            await self._payment_limiter.consume()
+        else:
+            await self._general_limiter.consume()
+            
+        async with self._sem:
+            start = time.perf_counter()
+
+            for attempt in range(self._MAX_RETRIES + 1):
+                try:
+                    resp = await self.client.request(method, path, **kw)
+                    resp.raise_for_status()
+                    logger.trace(
+                        "Strike {m} {p} – {s} in {t:.1f} ms".format(
+                            m=method.upper(),
+                            p=path,
+                            s=resp.status_code,
+                            t=(time.perf_counter() - start) * 1000,
+                        )
+                    )
+                    return resp
+
+                except httpx.HTTPStatusError as e:
+                    if (  # Only retry on specific status codes.
+                        e.response.status_code not in self._RETRY_STATUS
+                        or attempt == self._MAX_RETRIES
+                    ):
+                        raise
+                    logger.warning(
+                        "Strike {m} {p} -> {c}; retry {a}/{n}".format(
+                            m=method.upper(),
+                            p=path,
+                            c=e.response.status_code,
+                            a=attempt + 1,
+                            n=self._MAX_RETRIES,
+                        )
+                    )
+
+                except httpx.TransportError as e:
+                    if attempt == self._MAX_RETRIES:  # No more retries left.
+                        raise
+                    logger.warning(
+                        "Transport error contacting Strike ({err}); retry {a}/{n}".format(
+                            err=e, a=attempt + 1, n=self._MAX_RETRIES
+                        )
+                    )
+
+                delay = (self._RETRY_BACKOFF_BASE ** attempt) + (0.1 * random.random())  # Exponential backoff with jitter.
+                await asyncio.sleep(delay)
+
+        raise RuntimeError("exceeded retry budget in _req")
+
+    # Typed wrappers – so call-sites stay tidy.
+    async def _get(self, path: str, **kw) -> httpx.Response:  # GET request.
+        return await self._req("GET", path, **kw)
+
+    async def _post(self, path: str, **kw) -> httpx.Response:
+        return await self._req("POST", path, **kw)
+
+    async def _patch(self, path: str, **kw) -> httpx.Response:
+        return await self._req("PATCH", path, **kw)
+
+    # --------------------------------------------------------------------- #
+    # LNbits wallet API implementation                                      #
+    # --------------------------------------------------------------------- #
+
+    async def status(self) -> StatusResponse:
+        """
+        Return wallet balance (millisatoshis) with a 30-second cache.
+        """
+        now = time.time()
+        if (
+            self._cached_balance is not None
+            and now - self._cached_balance_ts < self._cache_ttl
+        ):
+            return StatusResponse(None, self._cached_balance)
+
+        try:
+            r = await self._get("/balances")  # Get balances from Strike API.
+            data = r.json()  # Parse JSON response.
+            balances = data.get("data", []) if isinstance(data, dict) else data  # Extract balances or use an empty list.
+            btc = next((b for b in balances if b.get("currency") == "BTC"), None)  # Find BTC balance.
+            if btc and "available" in btc:  # Check if BTC balance and available amount exist.
+                available_btc = Decimal(btc["available"])  # Get available BTC amount.
+                msats = int(available_btc * Decimal(1e11))  # Convert BTC to millisatoshis.
+                self._cached_balance = msats  # Update cached balance.
+                self._cached_balance_ts = now  # Update cache timestamp.
+                return StatusResponse(None, msats)  # Return successful status with balance.
+            # No BTC balance found.
+            return StatusResponse(None, 0)
+        except httpx.HTTPStatusError as e:
+            logger.error(f"Strike API error: {e.response.text}")
+            return StatusResponse(f"Strike API error: {e.response.text}", 0)
+        except Exception:
+            logger.exception("Unexpected error in status()")
+            return StatusResponse("Connection error", 0)
+
+    async def create_invoice(
+        self,
+        amount: int,
+        memo: Optional[str] = None,
+        description_hash: Optional[bytes] = None,
+        unhashed_description: bytes | None = None,  # Add this parameter
+        **kwargs,
+    ) -> InvoiceResponse:
+        try:
+            idem = kwargs.get("idempotency_key") or str(uuid.uuid4())  # Use provided idempotency key or generate a new one.
+            btc_amt = (Decimal(amount) / Decimal(1e8)).quantize(Decimal("0.00000001"))  # Convert amount from millisatoshis to BTC.
+            payload: Dict[str, Any] = {
+                "bolt11": {
+                    "amount": {"currency": "BTC", "amount": str(btc_amt)},  # Set amount in BTC.
+                    "description": memo or "",
+                },
+                "targetCurrency": "BTC",
+            }
+            if description_hash:
+                payload["bolt11"]["descriptionHash"] = description_hash.hex()
+
+            r = await self._post(
+                "/receive-requests",  # Create a receive request (invoice) on Strike.
+                json=payload,
+                headers={**self.client.headers, "idempotency-key": idem},
+            )
+            resp = r.json()  # Parse JSON response.
+            invoice_id = resp.get("receiveRequestId")  # Get the receive request ID.
+            bolt11 = resp.get("bolt11", {}).get("invoice")  # Get the bolt11 invoice.
+            if not invoice_id or not bolt11:  # Check if both invoice ID and bolt11 are present.
+                return InvoiceResponse(False, None, None, "Invalid invoice response")
+
+            self.pending_invoices.append(invoice_id)  # Add invoice ID to pending invoices.
+            return InvoiceResponse(True, invoice_id, bolt11, None)  # Return successful invoice response.
+        except httpx.HTTPStatusError as e:
+            msg = e.response.json().get("message", e.response.text)  # Get error message from response.
+            return InvoiceResponse(False, None, None, f"Strike API error: {msg}")
+        except Exception:
+            logger.exception("Error in create_invoice()")
+            return InvoiceResponse(False, None, None, "Connection error")
+
+    async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
+        try:
+            idem = str(uuid.uuid4())
+            # 1) Create a payment quote.
+            q = await self._post(
+                "/payment-quotes/lightning",
+                json={"lnInvoice": bolt11},
+                headers={**self.client.headers, "idempotency-key": idem},
+            )
+            quote_id = q.json().get("paymentQuoteId")  # Get the payment quote ID.
+            if not quote_id:  # Check if the quote ID is present.
+                return PaymentResponse(
+                    False, None, None, None, "Strike: missing paymentQuoteId"
+                )
+
+            # 2) Execute the payment quote.
+            e = await self._patch(f"/payment-quotes/{quote_id}/execute")
+            data = e.json() if e.content else {}  # Parse JSON response or use an empty dictionary.
+            payment_id = data.get("paymentId")  # Get the payment ID.
+            state = data.get("state", "").upper()  # Get the payment state and convert it to uppercase.
+
+            # Network fee → msat.
+            fee_obj = data.get("lightningNetworkFee") or data.get("totalFee") or {}  # Get fee object.
+            fee_btc = Decimal(fee_obj.get("amount", "0"))  # Get fee amount in BTC.
+            fee_msat = int(fee_btc * Decimal(1e11))  # Convert fee from BTC to millisatoshis.
+
+            # Store mapping for later polling.
+            if payment_id:  # If payment ID is present.
+                self.pending_payments[payment_id] = quote_id
+
+            if state in {"SUCCEEDED", "COMPLETED"}:  # If payment succeeded.
+                preimage = data.get("preimage") or data.get("preImage")  # Get payment preimage.
+                return PaymentResponse(True, payment_id, fee_msat, preimage, None)  # Return successful payment response.
+
+            # Strike often returns 202/PENDING immediately; treat as “still working”.
+            if state in {"PENDING", "QUEUED", "READY_TO_SETTLE", ""}:  # If payment is still pending.
+                return PaymentResponse(None, payment_id, None, None, None)  # Return pending payment response.
+
+            return PaymentResponse(False, payment_id, None, None, f"State: {state}")  # Return failed payment response with state.
path: lnbits/wallets/strike.py
user: login: motorina0
body: Isn't there an explicit list of states for 'FAILED'?
If a new `pending` status is introduced by the API then it will not be recognized and the payment will be marked as failed (which has an impact on the wallet balance).

It is better to check explicitly for `failed` and leave the default branch for `pending`.
created_at: 2025-05-09T17:56:38Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,467 @@
+import asyncio
+import random
+import time
+import traceback
+import uuid
+from decimal import Decimal
+from typing import Any, AsyncGenerator, Dict, List, Optional
+
+import httpx
+from loguru import logger
+
+from lnbits.settings import settings
+from .base import (
+    InvoiceResponse,
+    PaymentPendingStatus,
+    PaymentResponse,
+    PaymentStatus,
+    PaymentSuccessStatus,
+    StatusResponse,
+    Wallet,
+    PaymentFailedStatus,
+)
+
+
+class TokenBucket:
+    """
+    Token bucket rate limiter for Strike API endpoints.
+    """
+    def __init__(self, rate: int, period_seconds: int):
+        """
+        Initialize a token bucket.
+        
+        Args:
+            rate: Max requests allowed in the period
+            period_seconds: Time period in seconds.
+        """
+        self.rate = rate
+        self.period = period_seconds
+        self.tokens = rate
+        self.last_refill = time.monotonic()
+        self.lock = asyncio.Lock()
+    
+    async def consume(self) -> None:
+        """Wait until a token is available and consume it."""
+        async with self.lock:
+            # Refill tokens based on elapsed time
+            now = time.monotonic()
+            elapsed = now - self.last_refill
+            
+            if elapsed > 0:
+                new_tokens = (elapsed / self.period) * self.rate
+                self.tokens = min(self.rate, self.tokens + new_tokens)
+                self.last_refill = now
+            
+            # If no tokens available, calculate wait time
+            if self.tokens < 1:
+                wait_time = (self.period / self.rate) * (1 - self.tokens)
+                await asyncio.sleep(wait_time)
+                self.tokens = 0  # Reset after waiting
+            
+            # Consume a token
+            self.tokens -= 1
+
+
+class StrikeWallet(Wallet):
+    """
+    https://developer.strike.me/api
+    A minimal LNbits wallet backend for Strike.
+    """
+
+    # --------------------------------------------------------------------- #
+    # construction / teardown                                               #
+    # --------------------------------------------------------------------- #
+
+    def __init__(self):
+        if not settings.strike_api_endpoint:
+            raise ValueError("Missing strike_api_endpoint")
+        if not settings.strike_api_key:
+            raise ValueError("Missing strike_api_key")
+
+        super().__init__()
+
+        # tuneables
+        self._MAX_PARALLEL_REQUESTS = 20
+        self._MAX_RETRIES = 3
+        self._RETRY_STATUS = {429, 500, 502, 503, 504}
+        self._RETRY_BACKOFF_BASE = 2  # seconds (exponential)
+
+        # throttle
+        self._sem = asyncio.Semaphore(self._MAX_PARALLEL_REQUESTS)
+
+        # Rate limiters for different API endpoints
+        # Invoice/payment operations: 250 requests / 1 minute
+        self._invoice_limiter = TokenBucket(250, 60)
+        self._payment_limiter = TokenBucket(250, 60)
+        # All other operations: 1,000 requests / 10 minutes
+        self._general_limiter = TokenBucket(1000, 600)
+
+        self.client = httpx.AsyncClient(
+            base_url=self.normalize_endpoint(settings.strike_api_endpoint),
+            headers={
+                "Authorization": f"Bearer {settings.strike_api_key}",
+                "Content-Type": "application/json",
+                "Accept": "application/json",
+                "User-Agent": settings.user_agent,
+            },
+            timeout=httpx.Timeout(connect=5.0, read=40.0, write=10.0, pool=None),
+            transport=httpx.AsyncHTTPTransport(
+                limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
+                retries=0,  # we handle retries ourselves
+            ),
+        )
+
+        # runtime state
+        self.pending_invoices: list[str] = []  # Keep it as a list
+        self.pending_payments: Dict[str, str] = {}
+        self.failed_payments: Dict[str, str] = {}
+
+        # balance cache
+        self._cached_balance: Optional[int] = None
+        self._cached_balance_ts: float = 0.0
+        self._cache_ttl = 30  # seconds
+
+    async def cleanup(self):
+        try:
+            await self.client.aclose()
+        except Exception:
+            logger.exception("Error closing Strike client")
+
+    # --------------------------------------------------------------------- #
+    # low-level request helpers                                             #
+    # --------------------------------------------------------------------- #
+
+    async def _req(self, method: str, path: str, /, **kw) -> httpx.Response:
+        """Make a Strike HTTP call with:
+        One Strike HTTP call with
+            • rate limiting based on endpoint type
+            • concurrency throttle
+            • exponential back-off + jitter
+            • explicit retry on 429/5xx
+            • latency logging
+        """
+        # Apply the appropriate rate limiter based on the endpoint path.
+        if path.startswith("/invoices") or path.startswith("/receive-requests"):
+            await self._invoice_limiter.consume()
+        elif path.startswith("/payment-quotes"):
+            await self._payment_limiter.consume()
+        else:
+            await self._general_limiter.consume()
+            
+        async with self._sem:
+            start = time.perf_counter()
+
+            for attempt in range(self._MAX_RETRIES + 1):
+                try:
+                    resp = await self.client.request(method, path, **kw)
+                    resp.raise_for_status()
+                    logger.trace(
+                        "Strike {m} {p} – {s} in {t:.1f} ms".format(
+                            m=method.upper(),
+                            p=path,
+                            s=resp.status_code,
+                            t=(time.perf_counter() - start) * 1000,
+                        )
+                    )
+                    return resp
+
+                except httpx.HTTPStatusError as e:
+                    if (  # Only retry on specific status codes.
+                        e.response.status_code not in self._RETRY_STATUS
+                        or attempt == self._MAX_RETRIES
+                    ):
+                        raise
+                    logger.warning(
+                        "Strike {m} {p} -> {c}; retry {a}/{n}".format(
+                            m=method.upper(),
+                            p=path,
+                            c=e.response.status_code,
+                            a=attempt + 1,
+                            n=self._MAX_RETRIES,
+                        )
+                    )
+
+                except httpx.TransportError as e:
+                    if attempt == self._MAX_RETRIES:  # No more retries left.
+                        raise
+                    logger.warning(
+                        "Transport error contacting Strike ({err}); retry {a}/{n}".format(
+                            err=e, a=attempt + 1, n=self._MAX_RETRIES
+                        )
+                    )
+
+                delay = (self._RETRY_BACKOFF_BASE ** attempt) + (0.1 * random.random())  # Exponential backoff with jitter.
+                await asyncio.sleep(delay)
+
+        raise RuntimeError("exceeded retry budget in _req")
+
+    # Typed wrappers – so call-sites stay tidy.
+    async def _get(self, path: str, **kw) -> httpx.Response:  # GET request.
+        return await self._req("GET", path, **kw)
+
+    async def _post(self, path: str, **kw) -> httpx.Response:
+        return await self._req("POST", path, **kw)
+
+    async def _patch(self, path: str, **kw) -> httpx.Response:
+        return await self._req("PATCH", path, **kw)
+
+    # --------------------------------------------------------------------- #
+    # LNbits wallet API implementation                                      #
+    # --------------------------------------------------------------------- #
+
+    async def status(self) -> StatusResponse:
+        """
+        Return wallet balance (millisatoshis) with a 30-second cache.
+        """
+        now = time.time()
+        if (
+            self._cached_balance is not None
+            and now - self._cached_balance_ts < self._cache_ttl
+        ):
+            return StatusResponse(None, self._cached_balance)
+
+        try:
+            r = await self._get("/balances")  # Get balances from Strike API.
+            data = r.json()  # Parse JSON response.
+            balances = data.get("data", []) if isinstance(data, dict) else data  # Extract balances or use an empty list.
+            btc = next((b for b in balances if b.get("currency") == "BTC"), None)  # Find BTC balance.
+            if btc and "available" in btc:  # Check if BTC balance and available amount exist.
+                available_btc = Decimal(btc["available"])  # Get available BTC amount.
+                msats = int(available_btc * Decimal(1e11))  # Convert BTC to millisatoshis.
+                self._cached_balance = msats  # Update cached balance.
+                self._cached_balance_ts = now  # Update cache timestamp.
+                return StatusResponse(None, msats)  # Return successful status with balance.
+            # No BTC balance found.
+            return StatusResponse(None, 0)
+        except httpx.HTTPStatusError as e:
+            logger.error(f"Strike API error: {e.response.text}")
+            return StatusResponse(f"Strike API error: {e.response.text}", 0)
+        except Exception:
+            logger.exception("Unexpected error in status()")
+            return StatusResponse("Connection error", 0)
+
+    async def create_invoice(
+        self,
+        amount: int,
+        memo: Optional[str] = None,
+        description_hash: Optional[bytes] = None,
+        unhashed_description: bytes | None = None,  # Add this parameter
+        **kwargs,
+    ) -> InvoiceResponse:
+        try:
+            idem = kwargs.get("idempotency_key") or str(uuid.uuid4())  # Use provided idempotency key or generate a new one.
+            btc_amt = (Decimal(amount) / Decimal(1e8)).quantize(Decimal("0.00000001"))  # Convert amount from millisatoshis to BTC.
+            payload: Dict[str, Any] = {
+                "bolt11": {
+                    "amount": {"currency": "BTC", "amount": str(btc_amt)},  # Set amount in BTC.
+                    "description": memo or "",
+                },
+                "targetCurrency": "BTC",
+            }
+            if description_hash:
+                payload["bolt11"]["descriptionHash"] = description_hash.hex()
+
+            r = await self._post(
+                "/receive-requests",  # Create a receive request (invoice) on Strike.
+                json=payload,
+                headers={**self.client.headers, "idempotency-key": idem},
+            )
+            resp = r.json()  # Parse JSON response.
+            invoice_id = resp.get("receiveRequestId")  # Get the receive request ID.
+            bolt11 = resp.get("bolt11", {}).get("invoice")  # Get the bolt11 invoice.
+            if not invoice_id or not bolt11:  # Check if both invoice ID and bolt11 are present.
+                return InvoiceResponse(False, None, None, "Invalid invoice response")
+
+            self.pending_invoices.append(invoice_id)  # Add invoice ID to pending invoices.
+            return InvoiceResponse(True, invoice_id, bolt11, None)  # Return successful invoice response.
+        except httpx.HTTPStatusError as e:
+            msg = e.response.json().get("message", e.response.text)  # Get error message from response.
+            return InvoiceResponse(False, None, None, f"Strike API error: {msg}")
+        except Exception:
+            logger.exception("Error in create_invoice()")
+            return InvoiceResponse(False, None, None, "Connection error")
+
+    async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
+        try:
+            idem = str(uuid.uuid4())
+            # 1) Create a payment quote.
+            q = await self._post(
+                "/payment-quotes/lightning",
+                json={"lnInvoice": bolt11},
+                headers={**self.client.headers, "idempotency-key": idem},
+            )
+            quote_id = q.json().get("paymentQuoteId")  # Get the payment quote ID.
+            if not quote_id:  # Check if the quote ID is present.
+                return PaymentResponse(
+                    False, None, None, None, "Strike: missing paymentQuoteId"
+                )
+
+            # 2) Execute the payment quote.
+            e = await self._patch(f"/payment-quotes/{quote_id}/execute")
+            data = e.json() if e.content else {}  # Parse JSON response or use an empty dictionary.
+            payment_id = data.get("paymentId")  # Get the payment ID.
+            state = data.get("state", "").upper()  # Get the payment state and convert it to uppercase.
+
+            # Network fee → msat.
+            fee_obj = data.get("lightningNetworkFee") or data.get("totalFee") or {}  # Get fee object.
+            fee_btc = Decimal(fee_obj.get("amount", "0"))  # Get fee amount in BTC.
+            fee_msat = int(fee_btc * Decimal(1e11))  # Convert fee from BTC to millisatoshis.
+
+            # Store mapping for later polling.
+            if payment_id:  # If payment ID is present.
+                self.pending_payments[payment_id] = quote_id
+
+            if state in {"SUCCEEDED", "COMPLETED"}:  # If payment succeeded.
+                preimage = data.get("preimage") or data.get("preImage")  # Get payment preimage.
+                return PaymentResponse(True, payment_id, fee_msat, preimage, None)  # Return successful payment response.
+
+            # Strike often returns 202/PENDING immediately; treat as “still working”.
+            if state in {"PENDING", "QUEUED", "READY_TO_SETTLE", ""}:  # If payment is still pending.
+                return PaymentResponse(None, payment_id, None, None, None)  # Return pending payment response.
+
+            return PaymentResponse(False, payment_id, None, None, f"State: {state}")  # Return failed payment response with state.
+
+        except httpx.HTTPStatusError as e:
+            msg = e.response.json().get("message", e.response.text)  # Get error message from response.
+            return PaymentResponse(False, None, None, None, f"Strike API error: {msg}")  # Return payment response with error.
path: lnbits/wallets/strike.py
user: login: motorina0
body: There was a refactoring in this PR https://github.com/lnbits/lnbits/pull/3089 where explicit named parameters are used. Can you please use that form.

Example:
https://github.com/lnbits/lnbits/pull/3089/files#diff-dd5221a5b7e9defe1cbdc1f3e3e0d709cdea670356f838088ee785950af45bedR175
created_at: 2025-05-09T18:06:51Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,467 @@
+import asyncio
+import random
+import time
+import traceback
+import uuid
+from decimal import Decimal
+from typing import Any, AsyncGenerator, Dict, List, Optional
+
+import httpx
+from loguru import logger
+
+from lnbits.settings import settings
+from .base import (
+    InvoiceResponse,
+    PaymentPendingStatus,
+    PaymentResponse,
+    PaymentStatus,
+    PaymentSuccessStatus,
+    StatusResponse,
+    Wallet,
+    PaymentFailedStatus,
+)
+
+
+class TokenBucket:
+    """
+    Token bucket rate limiter for Strike API endpoints.
+    """
+    def __init__(self, rate: int, period_seconds: int):
+        """
+        Initialize a token bucket.
+        
+        Args:
+            rate: Max requests allowed in the period
+            period_seconds: Time period in seconds.
+        """
+        self.rate = rate
+        self.period = period_seconds
+        self.tokens = rate
+        self.last_refill = time.monotonic()
+        self.lock = asyncio.Lock()
+    
+    async def consume(self) -> None:
+        """Wait until a token is available and consume it."""
+        async with self.lock:
+            # Refill tokens based on elapsed time
+            now = time.monotonic()
+            elapsed = now - self.last_refill
+            
+            if elapsed > 0:
+                new_tokens = (elapsed / self.period) * self.rate
+                self.tokens = min(self.rate, self.tokens + new_tokens)
+                self.last_refill = now
+            
+            # If no tokens available, calculate wait time
+            if self.tokens < 1:
+                wait_time = (self.period / self.rate) * (1 - self.tokens)
+                await asyncio.sleep(wait_time)
+                self.tokens = 0  # Reset after waiting
+            
+            # Consume a token
+            self.tokens -= 1
+
+
+class StrikeWallet(Wallet):
+    """
+    https://developer.strike.me/api
+    A minimal LNbits wallet backend for Strike.
+    """
+
+    # --------------------------------------------------------------------- #
+    # construction / teardown                                               #
+    # --------------------------------------------------------------------- #
+
+    def __init__(self):
+        if not settings.strike_api_endpoint:
+            raise ValueError("Missing strike_api_endpoint")
+        if not settings.strike_api_key:
+            raise ValueError("Missing strike_api_key")
+
+        super().__init__()
+
+        # tuneables
+        self._MAX_PARALLEL_REQUESTS = 20
+        self._MAX_RETRIES = 3
+        self._RETRY_STATUS = {429, 500, 502, 503, 504}
+        self._RETRY_BACKOFF_BASE = 2  # seconds (exponential)
+
+        # throttle
+        self._sem = asyncio.Semaphore(self._MAX_PARALLEL_REQUESTS)
+
+        # Rate limiters for different API endpoints
+        # Invoice/payment operations: 250 requests / 1 minute
+        self._invoice_limiter = TokenBucket(250, 60)
+        self._payment_limiter = TokenBucket(250, 60)
+        # All other operations: 1,000 requests / 10 minutes
+        self._general_limiter = TokenBucket(1000, 600)
+
+        self.client = httpx.AsyncClient(
+            base_url=self.normalize_endpoint(settings.strike_api_endpoint),
+            headers={
+                "Authorization": f"Bearer {settings.strike_api_key}",
+                "Content-Type": "application/json",
+                "Accept": "application/json",
+                "User-Agent": settings.user_agent,
+            },
+            timeout=httpx.Timeout(connect=5.0, read=40.0, write=10.0, pool=None),
+            transport=httpx.AsyncHTTPTransport(
+                limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
+                retries=0,  # we handle retries ourselves
+            ),
+        )
+
+        # runtime state
+        self.pending_invoices: list[str] = []  # Keep it as a list
+        self.pending_payments: Dict[str, str] = {}
+        self.failed_payments: Dict[str, str] = {}
+
+        # balance cache
+        self._cached_balance: Optional[int] = None
+        self._cached_balance_ts: float = 0.0
+        self._cache_ttl = 30  # seconds
+
+    async def cleanup(self):
+        try:
+            await self.client.aclose()
+        except Exception:
+            logger.exception("Error closing Strike client")
+
+    # --------------------------------------------------------------------- #
+    # low-level request helpers                                             #
+    # --------------------------------------------------------------------- #
+
+    async def _req(self, method: str, path: str, /, **kw) -> httpx.Response:
+        """Make a Strike HTTP call with:
+        One Strike HTTP call with
+            • rate limiting based on endpoint type
+            • concurrency throttle
+            • exponential back-off + jitter
+            • explicit retry on 429/5xx
+            • latency logging
+        """
+        # Apply the appropriate rate limiter based on the endpoint path.
+        if path.startswith("/invoices") or path.startswith("/receive-requests"):
+            await self._invoice_limiter.consume()
+        elif path.startswith("/payment-quotes"):
+            await self._payment_limiter.consume()
+        else:
+            await self._general_limiter.consume()
+            
+        async with self._sem:
+            start = time.perf_counter()
+
+            for attempt in range(self._MAX_RETRIES + 1):
+                try:
+                    resp = await self.client.request(method, path, **kw)
+                    resp.raise_for_status()
+                    logger.trace(
+                        "Strike {m} {p} – {s} in {t:.1f} ms".format(
+                            m=method.upper(),
+                            p=path,
+                            s=resp.status_code,
+                            t=(time.perf_counter() - start) * 1000,
+                        )
+                    )
+                    return resp
+
+                except httpx.HTTPStatusError as e:
+                    if (  # Only retry on specific status codes.
+                        e.response.status_code not in self._RETRY_STATUS
+                        or attempt == self._MAX_RETRIES
+                    ):
+                        raise
+                    logger.warning(
+                        "Strike {m} {p} -> {c}; retry {a}/{n}".format(
+                            m=method.upper(),
+                            p=path,
+                            c=e.response.status_code,
+                            a=attempt + 1,
+                            n=self._MAX_RETRIES,
+                        )
+                    )
+
+                except httpx.TransportError as e:
+                    if attempt == self._MAX_RETRIES:  # No more retries left.
+                        raise
+                    logger.warning(
+                        "Transport error contacting Strike ({err}); retry {a}/{n}".format(
+                            err=e, a=attempt + 1, n=self._MAX_RETRIES
+                        )
+                    )
+
+                delay = (self._RETRY_BACKOFF_BASE ** attempt) + (0.1 * random.random())  # Exponential backoff with jitter.
+                await asyncio.sleep(delay)
+
+        raise RuntimeError("exceeded retry budget in _req")
+
+    # Typed wrappers – so call-sites stay tidy.
+    async def _get(self, path: str, **kw) -> httpx.Response:  # GET request.
+        return await self._req("GET", path, **kw)
+
+    async def _post(self, path: str, **kw) -> httpx.Response:
+        return await self._req("POST", path, **kw)
+
+    async def _patch(self, path: str, **kw) -> httpx.Response:
+        return await self._req("PATCH", path, **kw)
+
+    # --------------------------------------------------------------------- #
+    # LNbits wallet API implementation                                      #
+    # --------------------------------------------------------------------- #
+
+    async def status(self) -> StatusResponse:
+        """
+        Return wallet balance (millisatoshis) with a 30-second cache.
+        """
+        now = time.time()
+        if (
+            self._cached_balance is not None
+            and now - self._cached_balance_ts < self._cache_ttl
+        ):
+            return StatusResponse(None, self._cached_balance)
+
+        try:
+            r = await self._get("/balances")  # Get balances from Strike API.
+            data = r.json()  # Parse JSON response.
+            balances = data.get("data", []) if isinstance(data, dict) else data  # Extract balances or use an empty list.
+            btc = next((b for b in balances if b.get("currency") == "BTC"), None)  # Find BTC balance.
+            if btc and "available" in btc:  # Check if BTC balance and available amount exist.
+                available_btc = Decimal(btc["available"])  # Get available BTC amount.
+                msats = int(available_btc * Decimal(1e11))  # Convert BTC to millisatoshis.
+                self._cached_balance = msats  # Update cached balance.
+                self._cached_balance_ts = now  # Update cache timestamp.
+                return StatusResponse(None, msats)  # Return successful status with balance.
+            # No BTC balance found.
+            return StatusResponse(None, 0)
+        except httpx.HTTPStatusError as e:
+            logger.error(f"Strike API error: {e.response.text}")
+            return StatusResponse(f"Strike API error: {e.response.text}", 0)
+        except Exception:
+            logger.exception("Unexpected error in status()")
+            return StatusResponse("Connection error", 0)
+
+    async def create_invoice(
+        self,
+        amount: int,
+        memo: Optional[str] = None,
+        description_hash: Optional[bytes] = None,
+        unhashed_description: bytes | None = None,  # Add this parameter
+        **kwargs,
+    ) -> InvoiceResponse:
+        try:
+            idem = kwargs.get("idempotency_key") or str(uuid.uuid4())  # Use provided idempotency key or generate a new one.
+            btc_amt = (Decimal(amount) / Decimal(1e8)).quantize(Decimal("0.00000001"))  # Convert amount from millisatoshis to BTC.
+            payload: Dict[str, Any] = {
+                "bolt11": {
+                    "amount": {"currency": "BTC", "amount": str(btc_amt)},  # Set amount in BTC.
+                    "description": memo or "",
+                },
+                "targetCurrency": "BTC",
+            }
+            if description_hash:
+                payload["bolt11"]["descriptionHash"] = description_hash.hex()
+
+            r = await self._post(
+                "/receive-requests",  # Create a receive request (invoice) on Strike.
+                json=payload,
+                headers={**self.client.headers, "idempotency-key": idem},
+            )
+            resp = r.json()  # Parse JSON response.
+            invoice_id = resp.get("receiveRequestId")  # Get the receive request ID.
+            bolt11 = resp.get("bolt11", {}).get("invoice")  # Get the bolt11 invoice.
+            if not invoice_id or not bolt11:  # Check if both invoice ID and bolt11 are present.
+                return InvoiceResponse(False, None, None, "Invalid invoice response")
+
+            self.pending_invoices.append(invoice_id)  # Add invoice ID to pending invoices.
+            return InvoiceResponse(True, invoice_id, bolt11, None)  # Return successful invoice response.
+        except httpx.HTTPStatusError as e:
+            msg = e.response.json().get("message", e.response.text)  # Get error message from response.
+            return InvoiceResponse(False, None, None, f"Strike API error: {msg}")
+        except Exception:
+            logger.exception("Error in create_invoice()")
+            return InvoiceResponse(False, None, None, "Connection error")
+
+    async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
+        try:
+            idem = str(uuid.uuid4())
+            # 1) Create a payment quote.
+            q = await self._post(
+                "/payment-quotes/lightning",
+                json={"lnInvoice": bolt11},
+                headers={**self.client.headers, "idempotency-key": idem},
+            )
+            quote_id = q.json().get("paymentQuoteId")  # Get the payment quote ID.
+            if not quote_id:  # Check if the quote ID is present.
+                return PaymentResponse(
+                    False, None, None, None, "Strike: missing paymentQuoteId"
+                )
+
+            # 2) Execute the payment quote.
+            e = await self._patch(f"/payment-quotes/{quote_id}/execute")
+            data = e.json() if e.content else {}  # Parse JSON response or use an empty dictionary.
+            payment_id = data.get("paymentId")  # Get the payment ID.
+            state = data.get("state", "").upper()  # Get the payment state and convert it to uppercase.
+
+            # Network fee → msat.
+            fee_obj = data.get("lightningNetworkFee") or data.get("totalFee") or {}  # Get fee object.
+            fee_btc = Decimal(fee_obj.get("amount", "0"))  # Get fee amount in BTC.
+            fee_msat = int(fee_btc * Decimal(1e11))  # Convert fee from BTC to millisatoshis.
+
+            # Store mapping for later polling.
+            if payment_id:  # If payment ID is present.
+                self.pending_payments[payment_id] = quote_id
+
+            if state in {"SUCCEEDED", "COMPLETED"}:  # If payment succeeded.
+                preimage = data.get("preimage") or data.get("preImage")  # Get payment preimage.
+                return PaymentResponse(True, payment_id, fee_msat, preimage, None)  # Return successful payment response.
+
+            # Strike often returns 202/PENDING immediately; treat as “still working”.
+            if state in {"PENDING", "QUEUED", "READY_TO_SETTLE", ""}:  # If payment is still pending.
+                return PaymentResponse(None, payment_id, None, None, None)  # Return pending payment response.
+
+            return PaymentResponse(False, payment_id, None, None, f"State: {state}")  # Return failed payment response with state.
+
+        except httpx.HTTPStatusError as e:
+            msg = e.response.json().get("message", e.response.text)  # Get error message from response.
+            return PaymentResponse(False, None, None, None, f"Strike API error: {msg}")  # Return payment response with error.
path: lnbits/wallets/strike.py
user: login: santyr
body: Will do.
created_at: 2025-05-09T18:11:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,475 @@
+import asyncio
+import random
+import time
+import traceback
+import uuid
+from decimal import Decimal
+from typing import Any, AsyncGenerator, Dict, List, Optional
+
+import httpx
+from loguru import logger
+
+from lnbits.settings import settings
+from .base import (
+    InvoiceResponse,
+    PaymentPendingStatus,
+    PaymentResponse,
+    PaymentStatus,
+    PaymentSuccessStatus,
+    StatusResponse,
+    Wallet,
+    PaymentFailedStatus,
+)
+
+
+class TokenBucket:
+    """
+    Token bucket rate limiter for Strike API endpoints.
+    """
+    def __init__(self, rate: int, period_seconds: int):
+        """
+        Initialize a token bucket.
+        
+        Args:
+            rate: Max requests allowed in the period
+            period_seconds: Time period in seconds.
+        """
+        self.rate = rate
+        self.period = period_seconds
+        self.tokens = rate
+        self.last_refill = time.monotonic()
+        self.lock = asyncio.Lock()
+    
+    async def consume(self) -> None:
+        """Wait until a token is available and consume it."""
+        async with self.lock:
+            # Refill tokens based on elapsed time
+            now = time.monotonic()
+            elapsed = now - self.last_refill
+            
+            if elapsed > 0:
+                new_tokens = (elapsed / self.period) * self.rate
+                self.tokens = min(self.rate, self.tokens + new_tokens)
+                self.last_refill = now
+            
+            # If no tokens available, calculate wait time
+            if self.tokens < 1:
+                wait_time = (self.period / self.rate) * (1 - self.tokens)
+                await asyncio.sleep(wait_time)
+                self.tokens = 0  # Reset after waiting
+            
+            # Consume a token
+            self.tokens -= 1
+
+
+class StrikeWallet(Wallet):
+    """
+    https://developer.strike.me/api
+    A minimal LNbits wallet backend for Strike.
+    """
+
+    # --------------------------------------------------------------------- #
+    # construction / teardown                                               #
+    # --------------------------------------------------------------------- #
+
+    def __init__(self):
+        if not settings.strike_api_endpoint:
+            raise ValueError("Missing strike_api_endpoint")
+        if not settings.strike_api_key:
+            raise ValueError("Missing strike_api_key")
+
+        super().__init__()
+
+        # tuneables
+        self._MAX_PARALLEL_REQUESTS = 20
+        self._MAX_RETRIES = 3
+        self._RETRY_STATUS = {429, 500, 502, 503, 504}
+        self._RETRY_BACKOFF_BASE = 2  # seconds (exponential)
+
+        # throttle
+        self._sem = asyncio.Semaphore(self._MAX_PARALLEL_REQUESTS)
+
+        # Rate limiters for different API endpoints
+        # Invoice/payment operations: 250 requests / 1 minute
+        self._invoice_limiter = TokenBucket(250, 60)
+        self._payment_limiter = TokenBucket(250, 60)
+        # All other operations: 1,000 requests / 10 minutes
+        self._general_limiter = TokenBucket(1000, 600)
+
+        self.client = httpx.AsyncClient(
+            base_url=self.normalize_endpoint(settings.strike_api_endpoint),
+            headers={
+                "Authorization": f"Bearer {settings.strike_api_key}",
+                "Content-Type": "application/json",
+                "Accept": "application/json",
+                "User-Agent": settings.user_agent,
+            },
+            timeout=httpx.Timeout(connect=5.0, read=40.0, write=10.0, pool=None),
+            transport=httpx.AsyncHTTPTransport(
+                limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
+                retries=0,  # we handle retries ourselves
+            ),
+        )
+
+        # runtime state
+        self.pending_invoices: list[str] = []  # Keep it as a list
+        self.pending_payments: Dict[str, str] = {}
+        self.failed_payments: Dict[str, str] = {}
+
+        # balance cache
+        self._cached_balance: Optional[int] = None
+        self._cached_balance_ts: float = 0.0
+        self._cache_ttl = 30  # seconds
+
+    async def cleanup(self):
+        try:
+            await self.client.aclose()
+        except Exception:
+            logger.exception("Error closing Strike client")
+
+    # --------------------------------------------------------------------- #
+    # low-level request helpers                                             #
+    # --------------------------------------------------------------------- #
+
+    async def _req(self, method: str, path: str, /, **kw) -> httpx.Response:
+        """Make a Strike HTTP call with:
+        One Strike HTTP call with
+            • rate limiting based on endpoint type
+            • concurrency throttle
+            • exponential back-off + jitter
+            • explicit retry on 429/5xx
+            • latency logging
+        """
+        # Apply the appropriate rate limiter based on the endpoint path.
+        if path.startswith("/invoices") or path.startswith("/receive-requests"):
+            await self._invoice_limiter.consume()
+        elif path.startswith("/payment-quotes"):
+            await self._payment_limiter.consume()
+        else:
+            await self._general_limiter.consume()
+            
+        async with self._sem:
+            start = time.perf_counter()
+
+            for attempt in range(self._MAX_RETRIES + 1):
+                try:
+                    resp = await self.client.request(method, path, **kw)
+                    resp.raise_for_status()
+                    logger.trace(
+                        "Strike {m} {p} – {s} in {t:.1f} ms".format(
+                            m=method.upper(),
+                            p=path,
+                            s=resp.status_code,
+                            t=(time.perf_counter() - start) * 1000,
+                        )
+                    )
+                    return resp
+
+                except httpx.HTTPStatusError as e:
+                    if (  # Only retry on specific status codes.
+                        e.response.status_code not in self._RETRY_STATUS
+                        or attempt == self._MAX_RETRIES
+                    ):
+                        raise
+                    logger.warning(
+                        "Strike {m} {p} -> {c}; retry {a}/{n}".format(
+                            m=method.upper(),
+                            p=path,
+                            c=e.response.status_code,
+                            a=attempt + 1,
+                            n=self._MAX_RETRIES,
+                        )
+                    )
+
+                except httpx.TransportError as e:
+                    if attempt == self._MAX_RETRIES:  # No more retries left.
+                        raise
+                    logger.warning(
+                        "Transport error contacting Strike ({err}); retry {a}/{n}".format(
+                            err=e, a=attempt + 1, n=self._MAX_RETRIES
+                        )
+                    )
+
+                delay = (self._RETRY_BACKOFF_BASE ** attempt) + (0.1 * random.random())  # Exponential backoff with jitter.
+                await asyncio.sleep(delay)
+
+        raise RuntimeError("exceeded retry budget in _req")
+
+    # Typed wrappers – so call-sites stay tidy.
+    async def _get(self, path: str, **kw) -> httpx.Response:  # GET request.
+        return await self._req("GET", path, **kw)
+
+    async def _post(self, path: str, **kw) -> httpx.Response:
+        return await self._req("POST", path, **kw)
+
+    async def _patch(self, path: str, **kw) -> httpx.Response:
+        return await self._req("PATCH", path, **kw)
+
+    # --------------------------------------------------------------------- #
+    # LNbits wallet API implementation                                      #
+    # --------------------------------------------------------------------- #
+
+    async def status(self) -> StatusResponse:
+        """
+        Return wallet balance (millisatoshis) with a 30-second cache.
+        """
+        now = time.time()
+        if (
+            self._cached_balance is not None
+            and now - self._cached_balance_ts < self._cache_ttl
+        ):
+            return StatusResponse(None, self._cached_balance)
+
+        try:
+            r = await self._get("/balances")  # Get balances from Strike API.
+            data = r.json()  # Parse JSON response.
+            balances = data.get("data", []) if isinstance(data, dict) else data  # Extract balances or use an empty list.
+            btc = next((b for b in balances if b.get("currency") == "BTC"), None)  # Find BTC balance.
+            if btc and "available" in btc:  # Check if BTC balance and available amount exist.
+                available_btc = Decimal(btc["available"])  # Get available BTC amount.
+                msats = int(available_btc * Decimal(1e11))  # Convert BTC to millisatoshis.
+                self._cached_balance = msats  # Update cached balance.
+                self._cached_balance_ts = now  # Update cache timestamp.
+                return StatusResponse(None, msats)  # Return successful status with balance.
+            # No BTC balance found.
+            return StatusResponse(None, 0)
+        except httpx.HTTPStatusError as e:
+            logger.error(f"Strike API error: {e.response.text}")
+            return StatusResponse(f"Strike API error: {e.response.text}", 0)
+        except Exception:
+            logger.exception("Unexpected error in status()")
+            return StatusResponse("Connection error", 0)
+
+    async def create_invoice(
+        self,
+        amount: int,
+        memo: Optional[str] = None,
+        description_hash: Optional[bytes] = None,
+        unhashed_description: bytes | None = None,  # Add this parameter
+        **kwargs,
+    ) -> InvoiceResponse:
path: lnbits/wallets/strike.py
user: login: dni
body: is there a way to get the preimage of a created invoice? or pass in a custom preimage to keep track of it?
created_at: 2025-05-11T02:59:01Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,475 @@
+import asyncio
+import random
+import time
+import traceback
+import uuid
+from decimal import Decimal
+from typing import Any, AsyncGenerator, Dict, List, Optional
+
+import httpx
+from loguru import logger
+
+from lnbits.settings import settings
+from .base import (
+    InvoiceResponse,
+    PaymentPendingStatus,
+    PaymentResponse,
+    PaymentStatus,
+    PaymentSuccessStatus,
+    StatusResponse,
+    Wallet,
+    PaymentFailedStatus,
+)
+
+
+class TokenBucket:
+    """
+    Token bucket rate limiter for Strike API endpoints.
+    """
+    def __init__(self, rate: int, period_seconds: int):
+        """
+        Initialize a token bucket.
+        
+        Args:
+            rate: Max requests allowed in the period
+            period_seconds: Time period in seconds.
+        """
+        self.rate = rate
+        self.period = period_seconds
+        self.tokens = rate
+        self.last_refill = time.monotonic()
+        self.lock = asyncio.Lock()
+    
+    async def consume(self) -> None:
+        """Wait until a token is available and consume it."""
+        async with self.lock:
+            # Refill tokens based on elapsed time
+            now = time.monotonic()
+            elapsed = now - self.last_refill
+            
+            if elapsed > 0:
+                new_tokens = (elapsed / self.period) * self.rate
+                self.tokens = min(self.rate, self.tokens + new_tokens)
+                self.last_refill = now
+            
+            # If no tokens available, calculate wait time
+            if self.tokens < 1:
+                wait_time = (self.period / self.rate) * (1 - self.tokens)
+                await asyncio.sleep(wait_time)
+                self.tokens = 0  # Reset after waiting
+            
+            # Consume a token
+            self.tokens -= 1
+
+
+class StrikeWallet(Wallet):
+    """
+    https://developer.strike.me/api
+    A minimal LNbits wallet backend for Strike.
+    """
+
+    # --------------------------------------------------------------------- #
+    # construction / teardown                                               #
+    # --------------------------------------------------------------------- #
+
+    def __init__(self):
+        if not settings.strike_api_endpoint:
+            raise ValueError("Missing strike_api_endpoint")
+        if not settings.strike_api_key:
+            raise ValueError("Missing strike_api_key")
+
+        super().__init__()
+
+        # tuneables
+        self._MAX_PARALLEL_REQUESTS = 20
+        self._MAX_RETRIES = 3
+        self._RETRY_STATUS = {429, 500, 502, 503, 504}
+        self._RETRY_BACKOFF_BASE = 2  # seconds (exponential)
+
+        # throttle
+        self._sem = asyncio.Semaphore(self._MAX_PARALLEL_REQUESTS)
+
+        # Rate limiters for different API endpoints
+        # Invoice/payment operations: 250 requests / 1 minute
+        self._invoice_limiter = TokenBucket(250, 60)
+        self._payment_limiter = TokenBucket(250, 60)
+        # All other operations: 1,000 requests / 10 minutes
+        self._general_limiter = TokenBucket(1000, 600)
+
+        self.client = httpx.AsyncClient(
+            base_url=self.normalize_endpoint(settings.strike_api_endpoint),
+            headers={
+                "Authorization": f"Bearer {settings.strike_api_key}",
+                "Content-Type": "application/json",
+                "Accept": "application/json",
+                "User-Agent": settings.user_agent,
+            },
+            timeout=httpx.Timeout(connect=5.0, read=40.0, write=10.0, pool=None),
+            transport=httpx.AsyncHTTPTransport(
+                limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
+                retries=0,  # we handle retries ourselves
+            ),
+        )
+
+        # runtime state
+        self.pending_invoices: list[str] = []  # Keep it as a list
+        self.pending_payments: Dict[str, str] = {}
+        self.failed_payments: Dict[str, str] = {}
+
+        # balance cache
+        self._cached_balance: Optional[int] = None
+        self._cached_balance_ts: float = 0.0
+        self._cache_ttl = 30  # seconds
+
+    async def cleanup(self):
+        try:
+            await self.client.aclose()
+        except Exception:
+            logger.exception("Error closing Strike client")
+
+    # --------------------------------------------------------------------- #
+    # low-level request helpers                                             #
+    # --------------------------------------------------------------------- #
+
+    async def _req(self, method: str, path: str, /, **kw) -> httpx.Response:
+        """Make a Strike HTTP call with:
+        One Strike HTTP call with
+            • rate limiting based on endpoint type
+            • concurrency throttle
+            • exponential back-off + jitter
+            • explicit retry on 429/5xx
+            • latency logging
+        """
+        # Apply the appropriate rate limiter based on the endpoint path.
+        if path.startswith("/invoices") or path.startswith("/receive-requests"):
+            await self._invoice_limiter.consume()
+        elif path.startswith("/payment-quotes"):
+            await self._payment_limiter.consume()
+        else:
+            await self._general_limiter.consume()
+            
+        async with self._sem:
+            start = time.perf_counter()
+
+            for attempt in range(self._MAX_RETRIES + 1):
+                try:
+                    resp = await self.client.request(method, path, **kw)
+                    resp.raise_for_status()
+                    logger.trace(
+                        "Strike {m} {p} – {s} in {t:.1f} ms".format(
+                            m=method.upper(),
+                            p=path,
+                            s=resp.status_code,
+                            t=(time.perf_counter() - start) * 1000,
+                        )
+                    )
+                    return resp
+
+                except httpx.HTTPStatusError as e:
+                    if (  # Only retry on specific status codes.
+                        e.response.status_code not in self._RETRY_STATUS
+                        or attempt == self._MAX_RETRIES
+                    ):
+                        raise
+                    logger.warning(
+                        "Strike {m} {p} -> {c}; retry {a}/{n}".format(
+                            m=method.upper(),
+                            p=path,
+                            c=e.response.status_code,
+                            a=attempt + 1,
+                            n=self._MAX_RETRIES,
+                        )
+                    )
+
+                except httpx.TransportError as e:
+                    if attempt == self._MAX_RETRIES:  # No more retries left.
+                        raise
+                    logger.warning(
+                        "Transport error contacting Strike ({err}); retry {a}/{n}".format(
+                            err=e, a=attempt + 1, n=self._MAX_RETRIES
+                        )
+                    )
+
+                delay = (self._RETRY_BACKOFF_BASE ** attempt) + (0.1 * random.random())  # Exponential backoff with jitter.
+                await asyncio.sleep(delay)
+
+        raise RuntimeError("exceeded retry budget in _req")
+
+    # Typed wrappers – so call-sites stay tidy.
+    async def _get(self, path: str, **kw) -> httpx.Response:  # GET request.
+        return await self._req("GET", path, **kw)
+
+    async def _post(self, path: str, **kw) -> httpx.Response:
+        return await self._req("POST", path, **kw)
+
+    async def _patch(self, path: str, **kw) -> httpx.Response:
+        return await self._req("PATCH", path, **kw)
+
+    # --------------------------------------------------------------------- #
+    # LNbits wallet API implementation                                      #
+    # --------------------------------------------------------------------- #
+
+    async def status(self) -> StatusResponse:
+        """
+        Return wallet balance (millisatoshis) with a 30-second cache.
+        """
+        now = time.time()
+        if (
+            self._cached_balance is not None
+            and now - self._cached_balance_ts < self._cache_ttl
+        ):
+            return StatusResponse(None, self._cached_balance)
+
+        try:
+            r = await self._get("/balances")  # Get balances from Strike API.
+            data = r.json()  # Parse JSON response.
+            balances = data.get("data", []) if isinstance(data, dict) else data  # Extract balances or use an empty list.
+            btc = next((b for b in balances if b.get("currency") == "BTC"), None)  # Find BTC balance.
+            if btc and "available" in btc:  # Check if BTC balance and available amount exist.
+                available_btc = Decimal(btc["available"])  # Get available BTC amount.
+                msats = int(available_btc * Decimal(1e11))  # Convert BTC to millisatoshis.
+                self._cached_balance = msats  # Update cached balance.
+                self._cached_balance_ts = now  # Update cache timestamp.
+                return StatusResponse(None, msats)  # Return successful status with balance.
+            # No BTC balance found.
+            return StatusResponse(None, 0)
+        except httpx.HTTPStatusError as e:
+            logger.error(f"Strike API error: {e.response.text}")
+            return StatusResponse(f"Strike API error: {e.response.text}", 0)
+        except Exception:
+            logger.exception("Unexpected error in status()")
+            return StatusResponse("Connection error", 0)
+
+    async def create_invoice(
+        self,
+        amount: int,
+        memo: Optional[str] = None,
+        description_hash: Optional[bytes] = None,
+        unhashed_description: bytes | None = None,  # Add this parameter
+        **kwargs,
+    ) -> InvoiceResponse:
path: lnbits/wallets/strike.py
user: login: santyr
body: The create_invoice method in the StrikeWallet class only allows you to specify the amount, memo (description), and optionally a description hash. The preimage is handled internally by Strike and is only revealed after a successful payment.

Strike returns the preimage in the response to the payment execution request. Specifically, within the pay_invoice method:

1) After creating a payment quote, the code executes it with a PATCH request to /payment-quotes/{quote_id}/execute.

2) The response from this request (stored in the e variable) is parsed as JSON.

3) The code then attempts to extract the preimage from the JSON data using these lines:

`preimage = data.get("preimage") or data.get("preImage")`

If a preimage is found, it's included in the PaymentResponse object returned by the pay_invoice method.

As I understand it, the preimage serves as proof of payment. It's only revealed after the payment has been successfully claimed by the recipient.  Anyone who knows the preimage can verify that the payment was made by hashing it and comparing it to the hash lock in the original invoice.

created_at: 2025-05-11T12:59:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,475 @@
+import asyncio
+import random
+import time
+import traceback
+import uuid
+from decimal import Decimal
+from typing import Any, AsyncGenerator, Dict, List, Optional
+
+import httpx
+from loguru import logger
+
+from lnbits.settings import settings
+from .base import (
+    InvoiceResponse,
+    PaymentPendingStatus,
+    PaymentResponse,
+    PaymentStatus,
+    PaymentSuccessStatus,
+    StatusResponse,
+    Wallet,
+    PaymentFailedStatus,
+)
+
+
+class TokenBucket:
+    """
+    Token bucket rate limiter for Strike API endpoints.
+    """
+    def __init__(self, rate: int, period_seconds: int):
+        """
+        Initialize a token bucket.
+        
+        Args:
+            rate: Max requests allowed in the period
+            period_seconds: Time period in seconds.
+        """
+        self.rate = rate
+        self.period = period_seconds
+        self.tokens = rate
+        self.last_refill = time.monotonic()
+        self.lock = asyncio.Lock()
+    
+    async def consume(self) -> None:
+        """Wait until a token is available and consume it."""
+        async with self.lock:
+            # Refill tokens based on elapsed time
+            now = time.monotonic()
+            elapsed = now - self.last_refill
+            
+            if elapsed > 0:
+                new_tokens = (elapsed / self.period) * self.rate
+                self.tokens = min(self.rate, self.tokens + new_tokens)
+                self.last_refill = now
+            
+            # If no tokens available, calculate wait time
+            if self.tokens < 1:
+                wait_time = (self.period / self.rate) * (1 - self.tokens)
+                await asyncio.sleep(wait_time)
+                self.tokens = 0  # Reset after waiting
+            
+            # Consume a token
+            self.tokens -= 1
+
+
+class StrikeWallet(Wallet):
+    """
+    https://developer.strike.me/api
+    A minimal LNbits wallet backend for Strike.
+    """
+
+    # --------------------------------------------------------------------- #
+    # construction / teardown                                               #
+    # --------------------------------------------------------------------- #
+
+    def __init__(self):
+        if not settings.strike_api_endpoint:
+            raise ValueError("Missing strike_api_endpoint")
+        if not settings.strike_api_key:
+            raise ValueError("Missing strike_api_key")
+
+        super().__init__()
+
+        # tuneables
+        self._MAX_PARALLEL_REQUESTS = 20
+        self._MAX_RETRIES = 3
+        self._RETRY_STATUS = {429, 500, 502, 503, 504}
+        self._RETRY_BACKOFF_BASE = 2  # seconds (exponential)
+
+        # throttle
+        self._sem = asyncio.Semaphore(self._MAX_PARALLEL_REQUESTS)
+
+        # Rate limiters for different API endpoints
+        # Invoice/payment operations: 250 requests / 1 minute
+        self._invoice_limiter = TokenBucket(250, 60)
+        self._payment_limiter = TokenBucket(250, 60)
+        # All other operations: 1,000 requests / 10 minutes
+        self._general_limiter = TokenBucket(1000, 600)
+
+        self.client = httpx.AsyncClient(
+            base_url=self.normalize_endpoint(settings.strike_api_endpoint),
+            headers={
+                "Authorization": f"Bearer {settings.strike_api_key}",
+                "Content-Type": "application/json",
+                "Accept": "application/json",
+                "User-Agent": settings.user_agent,
+            },
+            timeout=httpx.Timeout(connect=5.0, read=40.0, write=10.0, pool=None),
+            transport=httpx.AsyncHTTPTransport(
+                limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
+                retries=0,  # we handle retries ourselves
+            ),
+        )
+
+        # runtime state
+        self.pending_invoices: list[str] = []  # Keep it as a list
+        self.pending_payments: Dict[str, str] = {}
+        self.failed_payments: Dict[str, str] = {}
+
+        # balance cache
+        self._cached_balance: Optional[int] = None
+        self._cached_balance_ts: float = 0.0
+        self._cache_ttl = 30  # seconds
+
+    async def cleanup(self):
+        try:
+            await self.client.aclose()
+        except Exception:
+            logger.exception("Error closing Strike client")
+
+    # --------------------------------------------------------------------- #
+    # low-level request helpers                                             #
+    # --------------------------------------------------------------------- #
+
+    async def _req(self, method: str, path: str, /, **kw) -> httpx.Response:
+        """Make a Strike HTTP call with:
+        One Strike HTTP call with
+            • rate limiting based on endpoint type
+            • concurrency throttle
+            • exponential back-off + jitter
+            • explicit retry on 429/5xx
+            • latency logging
+        """
+        # Apply the appropriate rate limiter based on the endpoint path.
+        if path.startswith("/invoices") or path.startswith("/receive-requests"):
+            await self._invoice_limiter.consume()
+        elif path.startswith("/payment-quotes"):
+            await self._payment_limiter.consume()
+        else:
+            await self._general_limiter.consume()
+            
+        async with self._sem:
+            start = time.perf_counter()
+
+            for attempt in range(self._MAX_RETRIES + 1):
+                try:
+                    resp = await self.client.request(method, path, **kw)
+                    resp.raise_for_status()
+                    logger.trace(
+                        "Strike {m} {p} – {s} in {t:.1f} ms".format(
+                            m=method.upper(),
+                            p=path,
+                            s=resp.status_code,
+                            t=(time.perf_counter() - start) * 1000,
+                        )
+                    )
+                    return resp
+
+                except httpx.HTTPStatusError as e:
+                    if (  # Only retry on specific status codes.
+                        e.response.status_code not in self._RETRY_STATUS
+                        or attempt == self._MAX_RETRIES
+                    ):
+                        raise
+                    logger.warning(
+                        "Strike {m} {p} -> {c}; retry {a}/{n}".format(
+                            m=method.upper(),
+                            p=path,
+                            c=e.response.status_code,
+                            a=attempt + 1,
+                            n=self._MAX_RETRIES,
+                        )
+                    )
+
+                except httpx.TransportError as e:
+                    if attempt == self._MAX_RETRIES:  # No more retries left.
+                        raise
+                    logger.warning(
+                        "Transport error contacting Strike ({err}); retry {a}/{n}".format(
+                            err=e, a=attempt + 1, n=self._MAX_RETRIES
+                        )
+                    )
+
+                delay = (self._RETRY_BACKOFF_BASE ** attempt) + (0.1 * random.random())  # Exponential backoff with jitter.
+                await asyncio.sleep(delay)
+
+        raise RuntimeError("exceeded retry budget in _req")
+
+    # Typed wrappers – so call-sites stay tidy.
+    async def _get(self, path: str, **kw) -> httpx.Response:  # GET request.
+        return await self._req("GET", path, **kw)
+
+    async def _post(self, path: str, **kw) -> httpx.Response:
+        return await self._req("POST", path, **kw)
+
+    async def _patch(self, path: str, **kw) -> httpx.Response:
+        return await self._req("PATCH", path, **kw)
+
+    # --------------------------------------------------------------------- #
+    # LNbits wallet API implementation                                      #
+    # --------------------------------------------------------------------- #
+
+    async def status(self) -> StatusResponse:
+        """
+        Return wallet balance (millisatoshis) with a 30-second cache.
+        """
+        now = time.time()
+        if (
+            self._cached_balance is not None
+            and now - self._cached_balance_ts < self._cache_ttl
+        ):
+            return StatusResponse(None, self._cached_balance)
+
+        try:
+            r = await self._get("/balances")  # Get balances from Strike API.
+            data = r.json()  # Parse JSON response.
+            balances = data.get("data", []) if isinstance(data, dict) else data  # Extract balances or use an empty list.
+            btc = next((b for b in balances if b.get("currency") == "BTC"), None)  # Find BTC balance.
+            if btc and "available" in btc:  # Check if BTC balance and available amount exist.
+                available_btc = Decimal(btc["available"])  # Get available BTC amount.
+                msats = int(available_btc * Decimal(1e11))  # Convert BTC to millisatoshis.
+                self._cached_balance = msats  # Update cached balance.
+                self._cached_balance_ts = now  # Update cache timestamp.
+                return StatusResponse(None, msats)  # Return successful status with balance.
+            # No BTC balance found.
+            return StatusResponse(None, 0)
+        except httpx.HTTPStatusError as e:
+            logger.error(f"Strike API error: {e.response.text}")
+            return StatusResponse(f"Strike API error: {e.response.text}", 0)
+        except Exception:
+            logger.exception("Unexpected error in status()")
+            return StatusResponse("Connection error", 0)
+
+    async def create_invoice(
+        self,
+        amount: int,
+        memo: Optional[str] = None,
+        description_hash: Optional[bytes] = None,
+        unhashed_description: bytes | None = None,  # Add this parameter
+        **kwargs,
+    ) -> InvoiceResponse:
path: lnbits/wallets/strike.py
user: login: dni
body: is that some AI :D?
in some fundingsources we are able to provide a payment_hash/preimage up front when creating an invoice, that is how we keep track of the preimage when creating an invoice. like this: https://github.com/lnbits/lnbits/blob/dev/lnbits/wallets/lndgrpc.py#L152
created_at: 2025-05-11T20:05:55Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,475 @@
+import asyncio
+import random
+import time
+import traceback
+import uuid
+from decimal import Decimal
+from typing import Any, AsyncGenerator, Dict, List, Optional
+
+import httpx
+from loguru import logger
+
+from lnbits.settings import settings
+from .base import (
+    InvoiceResponse,
+    PaymentPendingStatus,
+    PaymentResponse,
+    PaymentStatus,
+    PaymentSuccessStatus,
+    StatusResponse,
+    Wallet,
+    PaymentFailedStatus,
+)
+
+
+class TokenBucket:
+    """
+    Token bucket rate limiter for Strike API endpoints.
+    """
+    def __init__(self, rate: int, period_seconds: int):
+        """
+        Initialize a token bucket.
+        
+        Args:
+            rate: Max requests allowed in the period
+            period_seconds: Time period in seconds.
+        """
+        self.rate = rate
+        self.period = period_seconds
+        self.tokens = rate
+        self.last_refill = time.monotonic()
+        self.lock = asyncio.Lock()
+    
+    async def consume(self) -> None:
+        """Wait until a token is available and consume it."""
+        async with self.lock:
+            # Refill tokens based on elapsed time
+            now = time.monotonic()
+            elapsed = now - self.last_refill
+            
+            if elapsed > 0:
+                new_tokens = (elapsed / self.period) * self.rate
+                self.tokens = min(self.rate, self.tokens + new_tokens)
+                self.last_refill = now
+            
+            # If no tokens available, calculate wait time
+            if self.tokens < 1:
+                wait_time = (self.period / self.rate) * (1 - self.tokens)
+                await asyncio.sleep(wait_time)
+                self.tokens = 0  # Reset after waiting
+            
+            # Consume a token
+            self.tokens -= 1
+
+
+class StrikeWallet(Wallet):
+    """
+    https://developer.strike.me/api
+    A minimal LNbits wallet backend for Strike.
+    """
+
+    # --------------------------------------------------------------------- #
+    # construction / teardown                                               #
+    # --------------------------------------------------------------------- #
+
+    def __init__(self):
+        if not settings.strike_api_endpoint:
+            raise ValueError("Missing strike_api_endpoint")
+        if not settings.strike_api_key:
+            raise ValueError("Missing strike_api_key")
+
+        super().__init__()
+
+        # tuneables
+        self._MAX_PARALLEL_REQUESTS = 20
+        self._MAX_RETRIES = 3
+        self._RETRY_STATUS = {429, 500, 502, 503, 504}
+        self._RETRY_BACKOFF_BASE = 2  # seconds (exponential)
+
+        # throttle
+        self._sem = asyncio.Semaphore(self._MAX_PARALLEL_REQUESTS)
+
+        # Rate limiters for different API endpoints
+        # Invoice/payment operations: 250 requests / 1 minute
+        self._invoice_limiter = TokenBucket(250, 60)
+        self._payment_limiter = TokenBucket(250, 60)
+        # All other operations: 1,000 requests / 10 minutes
+        self._general_limiter = TokenBucket(1000, 600)
+
+        self.client = httpx.AsyncClient(
+            base_url=self.normalize_endpoint(settings.strike_api_endpoint),
+            headers={
+                "Authorization": f"Bearer {settings.strike_api_key}",
+                "Content-Type": "application/json",
+                "Accept": "application/json",
+                "User-Agent": settings.user_agent,
+            },
+            timeout=httpx.Timeout(connect=5.0, read=40.0, write=10.0, pool=None),
+            transport=httpx.AsyncHTTPTransport(
+                limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
+                retries=0,  # we handle retries ourselves
+            ),
+        )
+
+        # runtime state
+        self.pending_invoices: list[str] = []  # Keep it as a list
+        self.pending_payments: Dict[str, str] = {}
+        self.failed_payments: Dict[str, str] = {}
+
+        # balance cache
+        self._cached_balance: Optional[int] = None
+        self._cached_balance_ts: float = 0.0
+        self._cache_ttl = 30  # seconds
+
+    async def cleanup(self):
+        try:
+            await self.client.aclose()
+        except Exception:
+            logger.exception("Error closing Strike client")
+
+    # --------------------------------------------------------------------- #
+    # low-level request helpers                                             #
+    # --------------------------------------------------------------------- #
+
+    async def _req(self, method: str, path: str, /, **kw) -> httpx.Response:
+        """Make a Strike HTTP call with:
+        One Strike HTTP call with
+            • rate limiting based on endpoint type
+            • concurrency throttle
+            • exponential back-off + jitter
+            • explicit retry on 429/5xx
+            • latency logging
+        """
+        # Apply the appropriate rate limiter based on the endpoint path.
+        if path.startswith("/invoices") or path.startswith("/receive-requests"):
+            await self._invoice_limiter.consume()
+        elif path.startswith("/payment-quotes"):
+            await self._payment_limiter.consume()
+        else:
+            await self._general_limiter.consume()
+            
+        async with self._sem:
+            start = time.perf_counter()
+
+            for attempt in range(self._MAX_RETRIES + 1):
+                try:
+                    resp = await self.client.request(method, path, **kw)
+                    resp.raise_for_status()
+                    logger.trace(
+                        "Strike {m} {p} – {s} in {t:.1f} ms".format(
+                            m=method.upper(),
+                            p=path,
+                            s=resp.status_code,
+                            t=(time.perf_counter() - start) * 1000,
+                        )
+                    )
+                    return resp
+
+                except httpx.HTTPStatusError as e:
+                    if (  # Only retry on specific status codes.
+                        e.response.status_code not in self._RETRY_STATUS
+                        or attempt == self._MAX_RETRIES
+                    ):
+                        raise
+                    logger.warning(
+                        "Strike {m} {p} -> {c}; retry {a}/{n}".format(
+                            m=method.upper(),
+                            p=path,
+                            c=e.response.status_code,
+                            a=attempt + 1,
+                            n=self._MAX_RETRIES,
+                        )
+                    )
+
+                except httpx.TransportError as e:
+                    if attempt == self._MAX_RETRIES:  # No more retries left.
+                        raise
+                    logger.warning(
+                        "Transport error contacting Strike ({err}); retry {a}/{n}".format(
+                            err=e, a=attempt + 1, n=self._MAX_RETRIES
+                        )
+                    )
+
+                delay = (self._RETRY_BACKOFF_BASE ** attempt) + (0.1 * random.random())  # Exponential backoff with jitter.
+                await asyncio.sleep(delay)
+
+        raise RuntimeError("exceeded retry budget in _req")
+
+    # Typed wrappers – so call-sites stay tidy.
+    async def _get(self, path: str, **kw) -> httpx.Response:  # GET request.
+        return await self._req("GET", path, **kw)
+
+    async def _post(self, path: str, **kw) -> httpx.Response:
+        return await self._req("POST", path, **kw)
+
+    async def _patch(self, path: str, **kw) -> httpx.Response:
+        return await self._req("PATCH", path, **kw)
+
+    # --------------------------------------------------------------------- #
+    # LNbits wallet API implementation                                      #
+    # --------------------------------------------------------------------- #
+
+    async def status(self) -> StatusResponse:
+        """
+        Return wallet balance (millisatoshis) with a 30-second cache.
+        """
+        now = time.time()
+        if (
+            self._cached_balance is not None
+            and now - self._cached_balance_ts < self._cache_ttl
+        ):
+            return StatusResponse(None, self._cached_balance)
+
+        try:
+            r = await self._get("/balances")  # Get balances from Strike API.
+            data = r.json()  # Parse JSON response.
+            balances = data.get("data", []) if isinstance(data, dict) else data  # Extract balances or use an empty list.
+            btc = next((b for b in balances if b.get("currency") == "BTC"), None)  # Find BTC balance.
+            if btc and "available" in btc:  # Check if BTC balance and available amount exist.
+                available_btc = Decimal(btc["available"])  # Get available BTC amount.
+                msats = int(available_btc * Decimal(1e11))  # Convert BTC to millisatoshis.
+                self._cached_balance = msats  # Update cached balance.
+                self._cached_balance_ts = now  # Update cache timestamp.
+                return StatusResponse(None, msats)  # Return successful status with balance.
+            # No BTC balance found.
+            return StatusResponse(None, 0)
+        except httpx.HTTPStatusError as e:
+            logger.error(f"Strike API error: {e.response.text}")
+            return StatusResponse(f"Strike API error: {e.response.text}", 0)
+        except Exception:
+            logger.exception("Unexpected error in status()")
+            return StatusResponse("Connection error", 0)
+
+    async def create_invoice(
+        self,
+        amount: int,
+        memo: Optional[str] = None,
+        description_hash: Optional[bytes] = None,
+        unhashed_description: bytes | None = None,  # Add this parameter
+        **kwargs,
+    ) -> InvoiceResponse:
path: lnbits/wallets/strike.py
user: login: santyr
body: I did use an AI to help me write that, since I wasn't clear on the use of a preimage until you brought it up actually.  

In some cases, it's easier to get a preimage, such as when you're running your own node, and you are taking less of a risk in using it, since you're not revealing it to anyone before your Sats arrive.  

 As I understand it, the preimage is never revealed until the payment is received, then it back propagates along the payment route until it reaches the whoever paid the invoice.  To quote the AI, 'In essence, the preimage is the secret "key" that, once revealed by the recipient, cascades backward along the payment path, unlocking each HTLC and allowing funds to flow securely and atomically to the recipient while fees flow to intermediaries.'

On the other hand the payment hash, or the hash lock, is always available in a bolt 11 invoice, and it can be used as an identifier when you're tracking a payment and to verify it's legit.  The payment hash (hash lock)  is a the preimage in hashed form.

I pretty new to coding anything for Lightning, but couldn't you just extract the payment hash from the invoice and then confirm it against the preimage when the recipient (Strike in this case) reveals it as part of the regular payment flow?
created_at: 2025-05-11T20:58:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,475 @@
+import asyncio
+import random
+import time
+import traceback
+import uuid
+from decimal import Decimal
+from typing import Any, AsyncGenerator, Dict, List, Optional
+
+import httpx
+from loguru import logger
+
+from lnbits.settings import settings
+from .base import (
+    InvoiceResponse,
+    PaymentPendingStatus,
+    PaymentResponse,
+    PaymentStatus,
+    PaymentSuccessStatus,
+    StatusResponse,
+    Wallet,
+    PaymentFailedStatus,
+)
+
+
+class TokenBucket:
+    """
+    Token bucket rate limiter for Strike API endpoints.
+    """
+    def __init__(self, rate: int, period_seconds: int):
+        """
+        Initialize a token bucket.
+        
+        Args:
+            rate: Max requests allowed in the period
+            period_seconds: Time period in seconds.
+        """
+        self.rate = rate
+        self.period = period_seconds
+        self.tokens = rate
+        self.last_refill = time.monotonic()
+        self.lock = asyncio.Lock()
+    
+    async def consume(self) -> None:
+        """Wait until a token is available and consume it."""
+        async with self.lock:
+            # Refill tokens based on elapsed time
+            now = time.monotonic()
+            elapsed = now - self.last_refill
+            
+            if elapsed > 0:
+                new_tokens = (elapsed / self.period) * self.rate
+                self.tokens = min(self.rate, self.tokens + new_tokens)
+                self.last_refill = now
+            
+            # If no tokens available, calculate wait time
+            if self.tokens < 1:
+                wait_time = (self.period / self.rate) * (1 - self.tokens)
+                await asyncio.sleep(wait_time)
+                self.tokens = 0  # Reset after waiting
+            
+            # Consume a token
+            self.tokens -= 1
+
+
+class StrikeWallet(Wallet):
+    """
+    https://developer.strike.me/api
+    A minimal LNbits wallet backend for Strike.
+    """
+
+    # --------------------------------------------------------------------- #
+    # construction / teardown                                               #
+    # --------------------------------------------------------------------- #
+
+    def __init__(self):
+        if not settings.strike_api_endpoint:
+            raise ValueError("Missing strike_api_endpoint")
+        if not settings.strike_api_key:
+            raise ValueError("Missing strike_api_key")
+
+        super().__init__()
+
+        # tuneables
+        self._MAX_PARALLEL_REQUESTS = 20
+        self._MAX_RETRIES = 3
+        self._RETRY_STATUS = {429, 500, 502, 503, 504}
+        self._RETRY_BACKOFF_BASE = 2  # seconds (exponential)
+
+        # throttle
+        self._sem = asyncio.Semaphore(self._MAX_PARALLEL_REQUESTS)
+
+        # Rate limiters for different API endpoints
+        # Invoice/payment operations: 250 requests / 1 minute
+        self._invoice_limiter = TokenBucket(250, 60)
+        self._payment_limiter = TokenBucket(250, 60)
+        # All other operations: 1,000 requests / 10 minutes
+        self._general_limiter = TokenBucket(1000, 600)
+
+        self.client = httpx.AsyncClient(
+            base_url=self.normalize_endpoint(settings.strike_api_endpoint),
+            headers={
+                "Authorization": f"Bearer {settings.strike_api_key}",
+                "Content-Type": "application/json",
+                "Accept": "application/json",
+                "User-Agent": settings.user_agent,
+            },
+            timeout=httpx.Timeout(connect=5.0, read=40.0, write=10.0, pool=None),
+            transport=httpx.AsyncHTTPTransport(
+                limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
+                retries=0,  # we handle retries ourselves
+            ),
+        )
+
+        # runtime state
+        self.pending_invoices: list[str] = []  # Keep it as a list
+        self.pending_payments: Dict[str, str] = {}
+        self.failed_payments: Dict[str, str] = {}
+
+        # balance cache
+        self._cached_balance: Optional[int] = None
+        self._cached_balance_ts: float = 0.0
+        self._cache_ttl = 30  # seconds
+
+    async def cleanup(self):
+        try:
+            await self.client.aclose()
+        except Exception:
+            logger.exception("Error closing Strike client")
+
+    # --------------------------------------------------------------------- #
+    # low-level request helpers                                             #
+    # --------------------------------------------------------------------- #
+
+    async def _req(self, method: str, path: str, /, **kw) -> httpx.Response:
+        """Make a Strike HTTP call with:
+        One Strike HTTP call with
+            • rate limiting based on endpoint type
+            • concurrency throttle
+            • exponential back-off + jitter
+            • explicit retry on 429/5xx
+            • latency logging
+        """
+        # Apply the appropriate rate limiter based on the endpoint path.
+        if path.startswith("/invoices") or path.startswith("/receive-requests"):
+            await self._invoice_limiter.consume()
+        elif path.startswith("/payment-quotes"):
+            await self._payment_limiter.consume()
+        else:
+            await self._general_limiter.consume()
+            
+        async with self._sem:
+            start = time.perf_counter()
+
+            for attempt in range(self._MAX_RETRIES + 1):
+                try:
+                    resp = await self.client.request(method, path, **kw)
+                    resp.raise_for_status()
+                    logger.trace(
+                        "Strike {m} {p} – {s} in {t:.1f} ms".format(
+                            m=method.upper(),
+                            p=path,
+                            s=resp.status_code,
+                            t=(time.perf_counter() - start) * 1000,
+                        )
+                    )
+                    return resp
+
+                except httpx.HTTPStatusError as e:
+                    if (  # Only retry on specific status codes.
+                        e.response.status_code not in self._RETRY_STATUS
+                        or attempt == self._MAX_RETRIES
+                    ):
+                        raise
+                    logger.warning(
+                        "Strike {m} {p} -> {c}; retry {a}/{n}".format(
+                            m=method.upper(),
+                            p=path,
+                            c=e.response.status_code,
+                            a=attempt + 1,
+                            n=self._MAX_RETRIES,
+                        )
+                    )
+
+                except httpx.TransportError as e:
+                    if attempt == self._MAX_RETRIES:  # No more retries left.
+                        raise
+                    logger.warning(
+                        "Transport error contacting Strike ({err}); retry {a}/{n}".format(
+                            err=e, a=attempt + 1, n=self._MAX_RETRIES
+                        )
+                    )
+
+                delay = (self._RETRY_BACKOFF_BASE ** attempt) + (0.1 * random.random())  # Exponential backoff with jitter.
+                await asyncio.sleep(delay)
+
+        raise RuntimeError("exceeded retry budget in _req")
+
+    # Typed wrappers – so call-sites stay tidy.
+    async def _get(self, path: str, **kw) -> httpx.Response:  # GET request.
+        return await self._req("GET", path, **kw)
+
+    async def _post(self, path: str, **kw) -> httpx.Response:
+        return await self._req("POST", path, **kw)
+
+    async def _patch(self, path: str, **kw) -> httpx.Response:
+        return await self._req("PATCH", path, **kw)
+
+    # --------------------------------------------------------------------- #
+    # LNbits wallet API implementation                                      #
+    # --------------------------------------------------------------------- #
+
+    async def status(self) -> StatusResponse:
+        """
+        Return wallet balance (millisatoshis) with a 30-second cache.
+        """
+        now = time.time()
+        if (
+            self._cached_balance is not None
+            and now - self._cached_balance_ts < self._cache_ttl
+        ):
+            return StatusResponse(None, self._cached_balance)
+
+        try:
+            r = await self._get("/balances")  # Get balances from Strike API.
+            data = r.json()  # Parse JSON response.
+            balances = data.get("data", []) if isinstance(data, dict) else data  # Extract balances or use an empty list.
+            btc = next((b for b in balances if b.get("currency") == "BTC"), None)  # Find BTC balance.
+            if btc and "available" in btc:  # Check if BTC balance and available amount exist.
+                available_btc = Decimal(btc["available"])  # Get available BTC amount.
+                msats = int(available_btc * Decimal(1e11))  # Convert BTC to millisatoshis.
+                self._cached_balance = msats  # Update cached balance.
+                self._cached_balance_ts = now  # Update cache timestamp.
+                return StatusResponse(None, msats)  # Return successful status with balance.
+            # No BTC balance found.
+            return StatusResponse(None, 0)
+        except httpx.HTTPStatusError as e:
+            logger.error(f"Strike API error: {e.response.text}")
+            return StatusResponse(f"Strike API error: {e.response.text}", 0)
+        except Exception:
+            logger.exception("Unexpected error in status()")
+            return StatusResponse("Connection error", 0)
+
+    async def create_invoice(
+        self,
+        amount: int,
+        memo: Optional[str] = None,
+        description_hash: Optional[bytes] = None,
+        unhashed_description: bytes | None = None,  # Add this parameter
+        **kwargs,
+    ) -> InvoiceResponse:
path: lnbits/wallets/strike.py
user: login: dni
body: thank you for not using AI :D it seemed a bit like it, sorry for accusing.
you are completely right. if you have the preimage of the payment  it is the proof of the payment. so you pay the invoice, you get the preimage.
so basically until recently we did not keep track of preimages when creating invoices / payments we receive, only payments we pay. but keeping track of preimages for invoices we give out can also be very useful and even to just have consistent state of the payments. so most nodes give you the option to provide a hash or preimage upfront to create the invoice which makes it easy for lnbits to keep track of, its not 100% necessary in the context of receiving, but  for lnbits as accounting software its beneficial.
created_at: 2025-05-11T22:10:55Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,475 @@
+import asyncio
+import random
+import time
+import traceback
+import uuid
+from decimal import Decimal
+from typing import Any, AsyncGenerator, Dict, List, Optional
+
+import httpx
+from loguru import logger
+
+from lnbits.settings import settings
+from .base import (
+    InvoiceResponse,
+    PaymentPendingStatus,
+    PaymentResponse,
+    PaymentStatus,
+    PaymentSuccessStatus,
+    StatusResponse,
+    Wallet,
+    PaymentFailedStatus,
+)
+
+
+class TokenBucket:
+    """
+    Token bucket rate limiter for Strike API endpoints.
+    """
+    def __init__(self, rate: int, period_seconds: int):
+        """
+        Initialize a token bucket.
+        
+        Args:
+            rate: Max requests allowed in the period
+            period_seconds: Time period in seconds.
+        """
+        self.rate = rate
+        self.period = period_seconds
+        self.tokens = rate
+        self.last_refill = time.monotonic()
+        self.lock = asyncio.Lock()
+    
+    async def consume(self) -> None:
+        """Wait until a token is available and consume it."""
+        async with self.lock:
+            # Refill tokens based on elapsed time
+            now = time.monotonic()
+            elapsed = now - self.last_refill
+            
+            if elapsed > 0:
+                new_tokens = (elapsed / self.period) * self.rate
+                self.tokens = min(self.rate, self.tokens + new_tokens)
+                self.last_refill = now
+            
+            # If no tokens available, calculate wait time
+            if self.tokens < 1:
+                wait_time = (self.period / self.rate) * (1 - self.tokens)
+                await asyncio.sleep(wait_time)
+                self.tokens = 0  # Reset after waiting
+            
+            # Consume a token
+            self.tokens -= 1
+
+
+class StrikeWallet(Wallet):
+    """
+    https://developer.strike.me/api
+    A minimal LNbits wallet backend for Strike.
+    """
+
+    # --------------------------------------------------------------------- #
+    # construction / teardown                                               #
+    # --------------------------------------------------------------------- #
+
+    def __init__(self):
+        if not settings.strike_api_endpoint:
+            raise ValueError("Missing strike_api_endpoint")
+        if not settings.strike_api_key:
+            raise ValueError("Missing strike_api_key")
+
+        super().__init__()
+
+        # tuneables
+        self._MAX_PARALLEL_REQUESTS = 20
+        self._MAX_RETRIES = 3
+        self._RETRY_STATUS = {429, 500, 502, 503, 504}
+        self._RETRY_BACKOFF_BASE = 2  # seconds (exponential)
+
+        # throttle
+        self._sem = asyncio.Semaphore(self._MAX_PARALLEL_REQUESTS)
+
+        # Rate limiters for different API endpoints
+        # Invoice/payment operations: 250 requests / 1 minute
+        self._invoice_limiter = TokenBucket(250, 60)
+        self._payment_limiter = TokenBucket(250, 60)
+        # All other operations: 1,000 requests / 10 minutes
+        self._general_limiter = TokenBucket(1000, 600)
+
+        self.client = httpx.AsyncClient(
+            base_url=self.normalize_endpoint(settings.strike_api_endpoint),
+            headers={
+                "Authorization": f"Bearer {settings.strike_api_key}",
+                "Content-Type": "application/json",
+                "Accept": "application/json",
+                "User-Agent": settings.user_agent,
+            },
+            timeout=httpx.Timeout(connect=5.0, read=40.0, write=10.0, pool=None),
+            transport=httpx.AsyncHTTPTransport(
+                limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
+                retries=0,  # we handle retries ourselves
+            ),
+        )
+
+        # runtime state
+        self.pending_invoices: list[str] = []  # Keep it as a list
+        self.pending_payments: Dict[str, str] = {}
+        self.failed_payments: Dict[str, str] = {}
+
+        # balance cache
+        self._cached_balance: Optional[int] = None
+        self._cached_balance_ts: float = 0.0
+        self._cache_ttl = 30  # seconds
+
+    async def cleanup(self):
+        try:
+            await self.client.aclose()
+        except Exception:
+            logger.exception("Error closing Strike client")
+
+    # --------------------------------------------------------------------- #
+    # low-level request helpers                                             #
+    # --------------------------------------------------------------------- #
+
+    async def _req(self, method: str, path: str, /, **kw) -> httpx.Response:
+        """Make a Strike HTTP call with:
+        One Strike HTTP call with
+            • rate limiting based on endpoint type
+            • concurrency throttle
+            • exponential back-off + jitter
+            • explicit retry on 429/5xx
+            • latency logging
+        """
+        # Apply the appropriate rate limiter based on the endpoint path.
+        if path.startswith("/invoices") or path.startswith("/receive-requests"):
+            await self._invoice_limiter.consume()
+        elif path.startswith("/payment-quotes"):
+            await self._payment_limiter.consume()
+        else:
+            await self._general_limiter.consume()
+            
+        async with self._sem:
+            start = time.perf_counter()
+
+            for attempt in range(self._MAX_RETRIES + 1):
+                try:
+                    resp = await self.client.request(method, path, **kw)
+                    resp.raise_for_status()
+                    logger.trace(
+                        "Strike {m} {p} – {s} in {t:.1f} ms".format(
+                            m=method.upper(),
+                            p=path,
+                            s=resp.status_code,
+                            t=(time.perf_counter() - start) * 1000,
+                        )
+                    )
+                    return resp
+
+                except httpx.HTTPStatusError as e:
+                    if (  # Only retry on specific status codes.
+                        e.response.status_code not in self._RETRY_STATUS
+                        or attempt == self._MAX_RETRIES
+                    ):
+                        raise
+                    logger.warning(
+                        "Strike {m} {p} -> {c}; retry {a}/{n}".format(
+                            m=method.upper(),
+                            p=path,
+                            c=e.response.status_code,
+                            a=attempt + 1,
+                            n=self._MAX_RETRIES,
+                        )
+                    )
+
+                except httpx.TransportError as e:
+                    if attempt == self._MAX_RETRIES:  # No more retries left.
+                        raise
+                    logger.warning(
+                        "Transport error contacting Strike ({err}); retry {a}/{n}".format(
+                            err=e, a=attempt + 1, n=self._MAX_RETRIES
+                        )
+                    )
+
+                delay = (self._RETRY_BACKOFF_BASE ** attempt) + (0.1 * random.random())  # Exponential backoff with jitter.
+                await asyncio.sleep(delay)
+
+        raise RuntimeError("exceeded retry budget in _req")
+
+    # Typed wrappers – so call-sites stay tidy.
+    async def _get(self, path: str, **kw) -> httpx.Response:  # GET request.
+        return await self._req("GET", path, **kw)
+
+    async def _post(self, path: str, **kw) -> httpx.Response:
+        return await self._req("POST", path, **kw)
+
+    async def _patch(self, path: str, **kw) -> httpx.Response:
+        return await self._req("PATCH", path, **kw)
+
+    # --------------------------------------------------------------------- #
+    # LNbits wallet API implementation                                      #
+    # --------------------------------------------------------------------- #
+
+    async def status(self) -> StatusResponse:
+        """
+        Return wallet balance (millisatoshis) with a 30-second cache.
+        """
+        now = time.time()
+        if (
+            self._cached_balance is not None
+            and now - self._cached_balance_ts < self._cache_ttl
+        ):
+            return StatusResponse(None, self._cached_balance)
+
+        try:
+            r = await self._get("/balances")  # Get balances from Strike API.
+            data = r.json()  # Parse JSON response.
+            balances = data.get("data", []) if isinstance(data, dict) else data  # Extract balances or use an empty list.
+            btc = next((b for b in balances if b.get("currency") == "BTC"), None)  # Find BTC balance.
+            if btc and "available" in btc:  # Check if BTC balance and available amount exist.
+                available_btc = Decimal(btc["available"])  # Get available BTC amount.
+                msats = int(available_btc * Decimal(1e11))  # Convert BTC to millisatoshis.
+                self._cached_balance = msats  # Update cached balance.
+                self._cached_balance_ts = now  # Update cache timestamp.
+                return StatusResponse(None, msats)  # Return successful status with balance.
+            # No BTC balance found.
+            return StatusResponse(None, 0)
+        except httpx.HTTPStatusError as e:
+            logger.error(f"Strike API error: {e.response.text}")
+            return StatusResponse(f"Strike API error: {e.response.text}", 0)
+        except Exception:
+            logger.exception("Unexpected error in status()")
+            return StatusResponse("Connection error", 0)
+
+    async def create_invoice(
+        self,
+        amount: int,
+        memo: Optional[str] = None,
+        description_hash: Optional[bytes] = None,
+        unhashed_description: bytes | None = None,  # Add this parameter
+        **kwargs,
+    ) -> InvoiceResponse:
path: lnbits/wallets/strike.py
user: login: dni
body: they payment_hash in the invoice is not the be trusted :D you can just change the whole invoice from a 100k sats to a 1 sat invoice and keep the payment_hash the same if your account system trust just the hash you got a problem.

```On the other hand the payment hash, or the hash lock, is always available in a bolt 11 invoice, and it can be used as an identifier when you're tracking a payment and to verify it's legit. The payment hash (hash lock) is a the preimage in hashed form.```

so this is kinda incorrect
created_at: 2025-05-11T22:17:13Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,475 @@
+import asyncio
+import random
+import time
+import traceback
+import uuid
+from decimal import Decimal
+from typing import Any, AsyncGenerator, Dict, List, Optional
+
+import httpx
+from loguru import logger
+
+from lnbits.settings import settings
+from .base import (
+    InvoiceResponse,
+    PaymentPendingStatus,
+    PaymentResponse,
+    PaymentStatus,
+    PaymentSuccessStatus,
+    StatusResponse,
+    Wallet,
+    PaymentFailedStatus,
+)
+
+
+class TokenBucket:
+    """
+    Token bucket rate limiter for Strike API endpoints.
+    """
+    def __init__(self, rate: int, period_seconds: int):
+        """
+        Initialize a token bucket.
+        
+        Args:
+            rate: Max requests allowed in the period
+            period_seconds: Time period in seconds.
+        """
+        self.rate = rate
+        self.period = period_seconds
+        self.tokens = rate
+        self.last_refill = time.monotonic()
+        self.lock = asyncio.Lock()
+    
+    async def consume(self) -> None:
+        """Wait until a token is available and consume it."""
+        async with self.lock:
+            # Refill tokens based on elapsed time
+            now = time.monotonic()
+            elapsed = now - self.last_refill
+            
+            if elapsed > 0:
+                new_tokens = (elapsed / self.period) * self.rate
+                self.tokens = min(self.rate, self.tokens + new_tokens)
+                self.last_refill = now
+            
+            # If no tokens available, calculate wait time
+            if self.tokens < 1:
+                wait_time = (self.period / self.rate) * (1 - self.tokens)
+                await asyncio.sleep(wait_time)
+                self.tokens = 0  # Reset after waiting
+            
+            # Consume a token
+            self.tokens -= 1
+
+
+class StrikeWallet(Wallet):
+    """
+    https://developer.strike.me/api
+    A minimal LNbits wallet backend for Strike.
+    """
+
+    # --------------------------------------------------------------------- #
+    # construction / teardown                                               #
+    # --------------------------------------------------------------------- #
+
+    def __init__(self):
+        if not settings.strike_api_endpoint:
+            raise ValueError("Missing strike_api_endpoint")
+        if not settings.strike_api_key:
+            raise ValueError("Missing strike_api_key")
+
+        super().__init__()
+
+        # tuneables
+        self._MAX_PARALLEL_REQUESTS = 20
+        self._MAX_RETRIES = 3
+        self._RETRY_STATUS = {429, 500, 502, 503, 504}
+        self._RETRY_BACKOFF_BASE = 2  # seconds (exponential)
+
+        # throttle
+        self._sem = asyncio.Semaphore(self._MAX_PARALLEL_REQUESTS)
+
+        # Rate limiters for different API endpoints
+        # Invoice/payment operations: 250 requests / 1 minute
+        self._invoice_limiter = TokenBucket(250, 60)
+        self._payment_limiter = TokenBucket(250, 60)
+        # All other operations: 1,000 requests / 10 minutes
+        self._general_limiter = TokenBucket(1000, 600)
+
+        self.client = httpx.AsyncClient(
+            base_url=self.normalize_endpoint(settings.strike_api_endpoint),
+            headers={
+                "Authorization": f"Bearer {settings.strike_api_key}",
+                "Content-Type": "application/json",
+                "Accept": "application/json",
+                "User-Agent": settings.user_agent,
+            },
+            timeout=httpx.Timeout(connect=5.0, read=40.0, write=10.0, pool=None),
+            transport=httpx.AsyncHTTPTransport(
+                limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
+                retries=0,  # we handle retries ourselves
+            ),
+        )
+
+        # runtime state
+        self.pending_invoices: list[str] = []  # Keep it as a list
+        self.pending_payments: Dict[str, str] = {}
+        self.failed_payments: Dict[str, str] = {}
+
+        # balance cache
+        self._cached_balance: Optional[int] = None
+        self._cached_balance_ts: float = 0.0
+        self._cache_ttl = 30  # seconds
+
+    async def cleanup(self):
+        try:
+            await self.client.aclose()
+        except Exception:
+            logger.exception("Error closing Strike client")
+
+    # --------------------------------------------------------------------- #
+    # low-level request helpers                                             #
+    # --------------------------------------------------------------------- #
+
+    async def _req(self, method: str, path: str, /, **kw) -> httpx.Response:
+        """Make a Strike HTTP call with:
+        One Strike HTTP call with
+            • rate limiting based on endpoint type
+            • concurrency throttle
+            • exponential back-off + jitter
+            • explicit retry on 429/5xx
+            • latency logging
+        """
+        # Apply the appropriate rate limiter based on the endpoint path.
+        if path.startswith("/invoices") or path.startswith("/receive-requests"):
+            await self._invoice_limiter.consume()
+        elif path.startswith("/payment-quotes"):
+            await self._payment_limiter.consume()
+        else:
+            await self._general_limiter.consume()
+            
+        async with self._sem:
+            start = time.perf_counter()
+
+            for attempt in range(self._MAX_RETRIES + 1):
+                try:
+                    resp = await self.client.request(method, path, **kw)
+                    resp.raise_for_status()
+                    logger.trace(
+                        "Strike {m} {p} – {s} in {t:.1f} ms".format(
+                            m=method.upper(),
+                            p=path,
+                            s=resp.status_code,
+                            t=(time.perf_counter() - start) * 1000,
+                        )
+                    )
+                    return resp
+
+                except httpx.HTTPStatusError as e:
+                    if (  # Only retry on specific status codes.
+                        e.response.status_code not in self._RETRY_STATUS
+                        or attempt == self._MAX_RETRIES
+                    ):
+                        raise
+                    logger.warning(
+                        "Strike {m} {p} -> {c}; retry {a}/{n}".format(
+                            m=method.upper(),
+                            p=path,
+                            c=e.response.status_code,
+                            a=attempt + 1,
+                            n=self._MAX_RETRIES,
+                        )
+                    )
+
+                except httpx.TransportError as e:
+                    if attempt == self._MAX_RETRIES:  # No more retries left.
+                        raise
+                    logger.warning(
+                        "Transport error contacting Strike ({err}); retry {a}/{n}".format(
+                            err=e, a=attempt + 1, n=self._MAX_RETRIES
+                        )
+                    )
+
+                delay = (self._RETRY_BACKOFF_BASE ** attempt) + (0.1 * random.random())  # Exponential backoff with jitter.
+                await asyncio.sleep(delay)
+
+        raise RuntimeError("exceeded retry budget in _req")
+
+    # Typed wrappers – so call-sites stay tidy.
+    async def _get(self, path: str, **kw) -> httpx.Response:  # GET request.
+        return await self._req("GET", path, **kw)
+
+    async def _post(self, path: str, **kw) -> httpx.Response:
+        return await self._req("POST", path, **kw)
+
+    async def _patch(self, path: str, **kw) -> httpx.Response:
+        return await self._req("PATCH", path, **kw)
+
+    # --------------------------------------------------------------------- #
+    # LNbits wallet API implementation                                      #
+    # --------------------------------------------------------------------- #
+
+    async def status(self) -> StatusResponse:
+        """
+        Return wallet balance (millisatoshis) with a 30-second cache.
+        """
+        now = time.time()
+        if (
+            self._cached_balance is not None
+            and now - self._cached_balance_ts < self._cache_ttl
+        ):
+            return StatusResponse(None, self._cached_balance)
+
+        try:
+            r = await self._get("/balances")  # Get balances from Strike API.
+            data = r.json()  # Parse JSON response.
+            balances = data.get("data", []) if isinstance(data, dict) else data  # Extract balances or use an empty list.
+            btc = next((b for b in balances if b.get("currency") == "BTC"), None)  # Find BTC balance.
+            if btc and "available" in btc:  # Check if BTC balance and available amount exist.
+                available_btc = Decimal(btc["available"])  # Get available BTC amount.
+                msats = int(available_btc * Decimal(1e11))  # Convert BTC to millisatoshis.
+                self._cached_balance = msats  # Update cached balance.
+                self._cached_balance_ts = now  # Update cache timestamp.
+                return StatusResponse(None, msats)  # Return successful status with balance.
+            # No BTC balance found.
+            return StatusResponse(None, 0)
+        except httpx.HTTPStatusError as e:
+            logger.error(f"Strike API error: {e.response.text}")
+            return StatusResponse(f"Strike API error: {e.response.text}", 0)
+        except Exception:
+            logger.exception("Unexpected error in status()")
+            return StatusResponse("Connection error", 0)
+
+    async def create_invoice(
+        self,
+        amount: int,
+        memo: Optional[str] = None,
+        description_hash: Optional[bytes] = None,
+        unhashed_description: bytes | None = None,  # Add this parameter
+        **kwargs,
+    ) -> InvoiceResponse:
path: lnbits/wallets/strike.py
user: login: santyr
body: I see.  The payment hash is really just the hashed preimage, and could be used to verify if the backend (Strike for example)  received the payment, but the preimage doesn't have any payment amount data in it.  Out of curiosity, in the code you shared whats the reason for supplying a preimage to LND rather than letting LND generate it or only supplying the payment hash to LND while holding onto the preimage in LNbits?  (hold invoices?)


created_at: 2025-05-11T22:43:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,475 @@
+import asyncio
+import random
+import time
+import traceback
+import uuid
+from decimal import Decimal
+from typing import Any, AsyncGenerator, Dict, List, Optional
+
+import httpx
+from loguru import logger
+
+from lnbits.settings import settings
+from .base import (
+    InvoiceResponse,
+    PaymentPendingStatus,
+    PaymentResponse,
+    PaymentStatus,
+    PaymentSuccessStatus,
+    StatusResponse,
+    Wallet,
+    PaymentFailedStatus,
+)
+
+
+class TokenBucket:
+    """
+    Token bucket rate limiter for Strike API endpoints.
+    """
+    def __init__(self, rate: int, period_seconds: int):
+        """
+        Initialize a token bucket.
+        
+        Args:
+            rate: Max requests allowed in the period
+            period_seconds: Time period in seconds.
+        """
+        self.rate = rate
+        self.period = period_seconds
+        self.tokens = rate
+        self.last_refill = time.monotonic()
+        self.lock = asyncio.Lock()
+    
+    async def consume(self) -> None:
+        """Wait until a token is available and consume it."""
+        async with self.lock:
+            # Refill tokens based on elapsed time
+            now = time.monotonic()
+            elapsed = now - self.last_refill
+            
+            if elapsed > 0:
+                new_tokens = (elapsed / self.period) * self.rate
+                self.tokens = min(self.rate, self.tokens + new_tokens)
+                self.last_refill = now
+            
+            # If no tokens available, calculate wait time
+            if self.tokens < 1:
+                wait_time = (self.period / self.rate) * (1 - self.tokens)
+                await asyncio.sleep(wait_time)
+                self.tokens = 0  # Reset after waiting
+            
+            # Consume a token
+            self.tokens -= 1
+
+
+class StrikeWallet(Wallet):
+    """
+    https://developer.strike.me/api
+    A minimal LNbits wallet backend for Strike.
+    """
+
+    # --------------------------------------------------------------------- #
+    # construction / teardown                                               #
+    # --------------------------------------------------------------------- #
+
+    def __init__(self):
+        if not settings.strike_api_endpoint:
+            raise ValueError("Missing strike_api_endpoint")
+        if not settings.strike_api_key:
+            raise ValueError("Missing strike_api_key")
+
+        super().__init__()
+
+        # tuneables
+        self._MAX_PARALLEL_REQUESTS = 20
+        self._MAX_RETRIES = 3
+        self._RETRY_STATUS = {429, 500, 502, 503, 504}
+        self._RETRY_BACKOFF_BASE = 2  # seconds (exponential)
+
+        # throttle
+        self._sem = asyncio.Semaphore(self._MAX_PARALLEL_REQUESTS)
+
+        # Rate limiters for different API endpoints
+        # Invoice/payment operations: 250 requests / 1 minute
+        self._invoice_limiter = TokenBucket(250, 60)
+        self._payment_limiter = TokenBucket(250, 60)
+        # All other operations: 1,000 requests / 10 minutes
+        self._general_limiter = TokenBucket(1000, 600)
+
+        self.client = httpx.AsyncClient(
+            base_url=self.normalize_endpoint(settings.strike_api_endpoint),
+            headers={
+                "Authorization": f"Bearer {settings.strike_api_key}",
+                "Content-Type": "application/json",
+                "Accept": "application/json",
+                "User-Agent": settings.user_agent,
+            },
+            timeout=httpx.Timeout(connect=5.0, read=40.0, write=10.0, pool=None),
+            transport=httpx.AsyncHTTPTransport(
+                limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
+                retries=0,  # we handle retries ourselves
+            ),
+        )
+
+        # runtime state
+        self.pending_invoices: list[str] = []  # Keep it as a list
+        self.pending_payments: Dict[str, str] = {}
+        self.failed_payments: Dict[str, str] = {}
+
+        # balance cache
+        self._cached_balance: Optional[int] = None
+        self._cached_balance_ts: float = 0.0
+        self._cache_ttl = 30  # seconds
+
+    async def cleanup(self):
+        try:
+            await self.client.aclose()
+        except Exception:
+            logger.exception("Error closing Strike client")
+
+    # --------------------------------------------------------------------- #
+    # low-level request helpers                                             #
+    # --------------------------------------------------------------------- #
+
+    async def _req(self, method: str, path: str, /, **kw) -> httpx.Response:
+        """Make a Strike HTTP call with:
+        One Strike HTTP call with
+            • rate limiting based on endpoint type
+            • concurrency throttle
+            • exponential back-off + jitter
+            • explicit retry on 429/5xx
+            • latency logging
+        """
+        # Apply the appropriate rate limiter based on the endpoint path.
+        if path.startswith("/invoices") or path.startswith("/receive-requests"):
+            await self._invoice_limiter.consume()
+        elif path.startswith("/payment-quotes"):
+            await self._payment_limiter.consume()
+        else:
+            await self._general_limiter.consume()
+            
+        async with self._sem:
+            start = time.perf_counter()
+
+            for attempt in range(self._MAX_RETRIES + 1):
+                try:
+                    resp = await self.client.request(method, path, **kw)
+                    resp.raise_for_status()
+                    logger.trace(
+                        "Strike {m} {p} – {s} in {t:.1f} ms".format(
+                            m=method.upper(),
+                            p=path,
+                            s=resp.status_code,
+                            t=(time.perf_counter() - start) * 1000,
+                        )
+                    )
+                    return resp
+
+                except httpx.HTTPStatusError as e:
+                    if (  # Only retry on specific status codes.
+                        e.response.status_code not in self._RETRY_STATUS
+                        or attempt == self._MAX_RETRIES
+                    ):
+                        raise
+                    logger.warning(
+                        "Strike {m} {p} -> {c}; retry {a}/{n}".format(
+                            m=method.upper(),
+                            p=path,
+                            c=e.response.status_code,
+                            a=attempt + 1,
+                            n=self._MAX_RETRIES,
+                        )
+                    )
+
+                except httpx.TransportError as e:
+                    if attempt == self._MAX_RETRIES:  # No more retries left.
+                        raise
+                    logger.warning(
+                        "Transport error contacting Strike ({err}); retry {a}/{n}".format(
+                            err=e, a=attempt + 1, n=self._MAX_RETRIES
+                        )
+                    )
+
+                delay = (self._RETRY_BACKOFF_BASE ** attempt) + (0.1 * random.random())  # Exponential backoff with jitter.
+                await asyncio.sleep(delay)
+
+        raise RuntimeError("exceeded retry budget in _req")
+
+    # Typed wrappers – so call-sites stay tidy.
+    async def _get(self, path: str, **kw) -> httpx.Response:  # GET request.
+        return await self._req("GET", path, **kw)
+
+    async def _post(self, path: str, **kw) -> httpx.Response:
+        return await self._req("POST", path, **kw)
+
+    async def _patch(self, path: str, **kw) -> httpx.Response:
+        return await self._req("PATCH", path, **kw)
+
+    # --------------------------------------------------------------------- #
+    # LNbits wallet API implementation                                      #
+    # --------------------------------------------------------------------- #
+
+    async def status(self) -> StatusResponse:
+        """
+        Return wallet balance (millisatoshis) with a 30-second cache.
+        """
+        now = time.time()
+        if (
+            self._cached_balance is not None
+            and now - self._cached_balance_ts < self._cache_ttl
+        ):
+            return StatusResponse(None, self._cached_balance)
+
+        try:
+            r = await self._get("/balances")  # Get balances from Strike API.
+            data = r.json()  # Parse JSON response.
+            balances = data.get("data", []) if isinstance(data, dict) else data  # Extract balances or use an empty list.
+            btc = next((b for b in balances if b.get("currency") == "BTC"), None)  # Find BTC balance.
+            if btc and "available" in btc:  # Check if BTC balance and available amount exist.
+                available_btc = Decimal(btc["available"])  # Get available BTC amount.
+                msats = int(available_btc * Decimal(1e11))  # Convert BTC to millisatoshis.
+                self._cached_balance = msats  # Update cached balance.
+                self._cached_balance_ts = now  # Update cache timestamp.
+                return StatusResponse(None, msats)  # Return successful status with balance.
+            # No BTC balance found.
+            return StatusResponse(None, 0)
+        except httpx.HTTPStatusError as e:
+            logger.error(f"Strike API error: {e.response.text}")
+            return StatusResponse(f"Strike API error: {e.response.text}", 0)
+        except Exception:
+            logger.exception("Unexpected error in status()")
+            return StatusResponse("Connection error", 0)
+
+    async def create_invoice(
+        self,
+        amount: int,
+        memo: Optional[str] = None,
+        description_hash: Optional[bytes] = None,
+        unhashed_description: bytes | None = None,  # Add this parameter
+        **kwargs,
+    ) -> InvoiceResponse:
path: lnbits/wallets/strike.py
user: login: dni
body: the reason is because lnd would not give you back the preimage on the rpc call when you create an invoice.
created_at: 2025-05-12T14:22:09Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,551 @@
+import asyncio
+import random
+import time
+import uuid
+from decimal import Decimal
+from typing import Any, AsyncGenerator, Dict, Optional
+
+import httpx
+from loguru import logger
+
+from lnbits.settings import settings
+
+from .base import (
+    InvoiceResponse,
+    PaymentFailedStatus,
+    PaymentPendingStatus,
+    PaymentResponse,
+    PaymentStatus,
+    PaymentSuccessStatus,
+    StatusResponse,
+    Wallet,
+)
+
+
+class TokenBucket:
+    """
+    Token bucket rate limiter for Strike API endpoints.
+    """
+
+    def __init__(self, rate: int, period_seconds: int):
+        """
+        Initialize a token bucket.
+
+        Args:
+            rate: Max requests allowed in the period
+            period_seconds: Time period in seconds.
+        """
+        self.rate = rate
+        self.period = period_seconds
+        self.tokens = rate
+        self.last_refill = time.monotonic()
+        self.lock = asyncio.Lock()
+
+    async def consume(self) -> None:
+        """Wait until a token is available and consume it."""
+        async with self.lock:
+            # Refill tokens based on elapsed time
+            now = time.monotonic()
+            elapsed = now - self.last_refill
+
+            if elapsed > 0:
+                new_tokens = int((elapsed / self.period) * self.rate)
+                self.tokens = min(self.rate, self.tokens + new_tokens)
+                self.last_refill = now
+
+            # If no tokens available, calculate wait time
+            if self.tokens < 1:
+                wait_time = (self.period / self.rate) * (1 - self.tokens)
+                await asyncio.sleep(wait_time)
+                self.tokens = 0  # Reset after waiting
+
+            # Consume a token
+            self.tokens -= 1
+
+
+class StrikeWallet(Wallet):
+    """
+    https://developer.strike.me/api
+    A minimal LNbits wallet backend for Strike.
+    """
+
+    # --------------------------------------------------------------------- #
+    # construction / teardown                                               #
+    # --------------------------------------------------------------------- #
+
+    def __init__(self):
+        if not settings.strike_api_endpoint:
+            raise ValueError("Missing strike_api_endpoint")
+        if not settings.strike_api_key:
+            raise ValueError("Missing strike_api_key")
+
+        super().__init__()
+
+        # tuneables
+        self._MAX_PARALLEL_REQUESTS = 20
+        self._MAX_RETRIES = 3
+        self._RETRY_STATUS = {429, 500, 502, 503, 504}
+        self._RETRY_BACKOFF_BASE = 2  # seconds (exponential)
+
+        # throttle
+        self._sem = asyncio.Semaphore(self._MAX_PARALLEL_REQUESTS)
+
+        # Rate limiters for different API endpoints
+        # Invoice/payment operations: 250 requests / 1 minute
+        self._invoice_limiter = TokenBucket(250, 60)
+        self._payment_limiter = TokenBucket(250, 60)
+        # All other operations: 1,000 requests / 10 minutes
+        self._general_limiter = TokenBucket(1000, 600)
+
+        self.client = httpx.AsyncClient(
+            base_url=self.normalize_endpoint(settings.strike_api_endpoint),
+            headers={
+                "Authorization": f"Bearer {settings.strike_api_key}",
+                "Content-Type": "application/json",
+                "Accept": "application/json",
+                "User-Agent": settings.user_agent,
+            },
+            timeout=httpx.Timeout(connect=5.0, read=40.0, write=10.0, pool=None),
+            transport=httpx.AsyncHTTPTransport(
+                limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
+                retries=0,  # we handle retries ourselves
+            ),
+        )
+
+        # runtime state
+        self.pending_invoices: list[str] = []  # Keep it as a list
+        self.pending_payments: Dict[str, str] = {}
+        self.failed_payments: Dict[str, str] = {}
+
+        # balance cache
+        self._cached_balance: Optional[int] = None
+        self._cached_balance_ts: float = 0.0
+        self._cache_ttl = 30  # seconds
+
+    async def cleanup(self):
+        try:
+            await self.client.aclose()
+        except Exception:
+            logger.exception("Error closing Strike client")
+
+    # --------------------------------------------------------------------- #
+    # low-level request helpers                                             #
+    # --------------------------------------------------------------------- #
+
+    async def _req(self, method: str, path: str, /, **kw) -> httpx.Response:
+        """Make a Strike HTTP call with:
+        One Strike HTTP call with
+            • rate limiting based on endpoint type
+            • concurrency throttle
+            • exponential back-off + jitter
+            • explicit retry on 429/5xx
+            • latency logging
+        """
+        # Apply the appropriate rate limiter based on the endpoint path.
+        if path.startswith("/invoices") or path.startswith("/receive-requests"):
+            await self._invoice_limiter.consume()
+        elif path.startswith("/payment-quotes"):
+            await self._payment_limiter.consume()
+        else:
+            await self._general_limiter.consume()
+
+        async with self._sem:
+            start = time.perf_counter()
+
+            for attempt in range(self._MAX_RETRIES + 1):
+                try:
+                    resp = await self.client.request(method, path, **kw)
+                    resp.raise_for_status()
+                    logger.trace(
+                        f"Strike {method.upper()} {path} - {resp.status_code} "
+                        f"in {(time.perf_counter() - start) * 1000:.1f} ms"
+                    )
+                    return resp
+
+                except httpx.HTTPStatusError as e:
+                    if (  # Only retry on specific status codes.
+                        e.response.status_code not in self._RETRY_STATUS
+                        or attempt == self._MAX_RETRIES
+                    ):
+                        raise
+                    logger.warning(
+                        f"Strike {method.upper()} {path} -> {e.response.status_code}; "
+                        f"retry {attempt + 1}/{self._MAX_RETRIES}"
+                    )
+
+                except httpx.TransportError as e:
+                    if attempt == self._MAX_RETRIES:  # No more retries left.
+                        raise
+                    logger.warning(
+                        f"Transport error contacting Strike ({e}); "
+                        f"retry {attempt + 1}/{self._MAX_RETRIES}"
+                    )
+
+                delay = (self._RETRY_BACKOFF_BASE**attempt) + (
+                    0.1 * random.random()
+                )  # Exponential backoff with jitter.
+                await asyncio.sleep(delay)
+
+        raise RuntimeError("exceeded retry budget in _req")
+
+    # Typed wrappers - so call-sites stay tidy.
+    async def _get(self, path: str, **kw) -> httpx.Response:  # GET request.
+        return await self._req("GET", path, **kw)
+
+    async def _post(self, path: str, **kw) -> httpx.Response:
+        return await self._req("POST", path, **kw)
+
+    async def _patch(self, path: str, **kw) -> httpx.Response:
+        return await self._req("PATCH", path, **kw)
+
+    # --------------------------------------------------------------------- #
+    # LNbits wallet API implementation                                      #
+    # --------------------------------------------------------------------- #
+
+    async def status(self) -> StatusResponse:
+        """
+        Return wallet balance (millisatoshis) with a 30-second cache.
+        """
+        now = time.time()
+        if (
+            self._cached_balance is not None
+            and now - self._cached_balance_ts < self._cache_ttl
+        ):
+            return StatusResponse(None, self._cached_balance)
+
+        try:
+            r = await self._get("/balances")  # Get balances from Strike API.
+            data = r.json()  # Parse JSON response.
+            balances = (
+                data.get("data", []) if isinstance(data, dict) else data
+            )  # Extract balances or use an empty list.
+            btc = next(
+                (b for b in balances if b.get("currency") == "BTC"), None
+            )  # Find BTC balance.
+            if (
+                btc and "available" in btc
+            ):  # Check if BTC balance and available amount exist.
+                available_btc = Decimal(btc["available"])  # Get available BTC amount.
+                msats = int(
+                    available_btc * Decimal(1e11)
+                )  # Convert BTC to millisatoshis.
+                self._cached_balance = msats  # Update cached balance.
+                self._cached_balance_ts = now  # Update cache timestamp.
+                return StatusResponse(
+                    None, msats
+                )  # Return successful status with balance.
+            # No BTC balance found.
+            return StatusResponse(None, 0)
+        except httpx.HTTPStatusError as e:
+            logger.error(f"Strike API error: {e.response.text}")
+            return StatusResponse(f"Strike API error: {e.response.text}", 0)
+        except Exception:
+            logger.exception("Unexpected error in status()")
+            return StatusResponse("Connection error", 0)
+
+    async def create_invoice(
+        self,
+        amount: int,
+        memo: Optional[str] = None,
+        description_hash: Optional[bytes] = None,
+        unhashed_description: bytes | None = None,  # Add this parameter
+        **kwargs,
+    ) -> InvoiceResponse:
+        try:
+            idem = kwargs.get("idempotency_key") or str(
+                uuid.uuid4()
+            )  # Use provided idempotency key or generate a new one.
+            btc_amt = (Decimal(amount) / Decimal(1e8)).quantize(
+                Decimal("0.00000001")
+            )  # Convert amount from millisatoshis to BTC.
+            payload: Dict[str, Any] = {
+                "bolt11": {
+                    "amount": {
+                        "currency": "BTC",
+                        "amount": str(btc_amt),
+                    },  # Set amount in BTC.
+                    "description": memo or "",
+                },
+                "targetCurrency": "BTC",
+            }
+            if description_hash:
+                payload["bolt11"]["descriptionHash"] = description_hash.hex()
+
+            r = await self._post(
+                "/receive-requests",  # Create a receive request (invoice) on Strike.
+                json=payload,
+                headers={**self.client.headers, "idempotency-key": idem},
+            )
+            resp = r.json()  # Parse JSON response.
+            invoice_id = resp.get("receiveRequestId")  # Get the receive request ID.
+            bolt11 = resp.get("bolt11", {}).get("invoice")  # Get the bolt11 invoice.
+            if (
+                not invoice_id or not bolt11
+            ):  # Check if both invoice ID and bolt11 are present.
+                return InvoiceResponse(False, None, None, "Invalid invoice response")
+
+            self.pending_invoices.append(
+                invoice_id
+            )  # Add invoice ID to pending invoices.
+            return InvoiceResponse(
+                True, invoice_id, bolt11, None
+            )  # Return successful invoice response.
+        except httpx.HTTPStatusError as e:
+            msg = e.response.json().get(
+                "message", e.response.text
+            )  # Get error message from response.
+            return InvoiceResponse(False, None, None, f"Strike API error: {msg}")
+        except Exception:
+            logger.exception("Error in create_invoice()")
+            return InvoiceResponse(False, None, None, "Connection error")
+
+    async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
+        try:
+            idem = str(uuid.uuid4())
+            # 1) Create a payment quote.
+            q = await self._post(
+                "/payment-quotes/lightning",
+                json={"lnInvoice": bolt11},
+                headers={**self.client.headers, "idempotency-key": idem},
+            )
+            quote_id = q.json().get("paymentQuoteId")  # Get the payment quote ID.
+            if not quote_id:  # Check if the quote ID is present.
+                return PaymentResponse(
+                    False, None, None, None, "Strike: missing paymentQuoteId"
+                )
+
+            # 2) Execute the payment quote.
+            e = await self._patch(f"/payment-quotes/{quote_id}/execute")
+            data = (
+                e.json() if e.content else {}
+            )  # Parse JSON response or use an empty dictionary.
+            payment_id = data.get("paymentId")  # Get the payment ID.
+            state = data.get(
+                "state", ""
+            ).upper()  # Get the payment state and convert it to uppercase.
+
+            # Network fee → msat.
+            fee_obj = (
+                data.get("lightningNetworkFee") or data.get("totalFee") or {}
+            )  # Get fee object.
+            fee_btc = Decimal(fee_obj.get("amount", "0"))  # Get fee amount in BTC.
+            fee_msat = int(
+                fee_btc * Decimal(1e11)
+            )  # Convert fee from BTC to millisatoshis.
+
+            # Store mapping for later polling.
+            if payment_id:  # If payment ID is present.
+                self.pending_payments[payment_id] = quote_id
+
+            if state in {"SUCCEEDED", "COMPLETED"}:  # If payment succeeded.
+                preimage = data.get("preimage") or data.get(
+                    "preImage"
+                )  # Get payment preimage.
+                return PaymentResponse(
+                    True, payment_id, fee_msat, preimage, None
+                )  # Return successful payment response.
+
+            # Explicitly check for known failure states.
+            failed_states = {
+                "CANCELED",
+                "FAILED",
+                "TIMED_OUT",
+            }  # Add any other known failure states here.
+            if state in failed_states:
+                return PaymentResponse(
+                    False, payment_id, None, None, f"State: {state}"
+                )  # Return failed payment response with state.
+
+            # Treat all other states as pending (including unknown states).
+            return PaymentResponse(
+                None, payment_id, None, None, None
+            )  # Return pending payment response.
+
+        except httpx.HTTPStatusError as e:
+            error_message = e.response.json().get(
+                "message", e.response.text
+            )  # Get error message from response.
+            return PaymentResponse(
+                ok=False,
+                checking_id=None,
+                fee_msat=None,
+                preimage=None,
+                error_message=f"Strike API error: {error_message}",
+            )  # Return payment response with error.
+        except Exception:
+            logger.exception("Error in pay_invoice()")
+            return PaymentResponse(False, None, None, None, "Connection error")
+
+    async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
+        try:
+            r = await self._get(
+                f"/receive-requests/{checking_id}/receives"
+            )  # Get receive requests for the invoice.
+            for itm in r.json().get("items", []):  # Iterate through received items.
+                if itm.get("state") == "COMPLETED":  # If an item is completed.
+                    # Extract preimage from lightning object if available
+                    preimage = None
+                    lightning_data = itm.get("lightning")
+                    if lightning_data:
+                        preimage = lightning_data.get("preimage")
+                    return PaymentSuccessStatus(
+                        fee_msat=0, preimage=preimage
+                    )  # Return successful payment status with preimage.
+            return (
+                PaymentPendingStatus()
+            )  # Return pending payment status if no completed items.
+        except httpx.HTTPStatusError as e:
+            if e.response.status_code == 404:  # If invoice not found.
+                try:
+                    r2 = await self._get(
+                        f"/v1/invoices/{checking_id}"
+                    )  # Try getting invoice from the old endpoint with correct path.
+                    st = r2.json().get("state", "")  # Get invoice state.
+                    if st == "PAID":  # If invoice is paid.
+                        return PaymentSuccessStatus(
+                            fee_msat=0
+                        )  # Return successful payment status.
+                    if st == "CANCELLED":  # If invoice is cancelled.
+                        return PaymentStatus(False)  # Return failed payment status.
+                except Exception:
+                    pass  # Ignore exceptions from the old endpoint.
+            return PaymentPendingStatus()  # Return pending payment status.
+        except Exception:  # Handle other exceptions.
+            logger.exception("Error in get_invoice_status()")
+            return PaymentPendingStatus()
+
+    async def get_payment_status(self, checking_id: str) -> PaymentStatus:
+        quote_id = self.pending_payments.get(checking_id)
+        if not quote_id:  # Payment not found in pending list.
+            if checking_id in self.failed_payments:
+                return PaymentFailedStatus(
+                    paid=False
+                )  # Payment is known to have failed.
+            return PaymentPendingStatus()
+        try:  # Try to get payment quote.
+            r = await self._get(
+                f"/payment-quotes/{quote_id}"
+            )  # Get payment quote from Strike API.
+            r.raise_for_status()
+            data = r.json()  # Parse JSON response.
+            state = data.get("state")  # Get payment state.
+            preimage = data.get("preimage") or data.get(
+                "preImage"
+            )  # Get payment preimage.
+            if state in ("SUCCEEDED", "COMPLETED"):  # If payment succeeded.
+                return PaymentSuccessStatus(
+                    fee_msat=0, preimage=preimage
+                )  # Return successful payment status.
+            if state == "PENDING":  # If payment is pending.
+                return PaymentPendingStatus()  # Return pending payment status.
+            return PaymentStatus(False)  # Return failed payment status.
+        except httpx.HTTPStatusError as e:
+            if e.response.status_code == 404:
+                # Quote not found, likely expired or payment failed.
+                self.pending_payments.pop(checking_id, None)
+                self.failed_payments[checking_id] = quote_id
+                return PaymentFailedStatus(paid=False)
+            raise  # Re-raise other HTTP errors
+        except Exception:  # Handle exceptions.
+            logger.exception(f"Error in get_payment_status() for payment {checking_id}")
+            return PaymentPendingStatus()  # Treat as pending for now.
+
+    async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
+        """
+        Poll Strike for invoice settlement while respecting the documented API limits.
+
+        Uses dynamic adjustment of polling frequency based on activity.
+        """
+        min_poll, max_poll = 1, 15
+        # 1,000 requests / 10 minutes = ~100 requests/minute.
+        rate_limit = 100
+        sleep_s = min_poll
+        # Main loop for polling invoices.
+        self._running = True
+
+        while self._running and settings.lnbits_running:
+            loop_start = time.time()
+            had_activity = False
+
+            req_budget = max(
+                1, rate_limit * sleep_s // 60
+            )  # Calculate request budget based on sleep time.
+            processed = 0
+
+            for inv in list(self.pending_invoices):  # Iterate through pending invoices.
path: lnbits/wallets/strike.py
user: login: motorina0
body: After a server restart this list will be empty. Is that expected?
created_at: 2025-05-14T11:07:02Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,551 @@
+import asyncio
+import random
+import time
+import uuid
+from decimal import Decimal
+from typing import Any, AsyncGenerator, Dict, Optional
+
+import httpx
+from loguru import logger
+
+from lnbits.settings import settings
+
+from .base import (
+    InvoiceResponse,
+    PaymentFailedStatus,
+    PaymentPendingStatus,
+    PaymentResponse,
+    PaymentStatus,
+    PaymentSuccessStatus,
+    StatusResponse,
+    Wallet,
+)
+
+
+class TokenBucket:
+    """
+    Token bucket rate limiter for Strike API endpoints.
+    """
+
+    def __init__(self, rate: int, period_seconds: int):
+        """
+        Initialize a token bucket.
+
+        Args:
+            rate: Max requests allowed in the period
+            period_seconds: Time period in seconds.
+        """
+        self.rate = rate
+        self.period = period_seconds
+        self.tokens = rate
+        self.last_refill = time.monotonic()
+        self.lock = asyncio.Lock()
+
+    async def consume(self) -> None:
+        """Wait until a token is available and consume it."""
+        async with self.lock:
+            # Refill tokens based on elapsed time
+            now = time.monotonic()
+            elapsed = now - self.last_refill
+
+            if elapsed > 0:
+                new_tokens = int((elapsed / self.period) * self.rate)
+                self.tokens = min(self.rate, self.tokens + new_tokens)
+                self.last_refill = now
+
+            # If no tokens available, calculate wait time
+            if self.tokens < 1:
+                wait_time = (self.period / self.rate) * (1 - self.tokens)
+                await asyncio.sleep(wait_time)
+                self.tokens = 0  # Reset after waiting
+
+            # Consume a token
+            self.tokens -= 1
+
+
+class StrikeWallet(Wallet):
+    """
+    https://developer.strike.me/api
+    A minimal LNbits wallet backend for Strike.
+    """
+
+    # --------------------------------------------------------------------- #
+    # construction / teardown                                               #
+    # --------------------------------------------------------------------- #
+
+    def __init__(self):
+        if not settings.strike_api_endpoint:
+            raise ValueError("Missing strike_api_endpoint")
+        if not settings.strike_api_key:
+            raise ValueError("Missing strike_api_key")
+
+        super().__init__()
+
+        # tuneables
+        self._MAX_PARALLEL_REQUESTS = 20
+        self._MAX_RETRIES = 3
+        self._RETRY_STATUS = {429, 500, 502, 503, 504}
+        self._RETRY_BACKOFF_BASE = 2  # seconds (exponential)
+
+        # throttle
+        self._sem = asyncio.Semaphore(self._MAX_PARALLEL_REQUESTS)
+
+        # Rate limiters for different API endpoints
+        # Invoice/payment operations: 250 requests / 1 minute
+        self._invoice_limiter = TokenBucket(250, 60)
+        self._payment_limiter = TokenBucket(250, 60)
+        # All other operations: 1,000 requests / 10 minutes
+        self._general_limiter = TokenBucket(1000, 600)
+
+        self.client = httpx.AsyncClient(
+            base_url=self.normalize_endpoint(settings.strike_api_endpoint),
+            headers={
+                "Authorization": f"Bearer {settings.strike_api_key}",
+                "Content-Type": "application/json",
+                "Accept": "application/json",
+                "User-Agent": settings.user_agent,
+            },
+            timeout=httpx.Timeout(connect=5.0, read=40.0, write=10.0, pool=None),
+            transport=httpx.AsyncHTTPTransport(
+                limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
+                retries=0,  # we handle retries ourselves
+            ),
+        )
+
+        # runtime state
+        self.pending_invoices: list[str] = []  # Keep it as a list
+        self.pending_payments: Dict[str, str] = {}
+        self.failed_payments: Dict[str, str] = {}
+
+        # balance cache
+        self._cached_balance: Optional[int] = None
+        self._cached_balance_ts: float = 0.0
+        self._cache_ttl = 30  # seconds
+
+    async def cleanup(self):
+        try:
+            await self.client.aclose()
+        except Exception:
+            logger.exception("Error closing Strike client")
+
+    # --------------------------------------------------------------------- #
+    # low-level request helpers                                             #
+    # --------------------------------------------------------------------- #
+
+    async def _req(self, method: str, path: str, /, **kw) -> httpx.Response:
+        """Make a Strike HTTP call with:
+        One Strike HTTP call with
+            • rate limiting based on endpoint type
+            • concurrency throttle
+            • exponential back-off + jitter
+            • explicit retry on 429/5xx
+            • latency logging
+        """
+        # Apply the appropriate rate limiter based on the endpoint path.
+        if path.startswith("/invoices") or path.startswith("/receive-requests"):
+            await self._invoice_limiter.consume()
+        elif path.startswith("/payment-quotes"):
+            await self._payment_limiter.consume()
+        else:
+            await self._general_limiter.consume()
+
+        async with self._sem:
+            start = time.perf_counter()
+
+            for attempt in range(self._MAX_RETRIES + 1):
+                try:
+                    resp = await self.client.request(method, path, **kw)
+                    resp.raise_for_status()
+                    logger.trace(
+                        f"Strike {method.upper()} {path} - {resp.status_code} "
+                        f"in {(time.perf_counter() - start) * 1000:.1f} ms"
+                    )
+                    return resp
+
+                except httpx.HTTPStatusError as e:
+                    if (  # Only retry on specific status codes.
+                        e.response.status_code not in self._RETRY_STATUS
+                        or attempt == self._MAX_RETRIES
+                    ):
+                        raise
+                    logger.warning(
+                        f"Strike {method.upper()} {path} -> {e.response.status_code}; "
+                        f"retry {attempt + 1}/{self._MAX_RETRIES}"
+                    )
+
+                except httpx.TransportError as e:
+                    if attempt == self._MAX_RETRIES:  # No more retries left.
+                        raise
+                    logger.warning(
+                        f"Transport error contacting Strike ({e}); "
+                        f"retry {attempt + 1}/{self._MAX_RETRIES}"
+                    )
+
+                delay = (self._RETRY_BACKOFF_BASE**attempt) + (
+                    0.1 * random.random()
+                )  # Exponential backoff with jitter.
+                await asyncio.sleep(delay)
+
+        raise RuntimeError("exceeded retry budget in _req")
+
+    # Typed wrappers - so call-sites stay tidy.
+    async def _get(self, path: str, **kw) -> httpx.Response:  # GET request.
+        return await self._req("GET", path, **kw)
+
+    async def _post(self, path: str, **kw) -> httpx.Response:
+        return await self._req("POST", path, **kw)
+
+    async def _patch(self, path: str, **kw) -> httpx.Response:
+        return await self._req("PATCH", path, **kw)
+
+    # --------------------------------------------------------------------- #
+    # LNbits wallet API implementation                                      #
+    # --------------------------------------------------------------------- #
+
+    async def status(self) -> StatusResponse:
+        """
+        Return wallet balance (millisatoshis) with a 30-second cache.
+        """
+        now = time.time()
+        if (
+            self._cached_balance is not None
+            and now - self._cached_balance_ts < self._cache_ttl
+        ):
+            return StatusResponse(None, self._cached_balance)
+
+        try:
+            r = await self._get("/balances")  # Get balances from Strike API.
+            data = r.json()  # Parse JSON response.
+            balances = (
+                data.get("data", []) if isinstance(data, dict) else data
+            )  # Extract balances or use an empty list.
+            btc = next(
+                (b for b in balances if b.get("currency") == "BTC"), None
+            )  # Find BTC balance.
+            if (
+                btc and "available" in btc
+            ):  # Check if BTC balance and available amount exist.
+                available_btc = Decimal(btc["available"])  # Get available BTC amount.
+                msats = int(
+                    available_btc * Decimal(1e11)
+                )  # Convert BTC to millisatoshis.
+                self._cached_balance = msats  # Update cached balance.
+                self._cached_balance_ts = now  # Update cache timestamp.
+                return StatusResponse(
+                    None, msats
+                )  # Return successful status with balance.
+            # No BTC balance found.
+            return StatusResponse(None, 0)
+        except httpx.HTTPStatusError as e:
+            logger.error(f"Strike API error: {e.response.text}")
+            return StatusResponse(f"Strike API error: {e.response.text}", 0)
+        except Exception:
+            logger.exception("Unexpected error in status()")
+            return StatusResponse("Connection error", 0)
+
+    async def create_invoice(
+        self,
+        amount: int,
+        memo: Optional[str] = None,
+        description_hash: Optional[bytes] = None,
+        unhashed_description: bytes | None = None,  # Add this parameter
+        **kwargs,
+    ) -> InvoiceResponse:
+        try:
+            idem = kwargs.get("idempotency_key") or str(
+                uuid.uuid4()
+            )  # Use provided idempotency key or generate a new one.
+            btc_amt = (Decimal(amount) / Decimal(1e8)).quantize(
+                Decimal("0.00000001")
+            )  # Convert amount from millisatoshis to BTC.
+            payload: Dict[str, Any] = {
+                "bolt11": {
+                    "amount": {
+                        "currency": "BTC",
+                        "amount": str(btc_amt),
+                    },  # Set amount in BTC.
+                    "description": memo or "",
+                },
+                "targetCurrency": "BTC",
+            }
+            if description_hash:
+                payload["bolt11"]["descriptionHash"] = description_hash.hex()
+
+            r = await self._post(
+                "/receive-requests",  # Create a receive request (invoice) on Strike.
+                json=payload,
+                headers={**self.client.headers, "idempotency-key": idem},
path: lnbits/wallets/strike.py
user: login: motorina0
body: Where is this `idempotency-key` header documented.
I could not find it here https://docs.strike.me/api/create-a-receive-request
created_at: 2025-05-14T11:20:51Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,551 @@
+import asyncio
+import random
+import time
+import uuid
+from decimal import Decimal
+from typing import Any, AsyncGenerator, Dict, Optional
+
+import httpx
+from loguru import logger
+
+from lnbits.settings import settings
+
+from .base import (
+    InvoiceResponse,
+    PaymentFailedStatus,
+    PaymentPendingStatus,
+    PaymentResponse,
+    PaymentStatus,
+    PaymentSuccessStatus,
+    StatusResponse,
+    Wallet,
+)
+
+
+class TokenBucket:
+    """
+    Token bucket rate limiter for Strike API endpoints.
+    """
+
+    def __init__(self, rate: int, period_seconds: int):
+        """
+        Initialize a token bucket.
+
+        Args:
+            rate: Max requests allowed in the period
+            period_seconds: Time period in seconds.
+        """
+        self.rate = rate
+        self.period = period_seconds
+        self.tokens = rate
+        self.last_refill = time.monotonic()
+        self.lock = asyncio.Lock()
+
+    async def consume(self) -> None:
+        """Wait until a token is available and consume it."""
+        async with self.lock:
+            # Refill tokens based on elapsed time
+            now = time.monotonic()
+            elapsed = now - self.last_refill
+
+            if elapsed > 0:
+                new_tokens = int((elapsed / self.period) * self.rate)
+                self.tokens = min(self.rate, self.tokens + new_tokens)
+                self.last_refill = now
+
+            # If no tokens available, calculate wait time
+            if self.tokens < 1:
+                wait_time = (self.period / self.rate) * (1 - self.tokens)
+                await asyncio.sleep(wait_time)
+                self.tokens = 0  # Reset after waiting
+
+            # Consume a token
+            self.tokens -= 1
+
+
+class StrikeWallet(Wallet):
+    """
+    https://developer.strike.me/api
+    A minimal LNbits wallet backend for Strike.
+    """
+
+    # --------------------------------------------------------------------- #
+    # construction / teardown                                               #
+    # --------------------------------------------------------------------- #
+
+    def __init__(self):
+        if not settings.strike_api_endpoint:
+            raise ValueError("Missing strike_api_endpoint")
+        if not settings.strike_api_key:
+            raise ValueError("Missing strike_api_key")
+
+        super().__init__()
+
+        # tuneables
+        self._MAX_PARALLEL_REQUESTS = 20
+        self._MAX_RETRIES = 3
+        self._RETRY_STATUS = {429, 500, 502, 503, 504}
+        self._RETRY_BACKOFF_BASE = 2  # seconds (exponential)
+
+        # throttle
+        self._sem = asyncio.Semaphore(self._MAX_PARALLEL_REQUESTS)
+
+        # Rate limiters for different API endpoints
+        # Invoice/payment operations: 250 requests / 1 minute
+        self._invoice_limiter = TokenBucket(250, 60)
+        self._payment_limiter = TokenBucket(250, 60)
+        # All other operations: 1,000 requests / 10 minutes
+        self._general_limiter = TokenBucket(1000, 600)
+
+        self.client = httpx.AsyncClient(
+            base_url=self.normalize_endpoint(settings.strike_api_endpoint),
+            headers={
+                "Authorization": f"Bearer {settings.strike_api_key}",
+                "Content-Type": "application/json",
+                "Accept": "application/json",
+                "User-Agent": settings.user_agent,
+            },
+            timeout=httpx.Timeout(connect=5.0, read=40.0, write=10.0, pool=None),
+            transport=httpx.AsyncHTTPTransport(
+                limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
+                retries=0,  # we handle retries ourselves
+            ),
+        )
+
+        # runtime state
+        self.pending_invoices: list[str] = []  # Keep it as a list
+        self.pending_payments: Dict[str, str] = {}
+        self.failed_payments: Dict[str, str] = {}
+
+        # balance cache
+        self._cached_balance: Optional[int] = None
+        self._cached_balance_ts: float = 0.0
+        self._cache_ttl = 30  # seconds
+
+    async def cleanup(self):
+        try:
+            await self.client.aclose()
+        except Exception:
+            logger.exception("Error closing Strike client")
+
+    # --------------------------------------------------------------------- #
+    # low-level request helpers                                             #
+    # --------------------------------------------------------------------- #
+
+    async def _req(self, method: str, path: str, /, **kw) -> httpx.Response:
+        """Make a Strike HTTP call with:
+        One Strike HTTP call with
+            • rate limiting based on endpoint type
+            • concurrency throttle
+            • exponential back-off + jitter
+            • explicit retry on 429/5xx
+            • latency logging
+        """
+        # Apply the appropriate rate limiter based on the endpoint path.
+        if path.startswith("/invoices") or path.startswith("/receive-requests"):
+            await self._invoice_limiter.consume()
+        elif path.startswith("/payment-quotes"):
+            await self._payment_limiter.consume()
+        else:
+            await self._general_limiter.consume()
+
+        async with self._sem:
+            start = time.perf_counter()
+
+            for attempt in range(self._MAX_RETRIES + 1):
+                try:
+                    resp = await self.client.request(method, path, **kw)
+                    resp.raise_for_status()
+                    logger.trace(
+                        f"Strike {method.upper()} {path} - {resp.status_code} "
+                        f"in {(time.perf_counter() - start) * 1000:.1f} ms"
+                    )
+                    return resp
+
+                except httpx.HTTPStatusError as e:
+                    if (  # Only retry on specific status codes.
+                        e.response.status_code not in self._RETRY_STATUS
+                        or attempt == self._MAX_RETRIES
+                    ):
+                        raise
+                    logger.warning(
+                        f"Strike {method.upper()} {path} -> {e.response.status_code}; "
+                        f"retry {attempt + 1}/{self._MAX_RETRIES}"
+                    )
+
+                except httpx.TransportError as e:
+                    if attempt == self._MAX_RETRIES:  # No more retries left.
+                        raise
+                    logger.warning(
+                        f"Transport error contacting Strike ({e}); "
+                        f"retry {attempt + 1}/{self._MAX_RETRIES}"
+                    )
+
+                delay = (self._RETRY_BACKOFF_BASE**attempt) + (
+                    0.1 * random.random()
+                )  # Exponential backoff with jitter.
+                await asyncio.sleep(delay)
+
+        raise RuntimeError("exceeded retry budget in _req")
+
+    # Typed wrappers - so call-sites stay tidy.
+    async def _get(self, path: str, **kw) -> httpx.Response:  # GET request.
+        return await self._req("GET", path, **kw)
+
+    async def _post(self, path: str, **kw) -> httpx.Response:
+        return await self._req("POST", path, **kw)
+
+    async def _patch(self, path: str, **kw) -> httpx.Response:
+        return await self._req("PATCH", path, **kw)
+
+    # --------------------------------------------------------------------- #
+    # LNbits wallet API implementation                                      #
+    # --------------------------------------------------------------------- #
+
+    async def status(self) -> StatusResponse:
+        """
+        Return wallet balance (millisatoshis) with a 30-second cache.
+        """
+        now = time.time()
+        if (
+            self._cached_balance is not None
+            and now - self._cached_balance_ts < self._cache_ttl
+        ):
+            return StatusResponse(None, self._cached_balance)
+
+        try:
+            r = await self._get("/balances")  # Get balances from Strike API.
+            data = r.json()  # Parse JSON response.
+            balances = (
+                data.get("data", []) if isinstance(data, dict) else data
+            )  # Extract balances or use an empty list.
+            btc = next(
+                (b for b in balances if b.get("currency") == "BTC"), None
+            )  # Find BTC balance.
+            if (
+                btc and "available" in btc
+            ):  # Check if BTC balance and available amount exist.
+                available_btc = Decimal(btc["available"])  # Get available BTC amount.
+                msats = int(
+                    available_btc * Decimal(1e11)
+                )  # Convert BTC to millisatoshis.
+                self._cached_balance = msats  # Update cached balance.
+                self._cached_balance_ts = now  # Update cache timestamp.
+                return StatusResponse(
+                    None, msats
+                )  # Return successful status with balance.
+            # No BTC balance found.
+            return StatusResponse(None, 0)
+        except httpx.HTTPStatusError as e:
+            logger.error(f"Strike API error: {e.response.text}")
+            return StatusResponse(f"Strike API error: {e.response.text}", 0)
+        except Exception:
+            logger.exception("Unexpected error in status()")
+            return StatusResponse("Connection error", 0)
+
+    async def create_invoice(
+        self,
+        amount: int,
+        memo: Optional[str] = None,
+        description_hash: Optional[bytes] = None,
+        unhashed_description: bytes | None = None,  # Add this parameter
+        **kwargs,
+    ) -> InvoiceResponse:
+        try:
+            idem = kwargs.get("idempotency_key") or str(
+                uuid.uuid4()
+            )  # Use provided idempotency key or generate a new one.
+            btc_amt = (Decimal(amount) / Decimal(1e8)).quantize(
+                Decimal("0.00000001")
+            )  # Convert amount from millisatoshis to BTC.
+            payload: Dict[str, Any] = {
+                "bolt11": {
+                    "amount": {
+                        "currency": "BTC",
+                        "amount": str(btc_amt),
+                    },  # Set amount in BTC.
+                    "description": memo or "",
+                },
+                "targetCurrency": "BTC",
+            }
+            if description_hash:
+                payload["bolt11"]["descriptionHash"] = description_hash.hex()
+
+            r = await self._post(
+                "/receive-requests",  # Create a receive request (invoice) on Strike.
+                json=payload,
+                headers={**self.client.headers, "idempotency-key": idem},
path: lnbits/wallets/strike.py
user: login: santyr
body: I've removed the idempotency-key headers these were holdovers from early development.  They're totally unneeded and could be harmful.

https://github.com/santyr/lnbits/commit/3db1133e68df0d0b2b73356d1a7940947d174920
created_at: 2025-05-15T14:31:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,551 @@
+import asyncio
+import random
+import time
+import uuid
+from decimal import Decimal
+from typing import Any, AsyncGenerator, Dict, Optional
+
+import httpx
+from loguru import logger
+
+from lnbits.settings import settings
+
+from .base import (
+    InvoiceResponse,
+    PaymentFailedStatus,
+    PaymentPendingStatus,
+    PaymentResponse,
+    PaymentStatus,
+    PaymentSuccessStatus,
+    StatusResponse,
+    Wallet,
+)
+
+
+class TokenBucket:
+    """
+    Token bucket rate limiter for Strike API endpoints.
+    """
+
+    def __init__(self, rate: int, period_seconds: int):
+        """
+        Initialize a token bucket.
+
+        Args:
+            rate: Max requests allowed in the period
+            period_seconds: Time period in seconds.
+        """
+        self.rate = rate
+        self.period = period_seconds
+        self.tokens = rate
+        self.last_refill = time.monotonic()
+        self.lock = asyncio.Lock()
+
+    async def consume(self) -> None:
+        """Wait until a token is available and consume it."""
+        async with self.lock:
+            # Refill tokens based on elapsed time
+            now = time.monotonic()
+            elapsed = now - self.last_refill
+
+            if elapsed > 0:
+                new_tokens = int((elapsed / self.period) * self.rate)
+                self.tokens = min(self.rate, self.tokens + new_tokens)
+                self.last_refill = now
+
+            # If no tokens available, calculate wait time
+            if self.tokens < 1:
+                wait_time = (self.period / self.rate) * (1 - self.tokens)
+                await asyncio.sleep(wait_time)
+                self.tokens = 0  # Reset after waiting
+
+            # Consume a token
+            self.tokens -= 1
+
+
+class StrikeWallet(Wallet):
+    """
+    https://developer.strike.me/api
+    A minimal LNbits wallet backend for Strike.
+    """
+
+    # --------------------------------------------------------------------- #
+    # construction / teardown                                               #
+    # --------------------------------------------------------------------- #
+
+    def __init__(self):
+        if not settings.strike_api_endpoint:
+            raise ValueError("Missing strike_api_endpoint")
+        if not settings.strike_api_key:
+            raise ValueError("Missing strike_api_key")
+
+        super().__init__()
+
+        # tuneables
+        self._MAX_PARALLEL_REQUESTS = 20
+        self._MAX_RETRIES = 3
+        self._RETRY_STATUS = {429, 500, 502, 503, 504}
+        self._RETRY_BACKOFF_BASE = 2  # seconds (exponential)
+
+        # throttle
+        self._sem = asyncio.Semaphore(self._MAX_PARALLEL_REQUESTS)
+
+        # Rate limiters for different API endpoints
+        # Invoice/payment operations: 250 requests / 1 minute
+        self._invoice_limiter = TokenBucket(250, 60)
+        self._payment_limiter = TokenBucket(250, 60)
+        # All other operations: 1,000 requests / 10 minutes
+        self._general_limiter = TokenBucket(1000, 600)
+
+        self.client = httpx.AsyncClient(
+            base_url=self.normalize_endpoint(settings.strike_api_endpoint),
+            headers={
+                "Authorization": f"Bearer {settings.strike_api_key}",
+                "Content-Type": "application/json",
+                "Accept": "application/json",
+                "User-Agent": settings.user_agent,
+            },
+            timeout=httpx.Timeout(connect=5.0, read=40.0, write=10.0, pool=None),
+            transport=httpx.AsyncHTTPTransport(
+                limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
+                retries=0,  # we handle retries ourselves
+            ),
+        )
+
+        # runtime state
+        self.pending_invoices: list[str] = []  # Keep it as a list
+        self.pending_payments: Dict[str, str] = {}
+        self.failed_payments: Dict[str, str] = {}
+
+        # balance cache
+        self._cached_balance: Optional[int] = None
+        self._cached_balance_ts: float = 0.0
+        self._cache_ttl = 30  # seconds
+
+    async def cleanup(self):
+        try:
+            await self.client.aclose()
+        except Exception:
+            logger.exception("Error closing Strike client")
+
+    # --------------------------------------------------------------------- #
+    # low-level request helpers                                             #
+    # --------------------------------------------------------------------- #
+
+    async def _req(self, method: str, path: str, /, **kw) -> httpx.Response:
+        """Make a Strike HTTP call with:
+        One Strike HTTP call with
+            • rate limiting based on endpoint type
+            • concurrency throttle
+            • exponential back-off + jitter
+            • explicit retry on 429/5xx
+            • latency logging
+        """
+        # Apply the appropriate rate limiter based on the endpoint path.
+        if path.startswith("/invoices") or path.startswith("/receive-requests"):
+            await self._invoice_limiter.consume()
+        elif path.startswith("/payment-quotes"):
+            await self._payment_limiter.consume()
+        else:
+            await self._general_limiter.consume()
+
+        async with self._sem:
+            start = time.perf_counter()
+
+            for attempt in range(self._MAX_RETRIES + 1):
+                try:
+                    resp = await self.client.request(method, path, **kw)
+                    resp.raise_for_status()
+                    logger.trace(
+                        f"Strike {method.upper()} {path} - {resp.status_code} "
+                        f"in {(time.perf_counter() - start) * 1000:.1f} ms"
+                    )
+                    return resp
+
+                except httpx.HTTPStatusError as e:
+                    if (  # Only retry on specific status codes.
+                        e.response.status_code not in self._RETRY_STATUS
+                        or attempt == self._MAX_RETRIES
+                    ):
+                        raise
+                    logger.warning(
+                        f"Strike {method.upper()} {path} -> {e.response.status_code}; "
+                        f"retry {attempt + 1}/{self._MAX_RETRIES}"
+                    )
+
+                except httpx.TransportError as e:
+                    if attempt == self._MAX_RETRIES:  # No more retries left.
+                        raise
+                    logger.warning(
+                        f"Transport error contacting Strike ({e}); "
+                        f"retry {attempt + 1}/{self._MAX_RETRIES}"
+                    )
+
+                delay = (self._RETRY_BACKOFF_BASE**attempt) + (
+                    0.1 * random.random()
+                )  # Exponential backoff with jitter.
+                await asyncio.sleep(delay)
+
+        raise RuntimeError("exceeded retry budget in _req")
+
+    # Typed wrappers - so call-sites stay tidy.
+    async def _get(self, path: str, **kw) -> httpx.Response:  # GET request.
+        return await self._req("GET", path, **kw)
+
+    async def _post(self, path: str, **kw) -> httpx.Response:
+        return await self._req("POST", path, **kw)
+
+    async def _patch(self, path: str, **kw) -> httpx.Response:
+        return await self._req("PATCH", path, **kw)
+
+    # --------------------------------------------------------------------- #
+    # LNbits wallet API implementation                                      #
+    # --------------------------------------------------------------------- #
+
+    async def status(self) -> StatusResponse:
+        """
+        Return wallet balance (millisatoshis) with a 30-second cache.
+        """
+        now = time.time()
+        if (
+            self._cached_balance is not None
+            and now - self._cached_balance_ts < self._cache_ttl
+        ):
+            return StatusResponse(None, self._cached_balance)
+
+        try:
+            r = await self._get("/balances")  # Get balances from Strike API.
+            data = r.json()  # Parse JSON response.
+            balances = (
+                data.get("data", []) if isinstance(data, dict) else data
+            )  # Extract balances or use an empty list.
+            btc = next(
+                (b for b in balances if b.get("currency") == "BTC"), None
+            )  # Find BTC balance.
+            if (
+                btc and "available" in btc
+            ):  # Check if BTC balance and available amount exist.
+                available_btc = Decimal(btc["available"])  # Get available BTC amount.
+                msats = int(
+                    available_btc * Decimal(1e11)
+                )  # Convert BTC to millisatoshis.
+                self._cached_balance = msats  # Update cached balance.
+                self._cached_balance_ts = now  # Update cache timestamp.
+                return StatusResponse(
+                    None, msats
+                )  # Return successful status with balance.
+            # No BTC balance found.
+            return StatusResponse(None, 0)
+        except httpx.HTTPStatusError as e:
+            logger.error(f"Strike API error: {e.response.text}")
+            return StatusResponse(f"Strike API error: {e.response.text}", 0)
+        except Exception:
+            logger.exception("Unexpected error in status()")
+            return StatusResponse("Connection error", 0)
+
+    async def create_invoice(
+        self,
+        amount: int,
+        memo: Optional[str] = None,
+        description_hash: Optional[bytes] = None,
+        unhashed_description: bytes | None = None,  # Add this parameter
+        **kwargs,
+    ) -> InvoiceResponse:
+        try:
+            idem = kwargs.get("idempotency_key") or str(
+                uuid.uuid4()
+            )  # Use provided idempotency key or generate a new one.
+            btc_amt = (Decimal(amount) / Decimal(1e8)).quantize(
+                Decimal("0.00000001")
+            )  # Convert amount from millisatoshis to BTC.
+            payload: Dict[str, Any] = {
+                "bolt11": {
+                    "amount": {
+                        "currency": "BTC",
+                        "amount": str(btc_amt),
+                    },  # Set amount in BTC.
+                    "description": memo or "",
+                },
+                "targetCurrency": "BTC",
+            }
+            if description_hash:
+                payload["bolt11"]["descriptionHash"] = description_hash.hex()
+
+            r = await self._post(
+                "/receive-requests",  # Create a receive request (invoice) on Strike.
+                json=payload,
+                headers={**self.client.headers, "idempotency-key": idem},
+            )
+            resp = r.json()  # Parse JSON response.
+            invoice_id = resp.get("receiveRequestId")  # Get the receive request ID.
+            bolt11 = resp.get("bolt11", {}).get("invoice")  # Get the bolt11 invoice.
+            if (
+                not invoice_id or not bolt11
+            ):  # Check if both invoice ID and bolt11 are present.
+                return InvoiceResponse(False, None, None, "Invalid invoice response")
+
+            self.pending_invoices.append(
+                invoice_id
+            )  # Add invoice ID to pending invoices.
+            return InvoiceResponse(
+                True, invoice_id, bolt11, None
+            )  # Return successful invoice response.
+        except httpx.HTTPStatusError as e:
+            msg = e.response.json().get(
+                "message", e.response.text
+            )  # Get error message from response.
+            return InvoiceResponse(False, None, None, f"Strike API error: {msg}")
+        except Exception:
+            logger.exception("Error in create_invoice()")
+            return InvoiceResponse(False, None, None, "Connection error")
+
+    async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
+        try:
+            idem = str(uuid.uuid4())
+            # 1) Create a payment quote.
+            q = await self._post(
+                "/payment-quotes/lightning",
+                json={"lnInvoice": bolt11},
+                headers={**self.client.headers, "idempotency-key": idem},
+            )
+            quote_id = q.json().get("paymentQuoteId")  # Get the payment quote ID.
+            if not quote_id:  # Check if the quote ID is present.
+                return PaymentResponse(
+                    False, None, None, None, "Strike: missing paymentQuoteId"
+                )
+
+            # 2) Execute the payment quote.
+            e = await self._patch(f"/payment-quotes/{quote_id}/execute")
+            data = (
+                e.json() if e.content else {}
+            )  # Parse JSON response or use an empty dictionary.
+            payment_id = data.get("paymentId")  # Get the payment ID.
+            state = data.get(
+                "state", ""
+            ).upper()  # Get the payment state and convert it to uppercase.
+
+            # Network fee → msat.
+            fee_obj = (
+                data.get("lightningNetworkFee") or data.get("totalFee") or {}
+            )  # Get fee object.
+            fee_btc = Decimal(fee_obj.get("amount", "0"))  # Get fee amount in BTC.
+            fee_msat = int(
+                fee_btc * Decimal(1e11)
+            )  # Convert fee from BTC to millisatoshis.
+
+            # Store mapping for later polling.
+            if payment_id:  # If payment ID is present.
+                self.pending_payments[payment_id] = quote_id
+
+            if state in {"SUCCEEDED", "COMPLETED"}:  # If payment succeeded.
+                preimage = data.get("preimage") or data.get(
+                    "preImage"
+                )  # Get payment preimage.
+                return PaymentResponse(
+                    True, payment_id, fee_msat, preimage, None
+                )  # Return successful payment response.
+
+            # Explicitly check for known failure states.
+            failed_states = {
+                "CANCELED",
+                "FAILED",
+                "TIMED_OUT",
+            }  # Add any other known failure states here.
+            if state in failed_states:
+                return PaymentResponse(
+                    False, payment_id, None, None, f"State: {state}"
+                )  # Return failed payment response with state.
+
+            # Treat all other states as pending (including unknown states).
+            return PaymentResponse(
+                None, payment_id, None, None, None
+            )  # Return pending payment response.
+
+        except httpx.HTTPStatusError as e:
+            error_message = e.response.json().get(
+                "message", e.response.text
+            )  # Get error message from response.
+            return PaymentResponse(
+                ok=False,
+                checking_id=None,
+                fee_msat=None,
+                preimage=None,
+                error_message=f"Strike API error: {error_message}",
+            )  # Return payment response with error.
+        except Exception:
+            logger.exception("Error in pay_invoice()")
+            return PaymentResponse(False, None, None, None, "Connection error")
+
+    async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
+        try:
+            r = await self._get(
+                f"/receive-requests/{checking_id}/receives"
+            )  # Get receive requests for the invoice.
+            for itm in r.json().get("items", []):  # Iterate through received items.
+                if itm.get("state") == "COMPLETED":  # If an item is completed.
+                    # Extract preimage from lightning object if available
+                    preimage = None
+                    lightning_data = itm.get("lightning")
+                    if lightning_data:
+                        preimage = lightning_data.get("preimage")
+                    return PaymentSuccessStatus(
+                        fee_msat=0, preimage=preimage
+                    )  # Return successful payment status with preimage.
+            return (
+                PaymentPendingStatus()
+            )  # Return pending payment status if no completed items.
+        except httpx.HTTPStatusError as e:
+            if e.response.status_code == 404:  # If invoice not found.
+                try:
+                    r2 = await self._get(
+                        f"/v1/invoices/{checking_id}"
+                    )  # Try getting invoice from the old endpoint with correct path.
+                    st = r2.json().get("state", "")  # Get invoice state.
+                    if st == "PAID":  # If invoice is paid.
+                        return PaymentSuccessStatus(
+                            fee_msat=0
+                        )  # Return successful payment status.
+                    if st == "CANCELLED":  # If invoice is cancelled.
+                        return PaymentStatus(False)  # Return failed payment status.
+                except Exception:
+                    pass  # Ignore exceptions from the old endpoint.
+            return PaymentPendingStatus()  # Return pending payment status.
+        except Exception:  # Handle other exceptions.
+            logger.exception("Error in get_invoice_status()")
+            return PaymentPendingStatus()
+
+    async def get_payment_status(self, checking_id: str) -> PaymentStatus:
+        quote_id = self.pending_payments.get(checking_id)
+        if not quote_id:  # Payment not found in pending list.
+            if checking_id in self.failed_payments:
+                return PaymentFailedStatus(
+                    paid=False
+                )  # Payment is known to have failed.
+            return PaymentPendingStatus()
+        try:  # Try to get payment quote.
+            r = await self._get(
+                f"/payment-quotes/{quote_id}"
+            )  # Get payment quote from Strike API.
+            r.raise_for_status()
+            data = r.json()  # Parse JSON response.
+            state = data.get("state")  # Get payment state.
+            preimage = data.get("preimage") or data.get(
+                "preImage"
+            )  # Get payment preimage.
+            if state in ("SUCCEEDED", "COMPLETED"):  # If payment succeeded.
+                return PaymentSuccessStatus(
+                    fee_msat=0, preimage=preimage
+                )  # Return successful payment status.
+            if state == "PENDING":  # If payment is pending.
+                return PaymentPendingStatus()  # Return pending payment status.
+            return PaymentStatus(False)  # Return failed payment status.
+        except httpx.HTTPStatusError as e:
+            if e.response.status_code == 404:
+                # Quote not found, likely expired or payment failed.
+                self.pending_payments.pop(checking_id, None)
+                self.failed_payments[checking_id] = quote_id
+                return PaymentFailedStatus(paid=False)
+            raise  # Re-raise other HTTP errors
+        except Exception:  # Handle exceptions.
+            logger.exception(f"Error in get_payment_status() for payment {checking_id}")
+            return PaymentPendingStatus()  # Treat as pending for now.
+
+    async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
+        """
+        Poll Strike for invoice settlement while respecting the documented API limits.
+
+        Uses dynamic adjustment of polling frequency based on activity.
+        """
+        min_poll, max_poll = 1, 15
+        # 1,000 requests / 10 minutes = ~100 requests/minute.
+        rate_limit = 100
+        sleep_s = min_poll
+        # Main loop for polling invoices.
+        self._running = True
+
+        while self._running and settings.lnbits_running:
+            loop_start = time.time()
+            had_activity = False
+
+            req_budget = max(
+                1, rate_limit * sleep_s // 60
+            )  # Calculate request budget based on sleep time.
+            processed = 0
+
+            for inv in list(self.pending_invoices):  # Iterate through pending invoices.
path: lnbits/wallets/strike.py
user: login: santyr
body: This is an oversight.  Can we use something like lnbits.core.crud get_payments() to get pending invoices on startup?
created_at: 2025-05-15T14:53:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,490 @@
+import asyncio
+import random
+import time
+from decimal import Decimal
+from typing import Any, AsyncGenerator, Dict, Optional
+
+import httpx
+from loguru import logger
+
+from lnbits.settings import settings
+
+from .base import (
+    InvoiceResponse,
+    PaymentFailedStatus,
+    PaymentPendingStatus,
+    PaymentResponse,
+    PaymentStatus,
+    PaymentSuccessStatus,
+    StatusResponse,
+    Wallet,
+)
+
+
+class TokenBucket:
+    """
+    Token bucket rate limiter for Strike API endpoints.
+    """
+
+    def __init__(self, rate: int, period_seconds: int):
+        """
+        Initialize a token bucket.
+
+        Args:
+            rate: Max requests allowed in the period
+            period_seconds: Time period in seconds.
+        """
+        self.rate = rate
+        self.period = period_seconds
+        self.tokens = rate
+        self.last_refill = time.monotonic()
+        self.lock = asyncio.Lock()
+
+    async def consume(self) -> None:
+        """Wait until a token is available and consume it."""
+        async with self.lock:
+            # Refill tokens based on elapsed time
+            now = time.monotonic()
+            elapsed = now - self.last_refill
+
+            if elapsed > 0:
+                new_tokens = int((elapsed / self.period) * self.rate)
+                self.tokens = min(self.rate, self.tokens + new_tokens)
+                self.last_refill = now
+
+            # If no tokens available, calculate wait time and wait for a token
+            if self.tokens < 1:
+                # Calculate time needed for one token
+                wait_time = (self.period / self.rate) * (1 - self.tokens)
+                await asyncio.sleep(wait_time)
+
+                # After waiting, update time and add one token
+                self.last_refill = time.monotonic()
+                self.tokens = 1
+
+            # Consume a token (will be 0 or more after consumption)
+            self.tokens -= 1
+
+
+class StrikeWallet(Wallet):
+    """
+    https://developer.strike.me/api
+    A minimal LNbits wallet backend for Strike.
+    """
+
+    # --------------------------------------------------------------------- #
+    # construction / teardown                                               #
+    # --------------------------------------------------------------------- #
+
+    def __init__(self):
+        if not settings.strike_api_endpoint:
+            raise ValueError("Missing strike_api_endpoint")
+        if not settings.strike_api_key:
+            raise ValueError("Missing strike_api_key")
+
+        super().__init__()
+
+        # tuneables
+        self._MAX_PARALLEL_REQUESTS = 20
+        self._MAX_RETRIES = 3
+        self._RETRY_STATUS = {429, 500, 502, 503, 504}
+        self._RETRY_BACKOFF_BASE = 2  # seconds (exponential)
+
+        # throttle
+        self._sem = asyncio.Semaphore(self._MAX_PARALLEL_REQUESTS)
+
+        # Rate limiters for different API endpoints
+        # Invoice/payment operations: 250 requests / 1 minute
+        self._invoice_limiter = TokenBucket(250, 60)
+        self._payment_limiter = TokenBucket(250, 60)
+        # All other operations: 1,000 requests / 10 minutes
+        self._general_limiter = TokenBucket(1000, 600)
+
+        self.client = httpx.AsyncClient(
+            base_url=self.normalize_endpoint(settings.strike_api_endpoint),
+            headers={
+                "Authorization": f"Bearer {settings.strike_api_key}",
+                "Content-Type": "application/json",
+                "Accept": "application/json",
+                "User-Agent": settings.user_agent,
+            },
+            timeout=httpx.Timeout(connect=5.0, read=40.0, write=10.0, pool=None),
+            transport=httpx.AsyncHTTPTransport(
+                limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
+                retries=0,  # we handle retries ourselves
+            ),
+        )
+
+        # runtime state
+        self.pending_invoices: list[str] = []  # Keep it as a list
+        self.pending_payments: Dict[str, str] = {}
+        self.failed_payments: Dict[str, str] = {}
+
+        # balance cache
+        self._cached_balance: Optional[int] = None
+        self._cached_balance_ts: float = 0.0
+        self._cache_ttl = 30  # seconds
+
+    async def cleanup(self):
+        try:
+            await self.client.aclose()
+        except Exception:
+            logger.warning("Error closing Strike client")
+
+    # --------------------------------------------------------------------- #
+    # low-level request helpers                                             #
+    # --------------------------------------------------------------------- #
+
+    async def _req(self, method: str, path: str, /, **kw) -> httpx.Response:
+        """Make a Strike HTTP call with:
+        One Strike HTTP call with
+            • rate limiting based on endpoint type
+            • concurrency throttle
+            • exponential back-off + jitter
+            • explicit retry on 429/5xx
+            • latency logging
+        """
+        # Apply the appropriate rate limiter based on the endpoint path.
+        if path.startswith("/invoices") or path.startswith("/receive-requests"):
+            await self._invoice_limiter.consume()
+        elif path.startswith("/payment-quotes"):
+            await self._payment_limiter.consume()
+        else:
+            await self._general_limiter.consume()
+
+        async with self._sem:
+            start = time.perf_counter()
+
+            for attempt in range(self._MAX_RETRIES + 1):
+                try:
+                    resp = await self.client.request(method, path, **kw)
+                    resp.raise_for_status()
+                    logger.trace(
+                        f"Strike {method.upper()} {path} - {resp.status_code} "
+                        f"in {(time.perf_counter() - start) * 1000:.1f} ms"
+                    )
+                    return resp
+
+                except httpx.HTTPStatusError as e:
+                    if (  # Only retry on specific status codes.
+                        e.response.status_code not in self._RETRY_STATUS
+                        or attempt == self._MAX_RETRIES
+                    ):
+                        raise
+                    logger.warning(
+                        f"Strike {method.upper()} {path} -> {e.response.status_code}; "
+                        f"retry {attempt + 1}/{self._MAX_RETRIES}"
+                    )
+
+                except httpx.TransportError as e:
+                    if attempt == self._MAX_RETRIES:  # No more retries left.
+                        raise
+                    logger.warning(
+                        f"Transport error contacting Strike ({e}); "
+                        f"retry {attempt + 1}/{self._MAX_RETRIES}"
+                    )
+
+                delay = (self._RETRY_BACKOFF_BASE**attempt) + (
+                    0.1 * random.random()
+                )  # Exponential backoff with jitter.
+                await asyncio.sleep(delay)
+
+        raise RuntimeError("exceeded retry budget in _req")
+
+    # Typed wrappers - so call-sites stay tidy.
+    async def _get(self, path: str, **kw) -> httpx.Response:  # GET request.
+        return await self._req("GET", path, **kw)
+
+    async def _post(self, path: str, **kw) -> httpx.Response:
+        return await self._req("POST", path, **kw)
+
+    async def _patch(self, path: str, **kw) -> httpx.Response:
+        return await self._req("PATCH", path, **kw)
+
+    # --------------------------------------------------------------------- #
+    # LNbits wallet API implementation                                      #
+    # --------------------------------------------------------------------- #
+
+    async def status(self) -> StatusResponse:
+        """
+        Return wallet balance (millisatoshis) with a 30-second cache.
+        """
+        now = time.time()
+        if (
+            self._cached_balance is not None
+            and now - self._cached_balance_ts < self._cache_ttl
+        ):
+            return StatusResponse(None, self._cached_balance)
+
+        try:
+            r = await self._get("/balances")  # Get balances from Strike API.
+            data = r.json()  # Parse JSON response.
+            balances = (
+                data.get("data", []) if isinstance(data, dict) else data
+            )  # Extract balances or use an empty list.
+            btc = next(
+                (b for b in balances if b.get("currency") == "BTC"), None
+            )  # Find BTC balance.
+            if (
+                btc and "available" in btc
+            ):  # Check if BTC balance and available amount exist.
+                available_btc = Decimal(btc["available"])  # Get available BTC amount.
+                msats = int(
+                    available_btc * Decimal(1e11)
+                )  # Convert BTC to millisatoshis.
+                self._cached_balance = msats  # Update cached balance.
+                self._cached_balance_ts = now  # Update cache timestamp.
+                return StatusResponse(
+                    None, msats
+                )  # Return successful status with balance.
+            # No BTC balance found.
+            return StatusResponse(None, 0)
+        except httpx.HTTPStatusError as e:
+            logger.warning(f"Strike API error: {e.response.text}")
+            return StatusResponse(f"Strike API error: {e.response.text}", 0)
+        except Exception:
+            logger.warning("Unexpected error in status()")
+            return StatusResponse("Connection error", 0)
+
+    async def create_invoice(
+        self,
+        amount: int,
+        memo: Optional[str] = None,
+        description_hash: Optional[bytes] = None,
+        unhashed_description: bytes | None = None,  # Add this parameter
+        **kwargs,
+    ) -> InvoiceResponse:
+        try:
+            btc_amt = (Decimal(amount) / Decimal(1e8)).quantize(
+                Decimal("0.00000001")
+            )  # Convert amount from millisatoshis to BTC.
+            payload: Dict[str, Any] = {
+                "bolt11": {
+                    "amount": {
+                        "currency": "BTC",
+                        "amount": str(btc_amt),
+                    },
+                    "description": memo or "",
+                },
+                "targetCurrency": "BTC",
+            }
+            if description_hash:
+                payload["bolt11"]["descriptionHash"] = description_hash.hex()
+
+            r = await self._post(
+                "/receive-requests",  # Create a receive request (invoice) on Strike.
+                json=payload,
+            )
+            resp = r.json()
+            invoice_id = resp.get("receiveRequestId")
+            bolt11 = resp.get("bolt11", {}).get("invoice")
+            if not invoice_id or not bolt11:
+                return InvoiceResponse(
+                    ok=False, error_message="Invalid invoice response"
+                )
+
+            self.pending_invoices.append(invoice_id)
+            return InvoiceResponse(
+                ok=True, checking_id=invoice_id, payment_request=bolt11
+            )
+        except httpx.HTTPStatusError as e:
+            logger.warning(e)
+            msg = e.response.json().get(
+                "message", e.response.text
+            )  # Get error message from response.
+            return InvoiceResponse(ok=False, error_message=f"Strike API error: {msg}")
+        except Exception as e:
+            logger.warning(e)
+            return InvoiceResponse(ok=False, error_message="Connection error")
+
+    async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
+        try:
+            # 1) Create a payment quote.
+            q = await self._post(
+                "/payment-quotes/lightning",
+                json={"lnInvoice": bolt11},
+            )
+            q.raise_for_status()
path: lnbits/wallets/strike.py
user: login: dni
body: i think raise_for_status is already called here. https://github.com/lnbits/lnbits/pull/3150/files#diff-9c84ef3256052b49caaea3ca9b36142217977454b9012bd6ab50534ab4050dc2R161
created_at: 2025-05-16T09:17:12Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,551 @@
+import asyncio
+import random
+import time
+import uuid
+from decimal import Decimal
+from typing import Any, AsyncGenerator, Dict, Optional
+
+import httpx
+from loguru import logger
+
+from lnbits.settings import settings
+
+from .base import (
+    InvoiceResponse,
+    PaymentFailedStatus,
+    PaymentPendingStatus,
+    PaymentResponse,
+    PaymentStatus,
+    PaymentSuccessStatus,
+    StatusResponse,
+    Wallet,
+)
+
+
+class TokenBucket:
+    """
+    Token bucket rate limiter for Strike API endpoints.
+    """
+
+    def __init__(self, rate: int, period_seconds: int):
+        """
+        Initialize a token bucket.
+
+        Args:
+            rate: Max requests allowed in the period
+            period_seconds: Time period in seconds.
+        """
+        self.rate = rate
+        self.period = period_seconds
+        self.tokens = rate
+        self.last_refill = time.monotonic()
+        self.lock = asyncio.Lock()
+
+    async def consume(self) -> None:
+        """Wait until a token is available and consume it."""
+        async with self.lock:
+            # Refill tokens based on elapsed time
+            now = time.monotonic()
+            elapsed = now - self.last_refill
+
+            if elapsed > 0:
+                new_tokens = int((elapsed / self.period) * self.rate)
+                self.tokens = min(self.rate, self.tokens + new_tokens)
+                self.last_refill = now
+
+            # If no tokens available, calculate wait time
+            if self.tokens < 1:
+                wait_time = (self.period / self.rate) * (1 - self.tokens)
+                await asyncio.sleep(wait_time)
+                self.tokens = 0  # Reset after waiting
+
+            # Consume a token
+            self.tokens -= 1
+
+
+class StrikeWallet(Wallet):
+    """
+    https://developer.strike.me/api
+    A minimal LNbits wallet backend for Strike.
+    """
+
+    # --------------------------------------------------------------------- #
+    # construction / teardown                                               #
+    # --------------------------------------------------------------------- #
+
+    def __init__(self):
+        if not settings.strike_api_endpoint:
+            raise ValueError("Missing strike_api_endpoint")
+        if not settings.strike_api_key:
+            raise ValueError("Missing strike_api_key")
+
+        super().__init__()
+
+        # tuneables
+        self._MAX_PARALLEL_REQUESTS = 20
+        self._MAX_RETRIES = 3
+        self._RETRY_STATUS = {429, 500, 502, 503, 504}
+        self._RETRY_BACKOFF_BASE = 2  # seconds (exponential)
+
+        # throttle
+        self._sem = asyncio.Semaphore(self._MAX_PARALLEL_REQUESTS)
+
+        # Rate limiters for different API endpoints
+        # Invoice/payment operations: 250 requests / 1 minute
+        self._invoice_limiter = TokenBucket(250, 60)
+        self._payment_limiter = TokenBucket(250, 60)
+        # All other operations: 1,000 requests / 10 minutes
+        self._general_limiter = TokenBucket(1000, 600)
+
+        self.client = httpx.AsyncClient(
+            base_url=self.normalize_endpoint(settings.strike_api_endpoint),
+            headers={
+                "Authorization": f"Bearer {settings.strike_api_key}",
+                "Content-Type": "application/json",
+                "Accept": "application/json",
+                "User-Agent": settings.user_agent,
+            },
+            timeout=httpx.Timeout(connect=5.0, read=40.0, write=10.0, pool=None),
+            transport=httpx.AsyncHTTPTransport(
+                limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
+                retries=0,  # we handle retries ourselves
+            ),
+        )
+
+        # runtime state
+        self.pending_invoices: list[str] = []  # Keep it as a list
+        self.pending_payments: Dict[str, str] = {}
+        self.failed_payments: Dict[str, str] = {}
+
+        # balance cache
+        self._cached_balance: Optional[int] = None
+        self._cached_balance_ts: float = 0.0
+        self._cache_ttl = 30  # seconds
+
+    async def cleanup(self):
+        try:
+            await self.client.aclose()
+        except Exception:
+            logger.exception("Error closing Strike client")
+
+    # --------------------------------------------------------------------- #
+    # low-level request helpers                                             #
+    # --------------------------------------------------------------------- #
+
+    async def _req(self, method: str, path: str, /, **kw) -> httpx.Response:
+        """Make a Strike HTTP call with:
+        One Strike HTTP call with
+            • rate limiting based on endpoint type
+            • concurrency throttle
+            • exponential back-off + jitter
+            • explicit retry on 429/5xx
+            • latency logging
+        """
+        # Apply the appropriate rate limiter based on the endpoint path.
+        if path.startswith("/invoices") or path.startswith("/receive-requests"):
+            await self._invoice_limiter.consume()
+        elif path.startswith("/payment-quotes"):
+            await self._payment_limiter.consume()
+        else:
+            await self._general_limiter.consume()
+
+        async with self._sem:
+            start = time.perf_counter()
+
+            for attempt in range(self._MAX_RETRIES + 1):
+                try:
+                    resp = await self.client.request(method, path, **kw)
+                    resp.raise_for_status()
+                    logger.trace(
+                        f"Strike {method.upper()} {path} - {resp.status_code} "
+                        f"in {(time.perf_counter() - start) * 1000:.1f} ms"
+                    )
+                    return resp
+
+                except httpx.HTTPStatusError as e:
+                    if (  # Only retry on specific status codes.
+                        e.response.status_code not in self._RETRY_STATUS
+                        or attempt == self._MAX_RETRIES
+                    ):
+                        raise
+                    logger.warning(
+                        f"Strike {method.upper()} {path} -> {e.response.status_code}; "
+                        f"retry {attempt + 1}/{self._MAX_RETRIES}"
+                    )
+
+                except httpx.TransportError as e:
+                    if attempt == self._MAX_RETRIES:  # No more retries left.
+                        raise
+                    logger.warning(
+                        f"Transport error contacting Strike ({e}); "
+                        f"retry {attempt + 1}/{self._MAX_RETRIES}"
+                    )
+
+                delay = (self._RETRY_BACKOFF_BASE**attempt) + (
+                    0.1 * random.random()
+                )  # Exponential backoff with jitter.
+                await asyncio.sleep(delay)
+
+        raise RuntimeError("exceeded retry budget in _req")
+
+    # Typed wrappers - so call-sites stay tidy.
+    async def _get(self, path: str, **kw) -> httpx.Response:  # GET request.
+        return await self._req("GET", path, **kw)
+
+    async def _post(self, path: str, **kw) -> httpx.Response:
+        return await self._req("POST", path, **kw)
+
+    async def _patch(self, path: str, **kw) -> httpx.Response:
+        return await self._req("PATCH", path, **kw)
+
+    # --------------------------------------------------------------------- #
+    # LNbits wallet API implementation                                      #
+    # --------------------------------------------------------------------- #
+
+    async def status(self) -> StatusResponse:
+        """
+        Return wallet balance (millisatoshis) with a 30-second cache.
+        """
+        now = time.time()
+        if (
+            self._cached_balance is not None
+            and now - self._cached_balance_ts < self._cache_ttl
+        ):
+            return StatusResponse(None, self._cached_balance)
+
+        try:
+            r = await self._get("/balances")  # Get balances from Strike API.
+            data = r.json()  # Parse JSON response.
+            balances = (
+                data.get("data", []) if isinstance(data, dict) else data
+            )  # Extract balances or use an empty list.
+            btc = next(
+                (b for b in balances if b.get("currency") == "BTC"), None
+            )  # Find BTC balance.
+            if (
+                btc and "available" in btc
+            ):  # Check if BTC balance and available amount exist.
+                available_btc = Decimal(btc["available"])  # Get available BTC amount.
+                msats = int(
+                    available_btc * Decimal(1e11)
+                )  # Convert BTC to millisatoshis.
+                self._cached_balance = msats  # Update cached balance.
+                self._cached_balance_ts = now  # Update cache timestamp.
+                return StatusResponse(
+                    None, msats
+                )  # Return successful status with balance.
+            # No BTC balance found.
+            return StatusResponse(None, 0)
+        except httpx.HTTPStatusError as e:
+            logger.error(f"Strike API error: {e.response.text}")
+            return StatusResponse(f"Strike API error: {e.response.text}", 0)
+        except Exception:
+            logger.exception("Unexpected error in status()")
+            return StatusResponse("Connection error", 0)
+
+    async def create_invoice(
+        self,
+        amount: int,
+        memo: Optional[str] = None,
+        description_hash: Optional[bytes] = None,
+        unhashed_description: bytes | None = None,  # Add this parameter
+        **kwargs,
+    ) -> InvoiceResponse:
+        try:
+            idem = kwargs.get("idempotency_key") or str(
+                uuid.uuid4()
+            )  # Use provided idempotency key or generate a new one.
+            btc_amt = (Decimal(amount) / Decimal(1e8)).quantize(
+                Decimal("0.00000001")
+            )  # Convert amount from millisatoshis to BTC.
+            payload: Dict[str, Any] = {
+                "bolt11": {
+                    "amount": {
+                        "currency": "BTC",
+                        "amount": str(btc_amt),
+                    },  # Set amount in BTC.
+                    "description": memo or "",
+                },
+                "targetCurrency": "BTC",
+            }
+            if description_hash:
+                payload["bolt11"]["descriptionHash"] = description_hash.hex()
+
+            r = await self._post(
+                "/receive-requests",  # Create a receive request (invoice) on Strike.
+                json=payload,
+                headers={**self.client.headers, "idempotency-key": idem},
+            )
+            resp = r.json()  # Parse JSON response.
+            invoice_id = resp.get("receiveRequestId")  # Get the receive request ID.
+            bolt11 = resp.get("bolt11", {}).get("invoice")  # Get the bolt11 invoice.
+            if (
+                not invoice_id or not bolt11
+            ):  # Check if both invoice ID and bolt11 are present.
+                return InvoiceResponse(False, None, None, "Invalid invoice response")
+
+            self.pending_invoices.append(
+                invoice_id
+            )  # Add invoice ID to pending invoices.
+            return InvoiceResponse(
+                True, invoice_id, bolt11, None
+            )  # Return successful invoice response.
+        except httpx.HTTPStatusError as e:
+            msg = e.response.json().get(
+                "message", e.response.text
+            )  # Get error message from response.
+            return InvoiceResponse(False, None, None, f"Strike API error: {msg}")
+        except Exception:
+            logger.exception("Error in create_invoice()")
+            return InvoiceResponse(False, None, None, "Connection error")
+
+    async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
+        try:
+            idem = str(uuid.uuid4())
+            # 1) Create a payment quote.
+            q = await self._post(
+                "/payment-quotes/lightning",
+                json={"lnInvoice": bolt11},
+                headers={**self.client.headers, "idempotency-key": idem},
+            )
+            quote_id = q.json().get("paymentQuoteId")  # Get the payment quote ID.
+            if not quote_id:  # Check if the quote ID is present.
+                return PaymentResponse(
+                    False, None, None, None, "Strike: missing paymentQuoteId"
+                )
+
+            # 2) Execute the payment quote.
+            e = await self._patch(f"/payment-quotes/{quote_id}/execute")
+            data = (
+                e.json() if e.content else {}
+            )  # Parse JSON response or use an empty dictionary.
+            payment_id = data.get("paymentId")  # Get the payment ID.
+            state = data.get(
+                "state", ""
+            ).upper()  # Get the payment state and convert it to uppercase.
+
+            # Network fee → msat.
+            fee_obj = (
+                data.get("lightningNetworkFee") or data.get("totalFee") or {}
+            )  # Get fee object.
+            fee_btc = Decimal(fee_obj.get("amount", "0"))  # Get fee amount in BTC.
+            fee_msat = int(
+                fee_btc * Decimal(1e11)
+            )  # Convert fee from BTC to millisatoshis.
+
+            # Store mapping for later polling.
+            if payment_id:  # If payment ID is present.
+                self.pending_payments[payment_id] = quote_id
+
+            if state in {"SUCCEEDED", "COMPLETED"}:  # If payment succeeded.
+                preimage = data.get("preimage") or data.get(
+                    "preImage"
+                )  # Get payment preimage.
+                return PaymentResponse(
+                    True, payment_id, fee_msat, preimage, None
+                )  # Return successful payment response.
+
+            # Explicitly check for known failure states.
+            failed_states = {
+                "CANCELED",
+                "FAILED",
+                "TIMED_OUT",
+            }  # Add any other known failure states here.
+            if state in failed_states:
+                return PaymentResponse(
+                    False, payment_id, None, None, f"State: {state}"
+                )  # Return failed payment response with state.
+
+            # Treat all other states as pending (including unknown states).
+            return PaymentResponse(
+                None, payment_id, None, None, None
+            )  # Return pending payment response.
+
+        except httpx.HTTPStatusError as e:
+            error_message = e.response.json().get(
+                "message", e.response.text
+            )  # Get error message from response.
+            return PaymentResponse(
+                ok=False,
+                checking_id=None,
+                fee_msat=None,
+                preimage=None,
+                error_message=f"Strike API error: {error_message}",
+            )  # Return payment response with error.
+        except Exception:
+            logger.exception("Error in pay_invoice()")
+            return PaymentResponse(False, None, None, None, "Connection error")
+
+    async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
+        try:
+            r = await self._get(
+                f"/receive-requests/{checking_id}/receives"
+            )  # Get receive requests for the invoice.
+            for itm in r.json().get("items", []):  # Iterate through received items.
+                if itm.get("state") == "COMPLETED":  # If an item is completed.
+                    # Extract preimage from lightning object if available
+                    preimage = None
+                    lightning_data = itm.get("lightning")
+                    if lightning_data:
+                        preimage = lightning_data.get("preimage")
+                    return PaymentSuccessStatus(
+                        fee_msat=0, preimage=preimage
+                    )  # Return successful payment status with preimage.
+            return (
+                PaymentPendingStatus()
+            )  # Return pending payment status if no completed items.
+        except httpx.HTTPStatusError as e:
+            if e.response.status_code == 404:  # If invoice not found.
+                try:
+                    r2 = await self._get(
+                        f"/v1/invoices/{checking_id}"
+                    )  # Try getting invoice from the old endpoint with correct path.
+                    st = r2.json().get("state", "")  # Get invoice state.
+                    if st == "PAID":  # If invoice is paid.
+                        return PaymentSuccessStatus(
+                            fee_msat=0
+                        )  # Return successful payment status.
+                    if st == "CANCELLED":  # If invoice is cancelled.
+                        return PaymentStatus(False)  # Return failed payment status.
+                except Exception:
+                    pass  # Ignore exceptions from the old endpoint.
+            return PaymentPendingStatus()  # Return pending payment status.
+        except Exception:  # Handle other exceptions.
+            logger.exception("Error in get_invoice_status()")
+            return PaymentPendingStatus()
+
+    async def get_payment_status(self, checking_id: str) -> PaymentStatus:
+        quote_id = self.pending_payments.get(checking_id)
+        if not quote_id:  # Payment not found in pending list.
+            if checking_id in self.failed_payments:
+                return PaymentFailedStatus(
+                    paid=False
+                )  # Payment is known to have failed.
+            return PaymentPendingStatus()
+        try:  # Try to get payment quote.
+            r = await self._get(
+                f"/payment-quotes/{quote_id}"
+            )  # Get payment quote from Strike API.
+            r.raise_for_status()
+            data = r.json()  # Parse JSON response.
+            state = data.get("state")  # Get payment state.
+            preimage = data.get("preimage") or data.get(
+                "preImage"
+            )  # Get payment preimage.
+            if state in ("SUCCEEDED", "COMPLETED"):  # If payment succeeded.
+                return PaymentSuccessStatus(
+                    fee_msat=0, preimage=preimage
+                )  # Return successful payment status.
+            if state == "PENDING":  # If payment is pending.
+                return PaymentPendingStatus()  # Return pending payment status.
+            return PaymentStatus(False)  # Return failed payment status.
+        except httpx.HTTPStatusError as e:
+            if e.response.status_code == 404:
+                # Quote not found, likely expired or payment failed.
+                self.pending_payments.pop(checking_id, None)
+                self.failed_payments[checking_id] = quote_id
+                return PaymentFailedStatus(paid=False)
+            raise  # Re-raise other HTTP errors
+        except Exception:  # Handle exceptions.
+            logger.exception(f"Error in get_payment_status() for payment {checking_id}")
+            return PaymentPendingStatus()  # Treat as pending for now.
+
+    async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
+        """
+        Poll Strike for invoice settlement while respecting the documented API limits.
+
+        Uses dynamic adjustment of polling frequency based on activity.
+        """
+        min_poll, max_poll = 1, 15
+        # 1,000 requests / 10 minutes = ~100 requests/minute.
+        rate_limit = 100
+        sleep_s = min_poll
+        # Main loop for polling invoices.
+        self._running = True
+
+        while self._running and settings.lnbits_running:
+            loop_start = time.time()
+            had_activity = False
+
+            req_budget = max(
+                1, rate_limit * sleep_s // 60
+            )  # Calculate request budget based on sleep time.
+            processed = 0
+
+            for inv in list(self.pending_invoices):  # Iterate through pending invoices.
path: lnbits/wallets/strike.py
user: login: motorina0
body: LNbits does a regular check (every `30 min`) on `payments` like so:

```py
  funding_source = get_funding_source()
  if self.is_out:
      status = await funding_source.get_payment_status(self.checking_id)
  else:
      status = await funding_source.get_invoice_status(self.checking_id)
```

So these two functions need to make the right API calls, return the correct status and LNbits takes care of the rest.
created_at: 2025-05-16T11:34:30Z
author_association: COLLABORATOR

PR comments:
user: login: santyr
created_at: 2025-05-09T17:39:08Z
body: Currently used in production for the Lightning Goats Project.

https://lightning-goats.com
https://primal.net/p/nprofile1qqsxd84men85p8hqgearxes2az8azljn08nydeqa0s3klayk8u7rddshkxjm3

-S
author_association: CONTRIBUTOR
user: login: motorina0
created_at: 2025-05-09T17:53:37Z
body: This is GREAT! 
Thank you for opening this PR and sharing your code with the community!

I had a quick look, looks fine. I will review in detail next week.
author_association: COLLABORATOR
user: login: motorina0
created_at: 2025-05-09T18:30:04Z
body: Can you please also add the UI for the config.

Example:
![image](https://github.com/user-attachments/assets/92900b45-493c-43ba-a83c-c8beea6ac124)

author_association: COLLABORATOR
user: login: santyr
created_at: 2025-05-09T18:54:23Z
body: Where do I add the UI for the config?
author_association: CONTRIBUTOR
user: login: motorina0
created_at: 2025-05-09T19:05:27Z
body: > Where do I add the UI for the config?

See: `lnbits/static/js/components/lnbits-funding-sources.js`

If its not your area, I can add it later, just let me know.
author_association: COLLABORATOR
user: login: santyr
created_at: 2025-05-09T19:33:42Z
body: I've updated funding sources by adding the following, but still don't see ui form elements when I run locally.  I'm probably missing something obvious:
```
breez_greenlight_device_cert: 'Greenlight Device Cert',
breez_greenlight_invite_code: 'Greenlight Invite Code'
}
        ],
        [
          'StrikeWallet',
          'Strike',
          {
            strike_api_endpoint: 'API Endpoint',
            strike_api_key: 'API Key'
          }
]
]
}
```
author_association: CONTRIBUTOR
user: login: motorina0
created_at: 2025-05-09T19:37:32Z
body: > I've updated funding sources by adding the following, but still don't see ui form elements when I run locally. I'm probably missing something obvious:

You need to run `make bundle`.

Then hard refresh in the browser.
author_association: COLLABORATOR
user: login: santyr
created_at: 2025-05-09T19:53:00Z
body: Nice!  Thank you.

![image](https://github.com/user-attachments/assets/4b849bc8-2275-49e7-889a-11d0ddc644c0)

author_association: CONTRIBUTOR
user: login: santyr
created_at: 2025-05-12T15:16:27Z
body: https://github.com/santyr/lnbits/commit/039ea51cfec1c1b60c9d6f7bc2210f2c2a39f68d

- Added support for extracting preimage from lightning payment data in invoice responses
- Now returns preimage in PaymentSuccessStatus for completed invoice payments
- Using the correct API path with /v1/ prefix for old endpoint fallback
- This change completes proper preimage propagation from Strike's API to LNbits
author_association: CONTRIBUTOR
user: login: motorina0
created_at: 2025-05-14T12:29:43Z
body: I have did some code clean-up and refactorings.
@santyr please check this PR in your repo https://github.com/santyr/lnbits/pull/2/files

I have left a few `#todo` comments for you.

Looks good!
author_association: COLLABORATOR
user: login: codecov[bot]
created_at: 2025-05-14T14:06:50Z
body: ## [Codecov](https://app.codecov.io/gh/lnbits/lnbits/pull/3150?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lnbits) Report
Attention: Patch coverage is `10.76389%` with `257 lines` in your changes missing coverage. Please review.
> Project coverage is 60.41%. Comparing base [(`cbbba5c`)](https://app.codecov.io/gh/lnbits/lnbits/commit/cbbba5c4c75f0b8d86d13dc50818c0d26515261e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lnbits) to head [(`e66b104`)](https://app.codecov.io/gh/lnbits/lnbits/commit/e66b10434b7b3b05bd9b3e3b5341c7d821c40f84?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lnbits).
> Report is 2 commits behind head on dev.

| [Files with missing lines](https://app.codecov.io/gh/lnbits/lnbits/pull/3150?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lnbits) | Patch % | Lines |
|---|---|---|
| [lnbits/wallets/strike.py](https://app.codecov.io/gh/lnbits/lnbits/pull/3150?src=pr&el=tree&filepath=lnbits%2Fwallets%2Fstrike.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lnbits#diff-bG5iaXRzL3dhbGxldHMvc3RyaWtlLnB5) | 9.50% | [257 Missing :warning: ](https://app.codecov.io/gh/lnbits/lnbits/pull/3150?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lnbits) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##              dev    #3150      +/-   ##
==========================================
- Coverage   61.49%   60.41%   -1.09%     
==========================================
  Files         104      105       +1     
  Lines       13319    13607     +288     
==========================================
+ Hits         8191     8220      +29     
- Misses       5128     5387     +259     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/lnbits/lnbits/pull/3150?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lnbits).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lnbits).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: motorina0
created_at: 2025-05-16T11:00:57Z
body: @santyr I have simplified the retry logic, it is better to be handled at the business logic level

There is only one `#todo` left: `pending_payments` will be empty after a server restart. I think another endpoint must be used if now `quote_id` found (instead of returning `pending`). See `#todo` in code.

Also, can you please test that my refactoring did not broke anything.
author_association: COLLABORATOR
user: login: santyr
created_at: 2025-05-16T12:51:39Z
body: @motorina0  Re: testing.  Edit:  I'll try out the most recent changes and let you know.

I'll look into the last todo this afternoon.  Thanks for all the help!
author_association: CONTRIBUTOR
user: login: santyr
created_at: 2025-05-16T16:34:36Z
body: @motorina0  your changes seem to be running fine. (they're in being used by the Lightning Goats right now.) I did end up fixing an url to remove an extra /v1 and reworking get_invoice_status to prevent fallback to wrong endpoint after seeing the initial 404 caused by the extra /v1 in the path.

See: 
https://github.com/santyr/lnbits/commit/64ffb26e96f10543cf008743d94ad8cd9b8cb868 
https://github.com/santyr/lnbits/commit/ca06c08c7ea77ff6476a4cda251500f83dcd00ef
author_association: CONTRIBUTOR
user: login: santyr
created_at: 2025-05-16T17:13:29Z
body: I've modified get_payment_status() to address the #todo regarding `pending_payments` being lost on server restart.  This should do it:

https://github.com/santyr/lnbits/commit/13776c5e15df0f2788ac0d3e808b9ef4b3ad2180
author_association: CONTRIBUTOR
user: login: motorina0
created_at: 2025-05-19T11:45:25Z
body: @santyr thank you for pushing the changes.

- I have did a bit of refactoring (no functional changes). Please have a look and test.
- After the CI regtest issue is fixed (unrelated to this PR) I will merge this PR.
- I have also renamed `Strike` to `Strike (alpha)` in the funding source list. I will remove the `alpha` after this feature is part of at least one minor release. We will be waiting confirmation from you that all works as expected with the official release.

Thank you again for your contribution.

author_association: COLLABORATOR
user: login: santyr
created_at: 2025-05-19T18:12:01Z
body: Thanks @motorina0.  Eveything looks great. I'm running it now.  I'll let you know if I find anything that needs a tweak.
author_association: CONTRIBUTOR