From f81439ee75ac85606cf6e72a16cb14e640a09068 Mon Sep 17 00:00:00 2001 From: Evan Kaloudis Date: Mon, 6 May 2024 12:35:07 -0400 Subject: [PATCH 01/21] LSPS1 client Co-authored-by: Shubham --- Navigation.ts | 22 +- assets/images/SVG/OlympusAnimated.svg | 26 + assets/images/SVG/order-list.svg | 1 + backends/CLightningREST.ts | 2 + backends/Eclair.ts | 2 + backends/EmbeddedLND.ts | 9 +- backends/LND.ts | 53 + backends/LightningNodeConnect.ts | 12 + backends/LndHub.ts | 2 + backends/Spark.ts | 2 + ios/LndMobile/Lnd.swift | 3 + ios/Podfile.lock | 8 + lndmobile/LndMobileInjection.ts | 17 +- lndmobile/index.ts | 46 + locales/en.json | 35 + package.json | 1 + stores/LSPStore.ts | 260 ++++- stores/SettingsStore.ts | 64 ++ utils/BackendUtils.ts | 8 + views/Channels/ChannelsPane.tsx | 77 +- views/Settings/ChannelsSettings.tsx | 49 +- views/Settings/LSPS1/Order.tsx | 515 ++++++++++ views/Settings/LSPS1/OrdersPane.tsx | 232 +++++ views/Settings/LSPS1/Settings.tsx | 279 ++++++ views/Settings/LSPS1/index.tsx | 1338 +++++++++++++++++++++++++ views/Settings/LSPServicesList.tsx | 86 ++ views/Settings/Settings.tsx | 16 +- yarn.lock | 5 + 28 files changed, 3153 insertions(+), 17 deletions(-) create mode 100644 assets/images/SVG/OlympusAnimated.svg create mode 100644 assets/images/SVG/order-list.svg create mode 100644 views/Settings/LSPS1/Order.tsx create mode 100644 views/Settings/LSPS1/OrdersPane.tsx create mode 100644 views/Settings/LSPS1/Settings.tsx create mode 100644 views/Settings/LSPS1/index.tsx create mode 100644 views/Settings/LSPServicesList.tsx diff --git a/Navigation.ts b/Navigation.ts index 38faa85ee..e8e305eba 100644 --- a/Navigation.ts +++ b/Navigation.ts @@ -60,7 +60,8 @@ import Products from './views/POS/Products'; import ProductDetails from './views/POS/ProductDetails'; import PaymentsSettings from './views/Settings/PaymentsSettings'; import InvoicesSettings from './views/Settings/InvoicesSettings'; -import LSP from './views/Settings/LSP'; +import LSPServicesList from './views/Settings/LSPServicesList'; +import LSP from './views/Settings/LSP'; // Flow 2.0 import ChannelsSettings from './views/Settings/ChannelsSettings'; import SetNodePicture from './views/Settings/SetNodePicture'; @@ -128,6 +129,10 @@ import RestoreChannelBackups from './views/Settings/EmbeddedNode/RestoreChannelB import RawTxHex from './views/RawTxHex'; import CustodialWalletWarning from './views/Settings/CustodialWalletWarning'; +import LSPS1 from './views/Settings/LSPS1/index'; +import OrdersPane from './views/Settings/LSPS1/OrdersPane'; +import LSPS1Order from './views/Settings/LSPS1/Order'; +import LSPS1Settings from './views/Settings/LSPS1/Settings'; import PSBT from './views/PSBT'; import TxHex from './views/TxHex'; @@ -391,6 +396,9 @@ const AppScenes = { EmbeddedNodeSettingsAdvanced: { screen: Advanced }, + LSPServicesList: { + screen: LSPServicesList + }, LSPSettings: { screen: LSP }, @@ -459,6 +467,18 @@ const AppScenes = { }, TxHex: { screen: TxHex + }, + LSPS1: { + screen: LSPS1 + }, + OrdersPane: { + screen: OrdersPane + }, + LSPS1Order: { + screen: LSPS1Order + }, + LSPS1Settings: { + screen: LSPS1Settings } }; diff --git a/assets/images/SVG/OlympusAnimated.svg b/assets/images/SVG/OlympusAnimated.svg new file mode 100644 index 000000000..b9d675f1c --- /dev/null +++ b/assets/images/SVG/OlympusAnimated.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/SVG/order-list.svg b/assets/images/SVG/order-list.svg new file mode 100644 index 000000000..f33f118ab --- /dev/null +++ b/assets/images/SVG/order-list.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backends/CLightningREST.ts b/backends/CLightningREST.ts index c0591031a..13cef2a81 100644 --- a/backends/CLightningREST.ts +++ b/backends/CLightningREST.ts @@ -275,4 +275,6 @@ export default class CLightningREST extends LND { supportsOnchainBatching = () => false; supportsChannelBatching = () => false; isLNDBased = () => false; + supportsLSPS1customMessage = () => false; + supportsLSPS1rest = () => true; } diff --git a/backends/Eclair.ts b/backends/Eclair.ts index 313fb39b2..01f26ae46 100644 --- a/backends/Eclair.ts +++ b/backends/Eclair.ts @@ -507,6 +507,8 @@ export default class Eclair { supportsOnchainBatching = () => false; supportsChannelBatching = () => false; isLNDBased = () => false; + supportsLSPS1customMessage = () => false; + supportsLSPS1rest = () => true; } const mapInvoice = diff --git a/backends/EmbeddedLND.ts b/backends/EmbeddedLND.ts index 25001ecde..d6c557d30 100644 --- a/backends/EmbeddedLND.ts +++ b/backends/EmbeddedLND.ts @@ -23,7 +23,9 @@ const { getNetworkInfo, queryRoutes, lookupInvoice, - fundingStateStep + fundingStateStep, + sendCustomMessage, + subscribeCustomMessages } = lndMobile.index; const { channelBalance, @@ -68,6 +70,9 @@ export default class EmbeddedLND extends LND { data.spend_unconfirmed, data.send_all ); + sendCustomMessage = async (data: any) => + await sendCustomMessage(data.peer, data.type, data.data); + subscribeCustomMessages = async () => await subscribeCustomMessages(); getMyNodeInfo = async () => await getInfo(); getNetworkInfo = async () => await getNetworkInfo(); getInvoices = async () => await listInvoices(); @@ -289,4 +294,6 @@ export default class EmbeddedLND extends LND { supportsOnchainBatching = () => true; supportsChannelBatching = () => true; isLNDBased = () => true; + supportsLSPS1customMessage = () => true; + supportsLSPS1rest = () => false; } diff --git a/backends/LND.ts b/backends/LND.ts index 0512140d6..faa21d4a1 100644 --- a/backends/LND.ts +++ b/backends/LND.ts @@ -251,6 +251,57 @@ export default class LND { spend_unconfirmed: data.spend_unconfirmed, send_all: data.send_all }); + sendCustomMessage = (data: any) => + this.postRequest('/v1/custommessage', { + peer: Base64Utils.hexToBase64(data.peer), + type: data.type, + data: Base64Utils.hexToBase64(data.data) + }); + subscribeCustomMessages = (onResponse: any, onError: any) => { + const route = '/v1/custommessage/subscribe'; + const method = 'GET'; + + const { host, lndhubUrl, port, macaroonHex, accessToken } = + stores.settingsStore; + + const auth = macaroonHex || accessToken; + const headers: any = this.getHeaders(auth, true); + const methodRoute = `${route}?method=${method}`; + const url = this.getURL(host || lndhubUrl, port, methodRoute, true); + + const ws: any = new WebSocket(url, null, { + headers + }); + + ws.addEventListener('open', () => { + // connection opened + console.log('subscribeCustomMessages ws open'); + ws.send(JSON.stringify({})); + }); + + ws.addEventListener('message', (e: any) => { + // a message was received + const data = JSON.parse(e.data); + console.log('subscribeCustomMessagews message', data); + if (data.error) { + onError(data.error); + } else { + onResponse(data); + } + }); + + ws.addEventListener('error', (e: any) => { + // an error occurred + console.log('subscribeCustomMessages ws err', e); + const certWarning = localeString('backends.LND.wsReq.warning'); + onError(e.message ? `${certWarning} (${e.message})` : certWarning); + }); + + ws.addEventListener('close', () => { + // ws closed + console.log('subscribeCustomMessages ws close'); + }); + }; getMyNodeInfo = () => this.getRequest('/v1/getinfo'); getInvoices = (data: any) => this.getRequest( @@ -616,4 +667,6 @@ export default class LND { supportsOnchainBatching = () => true; supportsChannelBatching = () => true; isLNDBased = () => true; + supportsLSPS1customMessage = () => true; + supportsLSPS1rest = () => false; } diff --git a/backends/LightningNodeConnect.ts b/backends/LightningNodeConnect.ts index 4ff92722b..986c5d865 100644 --- a/backends/LightningNodeConnect.ts +++ b/backends/LightningNodeConnect.ts @@ -123,6 +123,16 @@ export default class LightningNodeConnect { send_all: data.send_all }) .then((data: lnrpc.SendCoinsResponse) => snakeize(data)); + sendCustomMessage = async (data: any) => + await this.lnc.lnd.lightning + .sendCustomMessage({ + peer: Base64Utils.hexToBase64(data.peer), + type: data.type, + data: Base64Utils.hexToBase64(data.data) + }) + .then((data: lnrpc.SendCustomMessageResponse) => snakeize(data)); + subscribeCustomMessages = () => + this.lnc.lnd.lightning.subscribeCustomMessages({}); getMyNodeInfo = async () => await this.lnc.lnd.lightning .getInfo({}) @@ -476,4 +486,6 @@ export default class LightningNodeConnect { supportsOnchainBatching = () => true; supportsChannelBatching = () => true; isLNDBased = () => true; + supportsLSPS1customMessage = () => true; + supportsLSPS1rest = () => false; } diff --git a/backends/LndHub.ts b/backends/LndHub.ts index 7f4212818..c5fccd704 100644 --- a/backends/LndHub.ts +++ b/backends/LndHub.ts @@ -155,4 +155,6 @@ export default class LndHub extends LND { supportsOnchainBatching = () => false; supportsChannelBatching = () => true; isLNDBased = () => false; + supportsLSPS1customMessage = () => false; + supportsLSPS1rest = () => false; } diff --git a/backends/Spark.ts b/backends/Spark.ts index 226034e78..b9b02189c 100644 --- a/backends/Spark.ts +++ b/backends/Spark.ts @@ -381,4 +381,6 @@ export default class Spark { supportsOnchainBatching = () => false; supportsChannelBatching = () => true; isLNDBased = () => false; + supportsLSPS1customMessage = () => false; + supportsLSPS1rest = () => true; } diff --git a/ios/LndMobile/Lnd.swift b/ios/LndMobile/Lnd.swift index 58ec8227d..ed794e3a6 100644 --- a/ios/LndMobile/Lnd.swift +++ b/ios/LndMobile/Lnd.swift @@ -75,6 +75,7 @@ open class Lnd { "AddInvoice": { bytes, cb in LndmobileAddInvoice(bytes, cb) }, "InvoicesCancelInvoice": { bytes, cb in LndmobileInvoicesCancelInvoice(bytes, cb) }, "ConnectPeer": { bytes, cb in LndmobileConnectPeer(bytes, cb) }, + "SendCustomMessage": { bytes, cb in LndmobileSendCustomMessage(bytes, cb) }, "DecodePayReq": { bytes, cb in LndmobileDecodePayReq(bytes, cb) }, "DescribeGraph": { bytes, cb in LndmobileDescribeGraph(bytes, cb) }, "GetInfo": { bytes, cb in LndmobileGetInfo(bytes, cb) }, @@ -144,6 +145,8 @@ open class Lnd { "SubscribeState": { req, cb in return LndmobileSubscribeState(req, cb) }, "RouterTrackPaymentV2": { req, cb in return LndmobileRouterTrackPaymentV2(req, cb) }, "OpenChannel": { bytes, cb in LndmobileOpenChannel(bytes, cb) }, + "SubscribeCustomMessages": { bytes, cb in LndmobileSubscribeCustomMessages(bytes, cb) }, + // channel // "CloseChannel": { req, cb in return LndmobileCloseChannel(req, cb)}, diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 8d51046d6..0f4a06c72 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -923,6 +923,10 @@ PODS: - React-Core - react-native-safe-area-context (0.6.4): - React + - react-native-slider (4.5.2): + - glog + - RCT-Folly (= 2022.05.16.00) + - React-Core - react-native-tor (0.1.8): - React - react-native-udp (4.1.7): @@ -1186,6 +1190,7 @@ DEPENDENCIES: - react-native-randombytes (from `../node_modules/react-native-randombytes`) - react-native-restart (from `../node_modules/react-native-restart`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) + - "react-native-slider (from `../node_modules/@react-native-community/slider`)" - react-native-tor (from `../node_modules/react-native-tor`) - react-native-udp (from `../node_modules/react-native-udp`) - React-nativeconfig (from `../node_modules/react-native/ReactCommon`) @@ -1326,6 +1331,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-restart" react-native-safe-area-context: :path: "../node_modules/react-native-safe-area-context" + react-native-slider: + :path: "../node_modules/@react-native-community/slider" react-native-tor: :path: "../node_modules/react-native-tor" react-native-udp: @@ -1454,6 +1461,7 @@ SPEC CHECKSUMS: react-native-randombytes: 3638d24759d67c68f6ccba60c52a7a8a8faa6a23 react-native-restart: 7595693413fe3ca15893702f2c8306c62a708162 react-native-safe-area-context: 52342d2d80ea8faadd0ffa76d83b6051f20c5329 + react-native-slider: 7a39874fc1fcdfee48e448fa72cce0a8f2c7c5d6 react-native-tor: 3b14e9160b2eb7fa3f310921b2dee71a5171e5b7 react-native-udp: df79c3cb72c4e71240cd3ce4687bfb8a137140d5 React-nativeconfig: 754233aac2a769578f828093b672b399355582e6 diff --git a/lndmobile/LndMobileInjection.ts b/lndmobile/LndMobileInjection.ts index 158ef95a3..4f8c645fe 100644 --- a/lndmobile/LndMobileInjection.ts +++ b/lndmobile/LndMobileInjection.ts @@ -37,7 +37,10 @@ import { listInvoices, subscribeChannelGraph, sendKeysendPaymentV2, - fundingStateStep + fundingStateStep, + sendCustomMessage, + subscribeCustomMessages, + decodeCustomMessage } from './index'; import { channelBalance, @@ -238,6 +241,13 @@ export interface ILndMobileInjections { psbt_verify, psbt_finalize }: any) => Promise; + sendCustomMessage: ( + peer: Uint8Array | null, + type: number | null, + data: Uint8Array | null + ) => Promise; + subscribeCustomMessages: () => Promise; + decodeCustomMessage: (data: string) => lnrpc.CustomMessage; }; channel: { channelBalance: () => Promise; @@ -464,7 +474,10 @@ export default { listInvoices, subscribeChannelGraph, sendKeysendPaymentV2, - fundingStateStep + fundingStateStep, + sendCustomMessage, + subscribeCustomMessages, + decodeCustomMessage }, channel: { channelBalance, diff --git a/lndmobile/index.ts b/lndmobile/index.ts index e807e49b3..5d3e06400 100644 --- a/lndmobile/index.ts +++ b/lndmobile/index.ts @@ -157,6 +157,52 @@ export const connectPeer = async ( }); }; +/** + * @throws + */ +export const sendCustomMessage = async ( + peer: string, + type: number, + data: string +): Promise => { + return await sendCommand< + lnrpc.ISendCustomMessageRequest, + lnrpc.SendCustomMessageRequest, + lnrpc.SendCustomMessageResponse + >({ + request: lnrpc.SendCustomMessageRequest, + response: lnrpc.SendCustomMessageResponse, + method: 'SendCustomMessage', + options: { + peer: Base64Utils.hexToBase64(peer), + type, + data: Base64Utils.hexToBase64(data) + } + }); +}; + +/** + * @throws + */ +export const subscribeCustomMessages = async (): Promise => { + const response = await sendStreamCommand< + lnrpc.ISubscribeCustomMessagesRequest, + lnrpc.SubscribeCustomMessagesRequest + >({ + request: lnrpc.SubscribeCustomMessagesRequest, + method: 'SubscribeCustomMessages', + options: {} + }); + return response; +}; + +export const decodeCustomMessage = (data: string): lnrpc.CustomMessage => { + return decodeStreamResult({ + response: lnrpc.CustomMessage, + base64Result: data + }); +}; + /** * @throws */ diff --git a/locales/en.json b/locales/en.json index 78c479027..e2b5ee40a 100644 --- a/locales/en.json +++ b/locales/en.json @@ -88,6 +88,7 @@ "general.valid": "Valid", "general.invalid": "Invalid", "general.createdAt": "Created at", + "general.expiresAt": "Expires at", "general.id": "ID", "general.hash": "Hash", "general.kind": "Kind", @@ -104,6 +105,7 @@ "general.destination": "Destination", "general.externalAccount": "External account", "general.version": "Version", + "general.state": "State", "restart.title": "Restart required", "restart.msg": "ZEUS has to be restarted before the new configuration is applied.", "restart.msg1": "Would you like to restart now?", @@ -280,6 +282,7 @@ "views.Wallet.Channels.online": "Online", "views.Wallet.Channels.offline": "Offline", "views.Wallet.Channels.filters": "Filters", + "views.Wallet.Channels.purchaseInbound": "Purchase Inbound", "views.OpenChannel.announceChannel": "Announce channel", "views.OpenChannel.scidAlias": "Attempt to use SCID alias", "views.OpenChannel.simpleTaprootChannel": "Simple Taproot Channel", @@ -707,6 +710,7 @@ "views.Settings.Invoices.title": "Invoices settings", "views.Settings.Invoices.showCustomPreimageField": "Show custom preimage field", "views.Settings.Channels.title": "Channels settings", + "views.Settings.Channels.lsps1ShowPurchaseButton": "Show channel purchase button", "views.Settings.Privacy.blockExplorer": "Default Block explorer", "views.Settings.Privacy.BlockExplorer.custom": "Custom", "views.Settings.Privacy.customBlockExplorer": "Custom Block explorer", @@ -928,6 +932,34 @@ "views.Sync.currentBlockHeight": "Current block height", "views.Sync.tip": "Tip", "views.Sync.numBlocksUntilSynced": "Number of blocks until synced", + "views.LSPS1.pubkeyAndHostNotFound": "Node pubkey and host are not set", + "views.LSPS1.timeoutError": "Did not receive response from server", + "views.LSPS1.channelExpiryBlocks": "Channel Expiry Blocks", + "views.LSPS1.maxChannelExpiryBlocks": "Max channel Expiry Blocks", + "views.LSPS1.clientBalance": "Client balance", + "views.LSPS1.totalChannelSize": "Total channel size", + "views.LSPS1.confirmWithinBlocks": "Confirm within blocks", + "views.LSPS1.lspBalance": "LSP Balance", + "views.LSPS1.orderId": "Order ID", + "views.LSPS1.orderState": "Order state", + "views.LSPS1.miniFeeFor0Conf": "Min fee for 0 conf", + "views.LSPS1.minOnchainPaymentConfirmations": "Min Onchain Payment Confirmations", + "views.LSPS1.onchainPayment": "Onchain payment", + "views.LSPS1.totalOrderValue": "Total order value", + "views.LSPS1.initialLSPBalance": "Initial LSP Balance", + "views.LSPS1.initialClientBalance": "Initial Client Balance", + "views.LSPS1.minChannelConfirmations": "Min channel confirmations", + "views.LSPS1.minOnchainPaymentSize": "Min onchain payment size", + "views.LSPS1.supportZeroChannelReserve": "Support zero channel reserve", + "views.LSPS1.requiredChannelConfirmations": "Required channel confirmations", + "views.LSPS1.token": "Token", + "views.LSPS1.refundOnchainAddress": "Refund onchain address", + "views.LSPS1.getQuote": "Get quote", + "views.LSPS1.makePayment": "Make payment", + "views.LSPS1.goToSettings": "Go to settings", + "views.LSPS1.fundedAt": "Funded At", + "views.LSPS1.fundingOutpoint": "Funding outpoint", + "views.LSPS1.settings": "LSPS1 Settings", "components.UTXOPicker.modal.title": "Select UTXOs to use", "components.UTXOPicker.modal.description": "Select the UTXOs to be used in this operation. You may want to only use specific UTXOs to preserve your privacy.", "components.UTXOPicker.modal.set": "Set UTXOs", @@ -1053,6 +1085,9 @@ "views.Settings.CustodialWalletWarning.graph3": "ZEUS has the ability to create a self-custodial wallet in the app. This wallet provides you with a 24-word seed phrase that gives you full control of your funds.", "views.Settings.CustodialWalletWarning.graph4": "To get started with your own self-custodial wallet, press the button below, and hit the 'Create mainnet wallet' button on the next screen.", "views.Settings.CustodialWalletWarning.create": "Create self-custodial wallet", + "view.Settings.LSPServicesList.title": "LSP Services", + "view.Settings.LSPServicesList.flow2": "Just-in-time channels", + "view.Settings.LSPServicesList.lsps1": "Purchase channels in advance", "views.LspExplanation.text1": "Zeus is a self-custodial lightning wallet. In order to send or receive a lightning payment, you must open a lightning payment channel, which has a setup fee.", "views.LspExplanation.text2": "Once the channel is set up, you'll only have to pay normal network fees until your channel exhausts its capacity.", "views.LspExplanation.buttonText": "Learn more about liquidity", diff --git a/package.json b/package.json index 03c2dc2eb..01c124c42 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@react-native-clipboard/clipboard": "1.13.2", "@react-native-community/masked-view": "0.1.11", "@react-native-community/netinfo": "11.3.0", + "@react-native-community/slider": "4.5.2", "@react-native-picker/picker": "2.6.1", "@react-navigation/bottom-tabs": "5.11.11", "@react-navigation/native": "6.1.10", diff --git a/stores/LSPStore.ts b/stores/LSPStore.ts index 9594df885..6d8025485 100644 --- a/stores/LSPStore.ts +++ b/stores/LSPStore.ts @@ -6,21 +6,31 @@ import ChannelsStore from './ChannelsStore'; import NodeInfoStore from './NodeInfoStore'; import lndMobile from '../lndmobile/LndMobileInjection'; -const { channel } = lndMobile; +const { index, channel } = lndMobile; import BackendUtils from '../utils/BackendUtils'; import Base64Utils from '../utils/Base64Utils'; import { LndMobileEventEmitter } from '../utils/LndMobileUtils'; import { localeString } from '../utils/LocaleUtils'; +import { errorToUserFriendly } from '../utils/ErrorUtils'; export default class LSPStore { @observable public info: any = {}; @observable public zeroConfFee: number | undefined; @observable public feeId: string | undefined; + @observable public pubkey: string; + @observable public getInfoId: string; + @observable public createOrderId: string; + @observable public getOrderId: string; + @observable public loading: boolean = true; @observable public error: boolean = false; @observable public error_msg: string = ''; @observable public showLspSettings: boolean = false; @observable public channelAcceptor: any; + @observable public customMessagesSubscriber: any; + @observable public getInfoData: any = {}; + @observable public createOrderResponse: any = {}; + @observable public getOrderResponse: any = {}; settingsStore: SettingsStore; channelsStore: ChannelsStore; @@ -44,6 +54,7 @@ export default class LSPStore { this.error_msg = ''; this.showLspSettings = false; this.channelAcceptor = undefined; + this.customMessagesSubscriber = undefined; }; @action @@ -51,11 +62,35 @@ export default class LSPStore { this.zeroConfFee = undefined; }; + @action + public resetLSPS1Data = () => { + this.createOrderResponse = {}; + this.getInfoData = {}; + this.loading = true; + this.error = false; + this.error_msg = ''; + }; + getLSPHost = () => this.nodeInfoStore!.nodeInfo.isTestNet ? this.settingsStore.settings.lspTestnet : this.settingsStore.settings.lspMainnet; + getLSPS1Pubkey = () => + this.nodeInfoStore!.nodeInfo.isTestNet + ? this.settingsStore.settings.lsps1PubkeyTestnet + : this.settingsStore.settings.lsps1PubkeyMainnet; + + getLSPS1Host = () => + this.nodeInfoStore!.nodeInfo.isTestNet + ? this.settingsStore.settings.lsps1HostTestnet + : this.settingsStore.settings.lsps1HostMainnet; + + getLSPS1Rest = () => + this.nodeInfoStore!.nodeInfo.isTestNet + ? this.settingsStore.settings.lsps1RestTestnet + : this.settingsStore.settings.lsps1RestMainnet; + @action public getLSPInfo = () => { return new Promise((resolve, reject) => { @@ -179,7 +214,7 @@ export default class LSPStore { await channel.channelAcceptorResponse( channelAcceptRequest.pending_chan_id, !channelAcceptRequest.wants_zero_conf || isZeroConfAllowed, - isZeroConfAllowed + isZeroConfAllowed && channelAcceptRequest.wants_zero_conf ); } catch (error: any) { console.error('handleChannelAcceptorEvent error:', error.message); @@ -270,4 +305,225 @@ export default class LSPStore { }); }); }; + + @action + public sendCustomMessage = ({ + peer, + type, + data + }: { + peer: string; + type: number | null; + data: string; + }) => { + return new Promise((resolve, reject) => { + if (!peer || !type || !data) { + reject('Invalid parameters for custom message.'); + return; + } + + BackendUtils.sendCustomMessage({ peer, type, data }) + .then((response: any) => { + resolve(response); + }) + .catch((error: any) => { + this.error = true; + this.error_msg = 'send message error'; + reject(error); + }); + }); + }; + + @action + public handleCustomMessages = (decoded: any) => { + const peer = Base64Utils.base64ToHex(decoded.peer); + const data = JSON.parse(Base64Utils.base64ToUtf8(decoded.data)); + + console.log('peer', peer); + console.log('data', data); + + if (data.id === this.getInfoId) { + this.getInfoData = data; + this.loading = false; + } else if (data.id === this.createOrderId) { + if (data.error) { + this.error = true; + this.loading = false; + this.error_msg = data?.error?.data?.message; + } else { + this.createOrderResponse = data; + this.loading = false; + } + } else if (data.id === this.getOrderId) { + if (data.error) { + this.error = true; + this.error_msg = data?.error?.message; + } else { + this.getOrderResponse = data; + } + } + }; + + @action + public subscribeCustomMessages = async () => { + if (this.customMessagesSubscriber) return; + let timer = 10000; + const timeoutId = setTimeout(() => { + this.error = true; + this.error_msg = 'Did not receive response from server'; + this.loading = false; + }, timer); + + if (this.settingsStore.implementation === 'embedded-lnd') { + this.customMessagesSubscriber = LndMobileEventEmitter.addListener( + 'SubscribeCustomMessages', + async (event: any) => { + try { + const decoded = index.decodeCustomMessage(event.data); + this.handleCustomMessages(decoded); + clearTimeout(timeoutId); + } catch (error: any) { + console.error( + 'sub custom messages error: ' + error.message + ); + } + } + ); + + await index.subscribeCustomMessages(); + } else { + BackendUtils.subscribeCustomMessages( + (response: any) => { + const decoded = response.result; + this.handleCustomMessages(decoded); + clearTimeout(timeoutId); + }, + (error: any) => { + console.error( + 'sub custom messages error: ' + error.message + ); + } + ); + } + }; + + @action + public getInfoREST = () => { + const endpoint = `${this.getLSPS1Rest()}/api/v1/get_info`; + + console.log('Fetching data from:', endpoint); + + return ReactNativeBlobUtil.fetch('GET', endpoint) + .then((response) => { + if (response.info().status === 200) { + const responseData = JSON.parse(response.data); + this.getInfoData = responseData; + try { + const uri = responseData.uris[0]; + const pubkey = uri.split('@')[0]; + this.pubkey = pubkey; + } catch (e) {} + this.loading = false; + } else { + this.error = true; + this.error_msg = 'Error fetching get_info data'; + this.loading = false; + } + }) + .catch(() => { + this.error = true; + this.error_msg = 'Error fetching get_info data'; + this.loading = false; + }); + }; + + @action + public createOrderREST = (state: any) => { + const data = JSON.stringify({ + lsp_balance_sat: state.lspBalanceSat, + client_balance_sat: state.clientBalanceSat, + required_channel_confirmations: parseInt( + state.requiredChannelConfirmations + ), + funding_confirms_within_blocks: parseInt( + state.confirmsWithinBlocks + ), + channel_expiry_blocks: parseInt(state.channelExpiryBlocks), + token: state.token, + refund_onchain_address: state.refundOnchainAddress, + announce_channel: state.announceChannel, + public_key: this.nodeInfoStore.nodeInfo.nodeId + }); + this.loading = true; + this.error = false; + this.error_msg = ''; + const endpoint = `${this.getLSPS1Rest()}/api/v1/create_order`; + console.log('Sending data to:', endpoint); + + return ReactNativeBlobUtil.fetch( + 'POST', + endpoint, + { + 'Content-Type': 'application/json' + }, + data + ) + .then((response) => { + const responseData = JSON.parse(response.data); + if (responseData.error) { + this.error = true; + this.error_msg = responseData.message; + this.loading = false; + } else { + this.createOrderResponse = responseData; + this.loading = false; + console.log('Response received:', responseData); + } + }) + .catch((error) => { + console.error( + 'Error sending (create_order) custom message:', + error + ); + this.error = true; + this.error_msg = errorToUserFriendly(error); + this.loading = false; + }); + }; + + @action + public getOrderREST(id: string) { + this.loading = true; + const data = JSON.stringify({ + order_id: id + }); + const endpoint = `${this.getLSPS1Rest()}/api/v1/get_order`; + + console.log('Sending data to:', endpoint); + + return ReactNativeBlobUtil.fetch( + 'POST', + endpoint, + { + 'Content-Type': 'application/json' + }, + data + ) + .then((response) => { + const responseData = JSON.parse(response.data); + console.log('Response received:', responseData); + if (responseData.error) { + this.error = true; + this.error_msg = responseData.message; + } else { + this.getOrderResponse = responseData; + } + }) + .catch((error) => { + console.error('Error sending custom message:', error); + this.error = true; + this.error_msg = errorToUserFriendly(error); + this.loading = false; + }); + } } diff --git a/stores/SettingsStore.ts b/stores/SettingsStore.ts index e69e69f87..33c4379cc 100644 --- a/stores/SettingsStore.ts +++ b/stores/SettingsStore.ts @@ -144,6 +144,15 @@ export interface Settings { lspTestnet: string; lspAccessKey: string; requestSimpleTaproot: boolean; + //LSPS1 + lsps1RestMainnet: string; + lsps1RestTestnet: string; + lsps1PubkeyMainnet: string; + lsps1PubkeyTestnet: string; + lsps1HostMainnet: string; + lsps1HostTestnet: string; + lsps1ShowPurchaseButton: boolean; + // Lightning Address lightningAddress: LightningAddressSettings; selectNodeOnStartup: boolean; @@ -893,6 +902,17 @@ export const LNDHUB_AUTH_MODES = [ export const DEFAULT_LSP_MAINNET = 'https://0conf.lnolymp.us'; export const DEFAULT_LSP_TESTNET = 'https://testnet-0conf.lnolymp.us'; +// LSPS1 REST +export const DEFAULT_LSPS1_REST_MAINNET = 'https://lsps1.lnolymp.us'; +export const DEFAULT_LSPS1_REST_TESTNET = 'https://testnet-lsps1.lnolymp.us'; + +export const DEFAULT_LSPS1_PUBKEY_MAINNET = + '031b301307574bbe9b9ac7b79cbe1700e31e544513eae0b5d7497483083f99e581'; +export const DEFAULT_LSPS1_PUBKEY_TESTNET = + '03e84a109cd70e57864274932fc87c5e6434c59ebb8e6e7d28532219ba38f7f6df'; +export const DEFAULT_LSPS1_HOST_MAINNET = '45.79.192.236:9735'; +export const DEFAULT_LSPS1_HOST_TESTNET = '139.144.22.237:9735'; + export const DEFAULT_NOSTR_RELAYS = [ 'wss://nostr.mutinywallet.com', 'wss://relay.damus.io', @@ -1033,6 +1053,14 @@ export default class SettingsStore { lspTestnet: DEFAULT_LSP_TESTNET, lspAccessKey: '', requestSimpleTaproot: false, + //lsps1 + lsps1RestMainnet: DEFAULT_LSPS1_REST_MAINNET, + lsps1RestTestnet: DEFAULT_LSPS1_REST_TESTNET, + lsps1PubkeyMainnet: DEFAULT_LSPS1_PUBKEY_MAINNET, + lsps1PubkeyTestnet: DEFAULT_LSPS1_PUBKEY_TESTNET, + lsps1HostMainnet: DEFAULT_LSPS1_HOST_MAINNET, + lsps1HostTestnet: DEFAULT_LSPS1_HOST_TESTNET, + lsps1ShowPurchaseButton: true, // Lightning Address lightningAddress: { enabled: false, @@ -1337,6 +1365,42 @@ export default class SettingsStore { await EncryptedStorage.setItem(MOD_KEY3, 'true'); } + const MOD_KEY4 = 'lsps1-hosts'; + const mod4 = await EncryptedStorage.getItem(MOD_KEY4); + if (!mod4) { + if (!this.settings?.lsps1HostMainnet) { + this.settings.lsps1HostMainnet = + DEFAULT_LSPS1_HOST_MAINNET; + } + if (!this.settings?.lsps1HostTestnet) { + this.settings.lsps1HostTestnet = + DEFAULT_LSPS1_HOST_TESTNET; + } + if (!this.settings?.lsps1PubkeyMainnet) { + this.settings.lsps1PubkeyMainnet = + DEFAULT_LSPS1_PUBKEY_MAINNET; + } + if (!this.settings?.lsps1PubkeyTestnet) { + this.settings.lsps1PubkeyTestnet = + DEFAULT_LSPS1_PUBKEY_TESTNET; + } + if (!this.settings?.lsps1RestMainnet) { + this.settings.lsps1RestMainnet = + DEFAULT_LSPS1_REST_MAINNET; + } + if (!this.settings?.lsps1RestTestnet) { + this.settings.lsps1RestTestnet = + DEFAULT_LSPS1_REST_TESTNET; + } + + if (!this.settings?.lsps1ShowPurchaseButton) { + this.settings.lsps1ShowPurchaseButton = true; + } + + this.setSettings(JSON.stringify(this.settings)); + await EncryptedStorage.setItem(MOD_KEY4, 'true'); + } + // migrate old POS squareEnabled setting to posEnabled if (this.settings?.pos?.squareEnabled) { this.settings.pos.posEnabled = PosEnabled.Square; diff --git a/utils/BackendUtils.ts b/utils/BackendUtils.ts index 67873c1e3..c7c0c566d 100644 --- a/utils/BackendUtils.ts +++ b/utils/BackendUtils.ts @@ -69,6 +69,10 @@ class BackendUtils { getLightningBalance = (...args: any[]) => this.call('getLightningBalance', args); sendCoins = (...args: any[]) => this.call('sendCoins', args); + sendCustomMessage = (...args: any[]) => + this.call('sendCustomMessage', args); + subscribeCustomMessages = (...args: any[]) => + this.call('subscribeCustomMessages', args); getMyNodeInfo = (...args: any[]) => this.call('getMyNodeInfo', args); getNetworkInfo = (...args: any[]) => this.call('getNetworkInfo', args); getInvoices = (...args: any[]) => this.call('getInvoices', args); @@ -115,6 +119,10 @@ class BackendUtils { this.call('subscribeTransactions', args); initChanAcceptor = (...args: any[]) => this.call('initChanAcceptor', args); + //cln + supportsLSPS1customMessage = () => this.call('supportsLSPS1customMessage'); + supportsLSPS1rest = () => this.call('supportsLSPS1rest'); + // lndhub login = (...args: any[]) => this.call('login', args); diff --git a/views/Channels/ChannelsPane.tsx b/views/Channels/ChannelsPane.tsx index f475da866..46748fd32 100644 --- a/views/Channels/ChannelsPane.tsx +++ b/views/Channels/ChannelsPane.tsx @@ -1,5 +1,13 @@ -import * as React from 'react'; -import { FlatList, View, TouchableHighlight } from 'react-native'; +import React, { useEffect, useRef, useState } from 'react'; +import { + Animated, + FlatList, + View, + StyleSheet, + Text, + TouchableHighlight, + TouchableOpacity +} from 'react-native'; import { inject, observer } from 'mobx-react'; import { duration } from 'moment'; @@ -19,6 +27,8 @@ import { localeString } from '../../utils/LocaleUtils'; import Channel from '../../models/Channel'; +import NavigationService from '../../NavigationService'; + // TODO: does this belong in the model? Or can it be computed from the model? export enum Status { Good = 'Good', @@ -35,6 +45,45 @@ interface ChannelsProps { SettingsStore?: SettingsStore; } +const ColorChangingButton = () => { + const [forward, setForward] = useState(true); + const animation = useRef(new Animated.Value(0)).current; + + useEffect(() => { + const interval = setInterval(() => { + // Toggle animation direction + setForward((prev) => !prev); + }, 5000); // Change color gradient every 6 seconds + + return () => clearInterval(interval); // Cleanup interval on component unmount + }, []); + + useEffect(() => { + // Animate from 0 to 1 or from 1 to 0 based on 'forward' value + Animated.timing(animation, { + toValue: forward ? 1 : 0, + duration: 4500, + useNativeDriver: true + }).start(); + }, [forward]); + + const backgroundColor: any = animation.interpolate({ + inputRange: [0, 1], + outputRange: ['rgb(180, 26, 20)', 'rgb(255, 169, 0)'] // Red to Gold gradient + }); + + return ( + NavigationService.navigate('LSPS1')} + style={[styles.button, { backgroundColor }]} + > + + {localeString('views.Wallet.Channels.purchaseInbound')} + + + ); +}; + @inject('ChannelsStore', 'SettingsStore') @observer export default class ChannelsPane extends React.PureComponent { @@ -139,8 +188,9 @@ export default class ChannelsPane extends React.PureComponent { channelsType } = ChannelsStore!; - const lurkerMode: boolean = - SettingsStore!.settings?.privacy?.lurkerMode || false; + const { settings } = SettingsStore!; + + const lurkerMode: boolean = settings?.privacy?.lurkerMode || false; let headerString; let channelsData: Channel[]; @@ -183,6 +233,11 @@ export default class ChannelsPane extends React.PureComponent { totalOffline={totalOffline} lurkerMode={lurkerMode} /> + {settings?.lsps1ShowPurchaseButton && + (BackendUtils.supportsLSPS1customMessage() || + BackendUtils.supportsLSPS1rest()) && ( + + )} {showSearch && } {loading ? ( @@ -204,3 +259,17 @@ export default class ChannelsPane extends React.PureComponent { ); } } + +const styles = StyleSheet.create({ + button: { + padding: 10, + borderRadius: 5, + margin: 10 + }, + buttonText: { + fontFamily: 'PPNeueMontreal-Book', + color: 'white', + fontWeight: 'bold', + textAlign: 'center' + } +}); diff --git a/views/Settings/ChannelsSettings.tsx b/views/Settings/ChannelsSettings.tsx index 84930f676..0933e4e29 100644 --- a/views/Settings/ChannelsSettings.tsx +++ b/views/Settings/ChannelsSettings.tsx @@ -23,6 +23,7 @@ interface ChannelsSettingsState { privateChannel: boolean; scidAlias: boolean; simpleTaprootChannel: boolean; + lsps1ShowPurchaseButton: boolean; } @inject('SettingsStore') @@ -35,7 +36,8 @@ export default class ChannelsSettings extends React.Component< min_confs: 1, privateChannel: true, scidAlias: true, - simpleTaprootChannel: false + simpleTaprootChannel: false, + lsps1ShowPurchaseButton: true }; async UNSAFE_componentWillMount() { @@ -56,7 +58,11 @@ export default class ChannelsSettings extends React.Component< simpleTaprootChannel: settings?.channels?.simpleTaprootChannel !== null ? settings.channels.simpleTaprootChannel - : false + : false, + lsps1ShowPurchaseButton: + settings?.lsps1ShowPurchaseButton !== null + ? settings.lsps1ShowPurchaseButton + : true }); } @@ -71,8 +77,13 @@ export default class ChannelsSettings extends React.Component< render() { const { navigation, SettingsStore } = this.props; - const { min_confs, privateChannel, scidAlias, simpleTaprootChannel } = - this.state; + const { + min_confs, + privateChannel, + scidAlias, + simpleTaprootChannel, + lsps1ShowPurchaseButton + } = this.state; const { updateSettings }: any = SettingsStore; return ( @@ -220,6 +231,36 @@ export default class ChannelsSettings extends React.Component< /> )} + + {(BackendUtils.supportsLSPS1customMessage() || + BackendUtils.supportsLSPS1rest()) && ( + <> + + {localeString( + 'views.Settings.Channels.lsps1ShowPurchaseButton' + )} + + { + this.setState({ + lsps1ShowPurchaseButton: + !lsps1ShowPurchaseButton + }); + + await updateSettings({ + lsps1ShowPurchaseButton: + !lsps1ShowPurchaseButton + }); + }} + /> + + )} ); diff --git a/views/Settings/LSPS1/Order.tsx b/views/Settings/LSPS1/Order.tsx new file mode 100644 index 000000000..1bff355bb --- /dev/null +++ b/views/Settings/LSPS1/Order.tsx @@ -0,0 +1,515 @@ +import React from 'react'; +import { View, ScrollView } from 'react-native'; +import EncryptedStorage from 'react-native-encrypted-storage'; +import { v4 as uuidv4 } from 'uuid'; +import { inject, observer } from 'mobx-react'; +import moment from 'moment'; + +import Screen from '../../../components/Screen'; +import Header from '../../../components/Header'; +import KeyValue from '../../../components/KeyValue'; +import Button from '../../../components/Button'; +import { WarningMessage } from '../../../components/SuccessErrorMessage'; +import LoadingIndicator from '../../../components/LoadingIndicator'; + +import { themeColor } from '../../../utils/ThemeUtils'; +import BackendUtils from '../../../utils/BackendUtils'; +import UrlUtils from '../../../utils/UrlUtils'; +import { localeString } from '../../../utils/LocaleUtils'; + +import LSPStore from '../../../stores/LSPStore'; +import SettingsStore from '../../../stores/SettingsStore'; +import InvoicesStore from '../../../stores/InvoicesStore'; +import NodeInfoStore from '../../../stores/NodeInfoStore'; +import Amount from '../../../components/Amount'; + +interface OrderProps { + navigation: any; + LSPStore: LSPStore; + SettingsStore: SettingsStore; + InvoicesStore: InvoicesStore; + NodeInfoStore: NodeInfoStore; +} + +interface OrdersState { + order: any; + fetchOldOrder: boolean; +} + +@inject('LSPStore', 'SettingsStore', 'InvoicesStore', 'NodeInfoStore') +@observer +export default class Orders extends React.Component { + constructor(props: OrderProps) { + super(props); + this.state = { + order: null, + fetchOldOrder: false + }; + } + + encodeMesage = (n: any) => Buffer.from(JSON.stringify(n)).toString('hex'); + + async componentDidMount() { + const { LSPStore } = this.props; + const orderId = this.props.navigation.getParam('orderId', null); + + BackendUtils.supportsLSPS1rest() + ? LSPStore.getOrderREST(orderId) + : this.lsps1_getorder(orderId); + + setTimeout(() => { + if (LSPStore.error && LSPStore.error_msg !== '') { + this.retrieveOrderFromStorage(orderId); + } else { + const getOrderData = LSPStore.getOrderResponse; + this.setState({ order: getOrderData, fetchOldOrder: false }); + const result = getOrderData?.result || getOrderData; + if ( + result?.order_state === 'COMPLETED' || + result?.order_state === 'FAILED' + ) { + this.updateOrderInStorage(getOrderData); + } + LSPStore.loading = false; + console.log('Latest Order state fetched!'); + } + }, 3000); + } + + updateOrderInStorage(order) { + console.log('Updating order in encrypted storage...'); + EncryptedStorage.getItem('orderResponses') + .then((responseArrayString) => { + if (responseArrayString) { + let responseArray = JSON.parse(responseArrayString); + // Find the index of the order to be updated + const index = responseArray.findIndex((response) => { + const decodedResponse = JSON.parse(response); + const result = + decodedResponse?.result || decodedResponse; + const currentOrderResult = order?.result || order; + return result.order_id === currentOrderResult.order_id; + }); + if (index !== -1) { + // Update the order + responseArray[index] = JSON.stringify(order); + // Save the updated order array back to encrypted storage + EncryptedStorage.setItem( + 'orderResponses', + JSON.stringify(responseArray) + ).then(() => { + console.log('Order updated in encrypted storage!'); + }); + } else { + console.log('Order not found in encrypted storage.'); + } + } else { + console.log( + 'No saved responses found in encrypted storage.' + ); + } + }) + .catch((error) => { + console.error( + 'Error retrieving saved responses from encrypted storage:', + error + ); + }); + } + + retrieveOrderFromStorage(id: string) { + console.log('Latest order state not found, retrieving from storage...'); + EncryptedStorage.getItem('orderResponses') + .then((responseArrayString) => { + if (responseArrayString) { + const responseArray = JSON.parse(responseArrayString); + const order = responseArray.find((response) => { + const decodedResponse = JSON.parse(response); + const result = + decodedResponse?.result || decodedResponse; + return result.order_id === id; + }); + if (order) { + const parsedOrder = JSON.parse(order); + this.setState({ + order: parsedOrder, + fetchOldOrder: true + }); + this.props.LSPStore.loading = false; + console.log('Old Order state fetched!'); + } else { + console.log('Order not found in encrypted storage.'); + } + } else { + console.log( + 'No saved responses found in encrypted storage.' + ); + } + }) + .catch((error) => { + console.error( + 'Error retrieving saved responses from encrypted storage:', + error + ); + }); + } + + lsps1_getorder(orderId: string) { + const { LSPStore } = this.props; + LSPStore.loading = true; + const peer = + '03e84a109cd70e57864274932fc87c5e6434c59ebb8e6e7d28532219ba38f7f6df@139.144.22.237:9735'; + const [node_pubkey_string] = peer.split('@'); + const type = 37913; + const id = uuidv4(); + LSPStore.getOrderId = id; + const data = this.encodeMesage({ + jsonrpc: '2.0', + method: 'lsps1.get_order', + params: { + order_id: orderId + }, + id: LSPStore.getOrderId + }); + + this.props.LSPStore.sendCustomMessage({ + peer: node_pubkey_string, + type, + data + }) + .then((response) => { + console.log('Custom message sent:', response); + }) + .catch((error) => { + console.error('Error sending custom message:', error); + }); + } + + render() { + const { navigation, LSPStore, InvoicesStore, NodeInfoStore } = + this.props; + const { testnet } = NodeInfoStore; + const { order, fetchOldOrder } = this.state; + const result = order?.result || order; + const payment = result?.payment; + const channel = result?.channel; + + return ( + +
{ + LSPStore.getOrderResponse = {}; + LSPStore.error = false; + LSPStore.error_msg = ''; + this.setState({ fetchOldOrder: false }); + }} + navigation={navigation} + /> + {LSPStore.loading ? ( + + ) : ( + + {fetchOldOrder && ( + + + + )} + {order && + Object.keys(order).length > 0 && + result && + payment && ( + + {result.announce_channel && ( + + )} + {result.channel_expiry_blocks && ( + + )} + {result.client_balance_sat && ( + + } + /> + )} + {result.funding_confirms_within_blocks && ( + + )} + {result.created_at && ( + + )} + {result.expires_at && ( + + )} + {result.lsp_balance_sat && ( + + } + /> + )} + {result.order_id && ( + + )} + {result.order_state && ( + + )} + + {payment.fee_total_sat && ( + + } + /> + )} + {(payment.lightning_invoice || + payment.bolt11_invoice) && ( + + )} + {payment.state && ( + + )} + {payment.min_fee_for_0conf && ( + + )} + {payment.min_onchain_payment_confirmations && ( + + )} + {payment.onchain_address && ( + + )} + {payment.onchain_payment && ( + + )} + {payment.order_total_sat && ( + + } + /> + )} + {channel && ( + <> + + + + + UrlUtils.goToBlockExplorerTXID( + channel?.funding_outpoint, + testnet + ) + } + /> + + )} + {result?.order_state !== 'COMPLETED' && ( +