From 7774ba4b6388f018799fc6c44a5fb273c48fb3de Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Tue, 20 Aug 2024 11:05:51 +0200 Subject: [PATCH] POC of NWC integration --- src/components/NwcIcon.tsx | 1 + src/components/index.ts | 1 + src/models/MintsStore.ts | 5 - src/models/NwcStore.ts | 552 ++++++++++++++++++++++++++++ src/models/ProofsStore.ts | 3 - src/models/RootStore.ts | 4 +- src/models/WalletStore.ts | 63 ++-- src/navigation/TabsNavigator.tsx | 5 +- src/screens/NwcScreen.tsx | 441 ++++++++++++++++++++++ src/screens/SettingsScreen.tsx | 42 ++- src/screens/TransferScreen.tsx | 5 + src/screens/UpdateScreen.tsx | 2 +- src/screens/Wallet/QRCode.tsx | 2 +- src/screens/index.ts | 1 + src/services/nostrService.ts | 17 +- src/services/notificationService.ts | 15 + src/services/wallet/sendTask.ts | 15 +- src/services/wallet/transferTask.ts | 20 +- src/services/walletService.ts | 2 +- src/theme/colors.ts | 1 + src/utils/eventEmitter.ts | 10 +- 21 files changed, 1141 insertions(+), 66 deletions(-) create mode 100644 src/components/NwcIcon.tsx create mode 100644 src/models/NwcStore.ts create mode 100644 src/screens/NwcScreen.tsx diff --git a/src/components/NwcIcon.tsx b/src/components/NwcIcon.tsx new file mode 100644 index 00000000..5577ef37 --- /dev/null +++ b/src/components/NwcIcon.tsx @@ -0,0 +1 @@ +export const NwcIcon = `` diff --git a/src/components/index.ts b/src/components/index.ts index 5156d82e..0f72fa6b 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -19,3 +19,4 @@ export * from "./UsdIcon" export * from "./EurIcon" export * from "./MintIcon" export * from "./HeaderBg" +export * from "./NwcIcon" diff --git a/src/models/MintsStore.ts b/src/models/MintsStore.ts index e8bc3074..d6e7b9e3 100644 --- a/src/models/MintsStore.ts +++ b/src/models/MintsStore.ts @@ -108,11 +108,6 @@ export const MintsStoreModel = types } log.trace('[addMint] start') - - // create default wallet instance then download and cache up to date mint keys in that instance - // const {walletStore} = getRoot(self) // this somehow makes typescript + mobx complain - // const keysets: CashuMintKeyset[] = yield walletStore.getMintKeysets(mintUrl) - // const keys: CashuMintKeys[] = yield walletStore.getMintKeys(mintUrl) const newMint = new CashuMint(mintUrl) // get fresh keysets diff --git a/src/models/NwcStore.ts b/src/models/NwcStore.ts new file mode 100644 index 00000000..be220e09 --- /dev/null +++ b/src/models/NwcStore.ts @@ -0,0 +1,552 @@ +import { + Instance, + SnapshotOut, + types, + flow, + destroy, + isStateTreeNode, + detach, +} from 'mobx-state-tree' +import {withSetPropAction} from './helpers/withSetPropAction' +import {log} from '../services/logService' +import EventEmitter from '../utils/eventEmitter' +import { getRootStore } from './helpers/getRootStore' +import { KeyChain, KeyPair, NostrClient, NostrEvent, NostrUnsignedEvent, TransactionTaskResult, WalletTask } from '../services' +import AppError, { Err } from '../utils/AppError' +import { LightningUtils } from '../services/lightning/lightningUtils' +import { addSeconds } from 'date-fns/addSeconds' +import { TransactionStatus } from './Transaction' +import { MeltQuoteResponse } from '@cashu/cashu-ts' +import { WalletStore } from './WalletStore' +import { Proofs } from './ProofsStore' +import { isSameDay } from 'date-fns/isSameDay' + +type NwcError = { + result_type: string, + error: { + code: string, + message: string + } +} + +const NwcKind = { + info: 13194, + request: 23194, + response: 23195 +} + +type NwcRequest = { + method: string, + params: any +} + +type NwcResponse = { + result_type: string, + result: any +} + +export const NwcConnectionModel = types.model('NwcConnection', { + name: types.string, + connectionPubkey: types.string, + connectionSecret: types.identifier, + meltQuoteInFlight: types.maybe(types.frozen()), + eventInFlight: types.maybe(types.frozen()), + dailyLimit: types.optional(types.number, 0), + remainingDailyLimit: types.optional(types.number, 0), + currentDay: types.optional(types.Date, new Date()), +}) +.actions(withSetPropAction) +.views(self => ({ + get walletPubkey(): string { + const rootStore = getRootStore(self) + const {walletProfileStore} = rootStore + return walletProfileStore.pubkey + }, + get relays(): string[] { + /* const publicRelays = NostrClient.getDefaultRelays() + const minibitsRelays = NostrClient.getMinibitsRelays() + + return [...publicRelays, ...minibitsRelays]*/ + return NostrClient.getMinibitsRelays() + }, + get supportedMethods() { + return ['pay_invoice', 'get_balance', 'get_info', 'list_transactions'] + } +})) +.views(self => ({ + get connectionString(): string { + return `nostr+walletconnect://${self.walletPubkey}?relay=${self.relays.join('&relay=')}&secret=${self.connectionSecret}` + }, +})) +.actions(self => ({ + getWalletKeyPair: flow(function* getWalletKeyPair() { + const keyPair: KeyPair = yield NostrClient.getOrCreateKeyPair() + return keyPair + }), + getWalletStore (): WalletStore { + const rootStore = getRootStore(self) + const {walletStore} = rootStore + return walletStore + }, + getProofsStore (): Proofs { + const rootStore = getRootStore(self) + const {proofsStore} = rootStore + return proofsStore + }, + setRemainingDailyLimit(limit: number) { + self.remainingDailyLimit = limit + }, + setCurrentDay() { + self.currentDay = new Date() + }, + setEventInFlight(event: NostrEvent) { + self.eventInFlight = event + }, + resetEventInFlight() { + self.eventInFlight = undefined + }, + setMeltQuoteInFlight(meltQuoteInFlight: MeltQuoteResponse) { + self.meltQuoteInFlight = meltQuoteInFlight + }, + resetMeltQuoteInFlight() { + self.meltQuoteInFlight = undefined + }, + /*setListenerExists(exists: boolean) { + self.listenerExists = exists + }*/ +})) +.actions(self => ({ + sendResponse: flow(function* sendResponse(nwcResponse: NwcResponse | NwcError) { + log.trace('[Nwc.sendResponse] start', {nwcResponse}) + + if(!self.eventInFlight) { + throw new AppError(Err.VALIDATION_ERROR, 'Missing eventInFlight', {caller: 'Nwc.sendResponse'}) + } + + // eventInFlight.pubkey should = connectionPubkey + const encryptedContent = yield NostrClient.encryptNip04( + self.eventInFlight.pubkey, + JSON.stringify(nwcResponse) + ) + + const responseEvent: NostrUnsignedEvent = { + pubkey: self.walletPubkey, + kind: NwcKind.response, + tags: [["p", self.eventInFlight.pubkey], ["e", self.eventInFlight.id]], + content: encryptedContent, + created_at: Math.floor(Date.now() / 1000) + } + + self.resetEventInFlight() + self.resetMeltQuoteInFlight() + + const publishedEvent: Event | undefined = yield NostrClient.publish( + responseEvent, + self.relays + ) + + return publishedEvent + }), +})) +.actions(self => ({ + handleTransferTaskResult: flow(function* handleTransferTaskResult(result: TransactionTaskResult) { + log.debug('[NWC.handleTransferTaskResult] Got transfer task result', {connection: self.name, meltQuote: result.meltQuote.quote}) + + let nwcResponse: NwcResponse | NwcError + + if(result.transaction?.status === TransactionStatus.COMPLETED) { + const updatedLimit = self.remainingDailyLimit - + result.transaction.amount + + result.transaction.fee + + nwcResponse = { + result_type: 'pay_invoice', + result: { + preimage: result.preimage, + } + } as NwcResponse + + self.setRemainingDailyLimit(updatedLimit) + + } else { + nwcResponse = { + result_type: 'pay_invoice', + error: { code: 'INTERNAL', message: result.message} + } as NwcError + } + + yield self.sendResponse(nwcResponse) + }) +})) +.actions(self => ({ + + handleGetInfo (nwcRequest: NwcRequest): NwcResponse { + const nwcResponse = { + result_type: nwcRequest.method, + result: { + alias: 'Minibits', + color: '#2372F5', + pubkey: self.walletPubkey, + network: 'mainnet', + block_height: 1, + block_hash: 'hash', + methods: self.supportedMethods + } + } + + return nwcResponse + }, + handleGetBalance (nwcRequest: NwcRequest): NwcResponse { + const rootStore = getRootStore(self) + const {proofsStore} = rootStore + + const balance = proofsStore.getMintBalanceWithMaxBalance('sat')?.balances.sat + let balanceMsat = 0 + + if(balance && balance > 0) { + balanceMsat = balance * 1000 + } else { + balanceMsat = 0 + } + + const nwcResponse = { + result_type: nwcRequest.method, + result: { + balance: balanceMsat + } + } + + return nwcResponse + }, + handlePayInvoice: flow(function* handlePayInvoice(nwcRequest: NwcRequest) { + log.trace('[Nwc.handlePayInvoice] start') + const encoded = nwcRequest.params.invoice + const walletStore = self.getWalletStore() + const proofsStore = self.getProofsStore() + + try { + const invoice = LightningUtils.decodeInvoice(encoded) + const {amount: amountToPay, expiry, description, timestamp} = LightningUtils.getInvoiceData(invoice) + const invoiceExpiry = addSeconds(new Date(timestamp as number * 1000), expiry as number) + + const mintBalance = proofsStore.getMintBalanceWithMaxBalance('sat') + const availableBalanceSat = mintBalance?.balances.sat || 0 + + if(!mintBalance || availableBalanceSat < amountToPay) { // decoded amount is in sat + const message = `Insufficient balance to pay this invoice` + return { + result_type: nwcRequest.method, + error: { code: 'INSUFFICIENT_BALANCE', message} + } as NwcError + } + + // melt quote + const meltQuote: MeltQuoteResponse = yield walletStore.createLightningMeltQuote( + mintBalance.mintUrl, + 'sat', + encoded, + ) + + self.setMeltQuoteInFlight(meltQuote) + const totalAmountToPay = meltQuote.amount + meltQuote.fee_reserve + + // reset daily limit if day changed while keeping live connection + if(!isSameDay(self.currentDay, new Date())) { + self.setRemainingDailyLimit(self.dailyLimit) + self.setCurrentDay() + } + + if(availableBalanceSat < totalAmountToPay) { + const message = `Insufficient balance to pay this invoice.` + return { + result_type: nwcRequest.method, + error: { code: 'INSUFFICIENT_BALANCE', message} + } as NwcError + } + + if(totalAmountToPay > self.remainingDailyLimit) { + const message = `Your remaining daily limit of ${self.remainingDailyLimit} SATS would be exceeded with this payment.` + return { + result_type: nwcRequest.method, + error: { code: 'QUOTA_EXCEEDED', message} + } as NwcError + } + + // Jachyme, hod ho do stroje! + WalletTask.transfer( + mintBalance, + amountToPay, + 'sat', + meltQuote, + description || '', + invoiceExpiry as Date, + encoded, + ) + + } catch (e: any) { + const message = `Could not pay provided invoice: ${e.message}` + log.error(`[NwcConnection.handlePayInvoice] ${message}`) + + self.resetEventInFlight() + self.resetMeltQuoteInFlight() + + return { + result_type: nwcRequest.method, + error: { code: 'INTERNAL', message} + } as NwcError + } + }) +})) +.actions(self => ({ + handleRequest: flow(function* handleRequest(requestEvent: NostrEvent) { + // decrypt with main wallet nostr privKey. Event.pubkey should then = connectionPubkey + const decryptedContent = yield NostrClient.decryptNip04(requestEvent.pubkey, requestEvent.content) + + const nwcRequest: NwcRequest = JSON.parse(decryptedContent) + let nwcResponse: NwcResponse | NwcError + + log.trace('[Nwc.handleRequest] request event', {requestEvent}) + log.trace('[Nwc.handleRequest] request method', {method: nwcRequest.method}) + + switch (nwcRequest.method) { + case 'get_info': + nwcResponse = self.handleGetInfo(nwcRequest) + break + case 'get_balance': + nwcResponse = self.handleGetBalance(nwcRequest) + break + case 'pay_invoice': + // payInvoice is long running and async, make sure we do not overwrite event + // by a new one + if (self.eventInFlight) { + nwcResponse = { + result_type: nwcRequest.method, + error: { code: 'INTERNAL', message: 'Another payment is in flight.' } + } as NwcError + + break + } + + self.setEventInFlight(requestEvent) + // only early errors are immediately returned, transfer result is handled via event handler + nwcResponse = yield self.handlePayInvoice(nwcRequest) as Promise + + // no early error, transfer initiated, exit and create response in transfer result event handler + if(!nwcResponse) { + return + } + break + default: + const message = `NWC method ${nwcRequest.method} is unknown or not yet supported.` + nwcResponse = { + result_type: nwcRequest.method, + error: { code: 'NOT_IMPLEMENTED', message} + } as NwcError + + log.error(message, {nwcRequest}) + } + + yield self.sendResponse(nwcResponse) + + }), +})) +.actions(self => ({ + receiveNwcEvents: flow(function* receiveNwcEvents() { + log.trace('[receiveNwcEvents] start for connection', {name: self.name}) + self.resetEventInFlight() + self.resetMeltQuoteInFlight() + + try { + // reset daily limit if day changed + if(!isSameDay(self.currentDay, new Date())) { + self.setRemainingDailyLimit(self.dailyLimit) + self.setCurrentDay() + } + + const since = Math.floor(Date.now() / 1000) + + const filters = [{ + kinds: [NwcKind.request], + authors: [self.connectionPubkey], + "#p": [self.walletPubkey], + since + }] + + const pool = NostrClient.getRelayPool() + + const sub = pool.sub(self.relays , filters) + const relaysConnections = pool._conn + const rootStore = getRootStore(self) + const {relaysStore} = rootStore + + // update single relay instances status + for (const url in relaysConnections) { + if (relaysConnections.hasOwnProperty(url)) { + const relay = relaysConnections[url] + + relay.on('error', (error: string) => { + const relayInstance = relaysStore.findByUrl(relay.url) + relayInstance?.setStatus(relay.status) + relayInstance?.setError(relay.error) + }) + + relay.on('connect', () => { + const relayInstance = relaysStore.findByUrl(relay.url) + relayInstance?.setStatus(relay.status) + }) + + relay.on('disconnect', () => { + const relayInstance = relaysStore.findByUrl(relay.url) + relayInstance?.setStatus(relay.status) + }) + } + } + + let eventsBatch: NostrEvent[] = [] + + sub.on('event', async (event: NostrEvent) => { + if (event.kind != NwcKind.request) { + return + } + + if(self.eventInFlight && self.eventInFlight.id === event.id) { + log.warn( + Err.ALREADY_EXISTS_ERROR, + 'Same NWC Event is being already processed, skipping...', + {id: event.id, created_at: event.created_at, caller: 'receiveNwcEvents'} + ) + return + } + + eventsBatch.push(event) + await self.handleRequest(event) + }) + + sub.on('eose', async () => { + log.trace('[receiveNwcEvents]', `Eose: Got ${eventsBatch.length} NWC events`) + eventsBatch = [] + }) + + } catch (e: any) { + log.error(e.name, e.message) + return + } + }) +})) + + + + + +export const NwcStoreModel = types + .model('NwcStore', { + nwcConnections: types.array(NwcConnectionModel), + }) + .views(self => ({ + findByName: (name: string) => { + const c = self.nwcConnections.find(c => c.name === name) + return c ? c : undefined + }, + findBySecret: (secret: string) => { + const c = self.nwcConnections.find(c => c.connectionSecret === secret) + return c ? c : undefined + }, + alreadyExists: (name: string) => { + return self.nwcConnections.some(c => c.name === name) + }, + get all() { + return self.nwcConnections + }, + get supportedMethods() { + return ['pay_invoice', 'get_balance', 'get_info', 'list_transactions'] + } + })) + .actions(self => ({ + handleTransferTaskResult: flow(function* handleTransferTaskResult(result: TransactionTaskResult) { + const connectionWithTransfer = + self.nwcConnections.find(c => c.meltQuoteInFlight?.quote === result.meltQuote.quote) + + if(connectionWithTransfer) { + yield connectionWithTransfer.handleTransferTaskResult(result) + } else { + log.trace('Not an NWC transfer, skipping...') + } + }), + })) + .actions(self => ({ + addConnection: flow(function* addConnection(name: string, dailyLimit: number) { + if(self.findByName(name) !== undefined) { + throw new AppError(Err.VALIDATION_ERROR, 'Connection with this name already exists') + } + + const keyPair = KeyChain.generateNostrKeyPair() + + const newConnection = NwcConnectionModel.create({ + name, + connectionSecret: keyPair.privateKey, + connectionPubkey: keyPair.publicKey, + dailyLimit, + remainingDailyLimit: dailyLimit, + }) + + self.nwcConnections.push(newConnection) + + const filters = [{ + kinds: [NwcKind.info], + authors: [newConnection.walletPubkey], + }] + + const existingInfoEvent = yield NostrClient.getEvent(newConnection.relays, filters) + + if(!existingInfoEvent) { + // publish info replacable event // seems to be a relict replaced by get_info request? + const infoEvent: NostrUnsignedEvent = { + kind: NwcKind.info, + pubkey: newConnection.walletPubkey, + tags: [], + content: self.supportedMethods.join(' '), + created_at: Math.floor(Date.now() / 1000) + } + + NostrClient.publish( + infoEvent, + newConnection.relays + ) + } + }), + removeConnection(connectionToRemove: NwcConnection) { + let connInstance: NwcConnection | undefined + + if (isStateTreeNode(connectionToRemove)) { + connInstance = connectionToRemove + } else { + connInstance = self.findByName((connectionToRemove as NwcConnection).name) + } + + if (connInstance) { + detach(connInstance) // needed + destroy(connInstance) + log.debug('[remove]', 'Connection removed from NwcStore') + } + }, + receiveNwcEvents() { + for (const conn of self.nwcConnections) { + conn.receiveNwcEvents() + } + + EventEmitter.on('ev_transferTask_result', self.handleTransferTaskResult) + } + })) + .views(self => ({ + get all() { + return self.nwcConnections.slice() + } + })) + + +export interface NwcConnection extends Instance {} + +export interface NwcStore + extends Instance {} +export interface NwcStoreSnapshot + extends SnapshotOut {} diff --git a/src/models/ProofsStore.ts b/src/models/ProofsStore.ts index 05cee789..16f48176 100644 --- a/src/models/ProofsStore.ts +++ b/src/models/ProofsStore.ts @@ -371,9 +371,6 @@ export const ProofsStoreModel = types }, getMintBalanceWithMaxBalance: (unit: MintUnit) => { const balances = self.getBalances().mintBalances - - log.debug('[getMintBalanceWithMaxBalance]', {balances}) - let maxBalance = null; let maxAmount = -Infinity; diff --git a/src/models/RootStore.ts b/src/models/RootStore.ts index b803f1ab..c04148bb 100644 --- a/src/models/RootStore.ts +++ b/src/models/RootStore.ts @@ -8,6 +8,7 @@ import {PaymentRequestsStoreModel} from './PaymentRequestsStore' import {ProofsStoreModel} from './ProofsStore' import {RelaysStoreModel} from './RelaysStore' import {WalletStoreModel} from './WalletStore' +import {NwcStoreModel} from './NwcStore' import { log } from '../services' export const rootStoreModelVersion = 23 // Update this if model changes require migrations defined in setupRootStore.ts @@ -26,7 +27,8 @@ export const RootStoreModel = types paymentRequestsStore: types.optional(PaymentRequestsStoreModel, {}), proofsStore: types.optional(ProofsStoreModel, {}), relaysStore: types.optional(RelaysStoreModel, {}), - walletStore: types.optional(WalletStoreModel, {}), // not persisted + walletStore: types.optional(WalletStoreModel, {}), // not persisted + nwcStore: types.optional(NwcStoreModel, {}), version: types.optional(types.number, rootStoreModelVersion), }) .actions(self => ({ diff --git a/src/models/WalletStore.ts b/src/models/WalletStore.ts index 5850be52..9cdf2c38 100644 --- a/src/models/WalletStore.ts +++ b/src/models/WalletStore.ts @@ -21,6 +21,7 @@ import { CashuUtils, ProofV3, TokenV3 } from '../services/cashu/cashuUtils' import { Proof } from './Proof' import { isObj } from '@cashu/cashu-ts/src/utils' import { Mint } from './Mint' +import { getRootStore } from './helpers/getRootStore' /* Not persisted, in-memory only model of the cashu-ts wallet instances and seed. @@ -36,6 +37,37 @@ export const WalletStoreModel = types mnemonicPhrase: types.maybe(types.string), seedBase64: types.maybe(types.string), }) + .views(self => ({ + getMintModelInstance(mintUrl: string) : Mint | undefined { + const mintsStore = getRootStore(self).mintsStore + return mintsStore.findByUrl(mintUrl) as Mint + }, + getOptimalKeyset(mintInstance: Mint, unit: MintUnit) { + const optimalKeyset: MintKeyset =mintInstance.keysets! + .filter((k: MintKeyset) => k.unit === unit && k.active) + .sort((a: MintKeyset, b: MintKeyset) => { + // Prioritize keysets that start with '00' + const aStartsWith00 = a.id.startsWith('00') ? 1 : 0; + const bStartsWith00 = b.id.startsWith('00') ? 1 : 0; + + if (aStartsWith00 !== bStartsWith00) { + return bStartsWith00 - aStartsWith00; + } + + // If both start with '00' or neither do, sort by input_fee_ppk + return (a.input_fee_ppk ?? 0) - (b.input_fee_ppk ?? 0); + })[0] + + if(!optimalKeyset) { + throw new AppError(Err.VALIDATION_ERROR, 'Wallet has not any active keyset for the selected unit.', { + mintUrl: mintInstance.mintUrl, + unit + }) + } + + return optimalKeyset + }, + })) .actions(self => ({ getMnemonic: flow(function* getMnemonic() { if (self.mnemonicPhrase) { @@ -67,36 +99,7 @@ export const WalletStoreModel = types self.seedBase64 = Buffer.from(seed).toString('base64') return seed }), - getMintModelInstance(mintUrl: string) { - const mintsStore = getRoot(self).mintsStore - return mintsStore.findByUrl(mintUrl) as Mint - }, - getOptimalKeyset(mintInstance: Mint, unit: MintUnit) { - const optimalKeyset: MintKeyset =mintInstance.keysets! - .filter((k: MintKeyset) => k.unit === unit && k.active) - .sort((a: MintKeyset, b: MintKeyset) => { - // Prioritize keysets that start with '00' - const aStartsWith00 = a.id.startsWith('00') ? 1 : 0; - const bStartsWith00 = b.id.startsWith('00') ? 1 : 0; - - if (aStartsWith00 !== bStartsWith00) { - return bStartsWith00 - aStartsWith00; - } - - // If both start with '00' or neither do, sort by input_fee_ppk - return (a.input_fee_ppk ?? 0) - (b.input_fee_ppk ?? 0); - })[0] - - if(!optimalKeyset) { - throw new AppError(Err.VALIDATION_ERROR, 'Wallet has not any active keyset for the selected unit.', { - mintUrl: mintInstance.mintUrl, - unit - }) - } - - return optimalKeyset - }, - })) + })) .actions(self => ({ getOrCreateMnemonic: flow(function* getOrCreateMnemonic() { let mnemonic: string | undefined = undefined diff --git a/src/navigation/TabsNavigator.tsx b/src/navigation/TabsNavigator.tsx index 9d43f67f..a6e6c837 100644 --- a/src/navigation/TabsNavigator.tsx +++ b/src/navigation/TabsNavigator.tsx @@ -41,6 +41,7 @@ import { SendOption, TokenReceiveScreen, RemoteRecoveryScreen, + NwcScreen, } from "../screens" import { Contact } from "../models/Contact" import { colors, useThemeColor, spacing, typography } from "../theme" @@ -291,7 +292,8 @@ export type SettingsStackParamList = { Relays: undefined RemoteRecovery: {isAddressOnlyRecovery: boolean} WalletNavigator: {screen: string} - ContactsNavigator: {screen: string} + ContactsNavigator: {screen: string} + Nwc: undefined } export type SettingsStackScreenProps = StackScreenProps< @@ -322,6 +324,7 @@ const SettingsNavigator = function SettingsNavigator() { + ) } diff --git a/src/screens/NwcScreen.tsx b/src/screens/NwcScreen.tsx new file mode 100644 index 00000000..1085713e --- /dev/null +++ b/src/screens/NwcScreen.tsx @@ -0,0 +1,441 @@ +import {observer} from 'mobx-react-lite' +import { Observer } from 'mobx-react-lite' +import Clipboard from '@react-native-clipboard/clipboard' +import React, {FC, useRef, useState} from 'react' +import {FlatList, TextInput, TextStyle, View, ViewStyle} from 'react-native' +import {colors, spacing, useThemeColor} from '../theme' +import {SettingsStackScreenProps} from '../navigation' // @demo remove-current-line +import {Icon, ListItem, Screen, Text, Card, BottomModal, Button, InfoModal, ErrorModal} from '../components' +import {useHeader} from '../utils/useHeader' +import {useStores} from '../models' +import AppError, { Err } from '../utils/AppError' +import { log } from '../services' +import { moderateVerticalScale, verticalScale } from '@gocodingnow/rn-size-matters' +import { NwcConnection } from '../models/NwcStore' +import { QRCodeBlock } from './Wallet/QRCode' +import { CollapsibleText } from '../components/CollapsibleText' + +interface SettingsScreenProps extends SettingsStackScreenProps<'Nwc'> {} + +export const NwcScreen: FC = observer( + function NwcScreen(_props) { + const {navigation} = _props + + useHeader({ + leftIcon: 'faArrowLeft', + onLeftPress: () => navigation.goBack(), + rightIcon: 'faRotate', + onRightPress: () => onConnect() + }) + + const connectionNameInputRef = useRef(null) + const dailyLimitInputRef = useRef(null) + const {nwcStore} = useStores() + + const [selectedConnection, setSelectedConnection] = useState() + const [isAddConnectionModalVisible, setIsAddConnectionModalVisible] = useState(false) + const [isShareConnectionModalVisible, setIsShareConnectionModalVisible] = useState(false) + const [isMenuConnectionModalVisible, setIsMenuConnectionModalVisible] = useState(false) + const [newConnectionName, setNewConnectionName] = useState('') + const [newConnectionDailyLimit, setNewConnectionDailyLimit] = useState('0') + const [info, setInfo] = useState('') + const [error, setError] = useState() + const [isLoading, setIsLoading] = useState(false) + + const toggleAddConnectionModal = () => { + setIsAddConnectionModalVisible(previousState => !previousState) + setIsShareConnectionModalVisible(false) + setIsMenuConnectionModalVisible(false) + if(selectedConnection) { + setSelectedConnection(undefined) + } + } + + const toggleShareConnectionModal = () => { + setIsShareConnectionModalVisible(previousState => !previousState) + setIsAddConnectionModalVisible(false) + setIsMenuConnectionModalVisible(false) + if(selectedConnection) { + setSelectedConnection(undefined) + } + } + + const toggleMenuConnectionModal = () => { + setIsMenuConnectionModalVisible(previousState => !previousState) + setIsAddConnectionModalVisible(false) + setIsShareConnectionModalVisible(false) + if(selectedConnection) { + setSelectedConnection(undefined) + } + } + + const onConnectionSelect = function (conn: NwcConnection) { + setSelectedConnection(conn) + toggleMenuConnectionModal() + } + + + const removeConnection = function () { + nwcStore.removeConnection(selectedConnection as NwcConnection) + setSelectedConnection(undefined) + toggleMenuConnectionModal() + } + + const onConnect = async function () { + log.trace('onConnect') + + // Full force re-subscription, not just reconnect + // WalletTask.receiveEventsFromRelays().catch(e => false) + setSelectedConnection(undefined) + nwcStore.receiveNwcEvents() + setInfo('NWC connections were refreshed') + } + + const gotoAdd = function () { + toggleAddConnectionModal() + } + + const gotoShare = function (conn: NwcConnection) { + setSelectedConnection(conn) + toggleShareConnectionModal() + } + + const onCopy = function (conn: NwcConnection) { + try { + Clipboard.setString(JSON.stringify(conn.connectionString)) + } catch (e: any) { + return false + } + } + + + const onSaveConnection = async function () { + try { + + if(!newConnectionName || !newConnectionDailyLimit) { + setInfo('Insert name or daily limit') + } + + if(nwcStore.alreadyExists(newConnectionName)) { + setInfo('Connection with this name already exists.') + return + } + + setIsLoading(true) + toggleAddConnectionModal() + await nwcStore.addConnection(newConnectionName, parseInt(newConnectionDailyLimit)) + setIsLoading(false) + onConnect() + + } catch(e: any) { + handleError(e) + } + } + + const handleError = function (e: AppError): void { + setIsAddConnectionModalVisible(false) + setError(e) + } + + const $itemRight = {color: useThemeColor('textDim')} + const iconColor = useThemeColor('textDim') + const headerBg = useThemeColor('header') + const inputBg = useThemeColor('background') + const iconBottom = useThemeColor('button') + const mainButtonColor = useThemeColor('card') + const screenBg = useThemeColor('background') + const mainButtonIcon = useThemeColor('button') + const labelText = useThemeColor('textDim') + const $subText = {color: useThemeColor('textDim'), fontSize: 14} + + return ( + + + + + + + } + /> + } + /> + {nwcStore.nwcConnections.length > 0 && ( + + + data={nwcStore.all} + extraData={nwcStore.all} + renderItem={({ item, index }) => { + return {() => ( + + {/*