POC of NWC integration

This commit is contained in:
minibits-cash
2024-08-20 11:05:51 +02:00
parent bb0c91e4de
commit 7774ba4b63
21 changed files with 1141 additions and 66 deletions
+1
View File
@@ -0,0 +1 @@
export const NwcIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="128" height="120" viewBox="0 0 128 120" fill="none"><g clip-path="url(#a)"><path fill="#472459" d="M127.2 65.67c-5.13 5.11-12.23 3.88-17.35-1.25l-41.4-41.6 19.4-19.47a11.35 11.35 0 0 1 18.4 3.37l21.58 56.22a2.5 2.5 0 0 1-.57 2.67l-13.63 13.57 13.58-13.51Z"/><path fill="url(#b)" d="m109.85 64.42-54.16-54.4a13.13 13.13 0 0 0-18.57-.05L3.87 43.07a13.13 13.13 0 0 0-.05 18.57l54.16 54.4a13.13 13.13 0 0 0 18.57.04l10.7-10.64 3.12-3.12-10-10.03a19.04 19.04 0 0 1-23.83-2.56l-6.8-6.85a2.46 2.46 0 0 1 0-3.5l3.35-3.32-8.4-8.46a3.67 3.67 0 0 1-.34-4.9 3.57 3.57 0 0 1 5.29-.23l8.51 8.56 6.68-6.63-8.41-8.46a3.67 3.67 0 0 1-.33-4.9 3.58 3.58 0 0 1 5.3-.24l8.5 8.57 3.36-3.34a2.47 2.47 0 0 1 3.5.01l6.8 6.84a19.03 19.03 0 0 1 2.41 23.86l10 10.03 5.69-5.67 8.16-8.12 17.4-17.31c-5.15 5.11-12.25 3.88-17.36-1.25Z"/></g><defs><linearGradient id="b" x1="63.6" x2="63.6" y1="6.15" y2="119.91" gradientUnits="userSpaceOnUse"><stop stop-color="#FFCA4A"/><stop offset="1" stop-color="#F7931A"/></linearGradient><clipPath id="a"><path fill="#fff" d="M0 0h128v119.91H0z"/></clipPath></defs></svg>`
+1
View File
@@ -19,3 +19,4 @@ export * from "./UsdIcon"
export * from "./EurIcon"
export * from "./MintIcon"
export * from "./HeaderBg"
export * from "./NwcIcon"
-5
View File
@@ -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
+552
View File
@@ -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<MeltQuoteResponse>()),
eventInFlight: types.maybe(types.frozen<NostrEvent>()),
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<NwcError>
// 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<typeof NwcConnectionModel> {}
export interface NwcStore
extends Instance<typeof NwcStoreModel> {}
export interface NwcStoreSnapshot
extends SnapshotOut<typeof NwcStoreModel> {}
-3
View File
@@ -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;
+3 -1
View File
@@ -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 => ({
+33 -30
View File
@@ -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
+4 -1
View File
@@ -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<T extends keyof SettingsStackParamList> = StackScreenProps<
@@ -322,6 +324,7 @@ const SettingsNavigator = function SettingsNavigator() {
<SettingsStack.Screen name="Developer" component={DeveloperScreen} />
<SettingsStack.Screen name="Relays" component={RelaysScreen} />
<SettingsStack.Screen name="RemoteRecovery" component={RemoteRecoveryScreen} />
<SettingsStack.Screen name="Nwc" component={NwcScreen} />
</SettingsStack.Navigator>
)
}
+441
View File
@@ -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<SettingsScreenProps> = observer(
function NwcScreen(_props) {
const {navigation} = _props
useHeader({
leftIcon: 'faArrowLeft',
onLeftPress: () => navigation.goBack(),
rightIcon: 'faRotate',
onRightPress: () => onConnect()
})
const connectionNameInputRef = useRef<TextInput>(null)
const dailyLimitInputRef = useRef<TextInput>(null)
const {nwcStore} = useStores()
const [selectedConnection, setSelectedConnection] = useState<NwcConnection | undefined>()
const [isAddConnectionModalVisible, setIsAddConnectionModalVisible] = useState(false)
const [isShareConnectionModalVisible, setIsShareConnectionModalVisible] = useState(false)
const [isMenuConnectionModalVisible, setIsMenuConnectionModalVisible] = useState(false)
const [newConnectionName, setNewConnectionName] = useState<string>('')
const [newConnectionDailyLimit, setNewConnectionDailyLimit] = useState<string>('0')
const [info, setInfo] = useState('')
const [error, setError] = useState<AppError | undefined>()
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 (
<Screen contentContainerStyle={$screen}>
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
<Text
preset='heading'
text='NWC'
style={{color: 'white'}}
/>
</View>
<View style={$contentContainer}>
<Card
style={$card}
ContentComponent={
<ListItem
text='Nostr Wallet Connect'
BottomComponent={
<CollapsibleText
collapsed={true}
summary='NWC lets you control your wallet from another application, such as your favourite Nostr app.'
text={'NWC lets you control your wallet from another application, such as your favourite Nostr app. Allow access only to the apps you trust. Your device must have push notifications enabled and stay online.'}
textProps={{style: $subText}}
/>
}
/>
}
/>
{nwcStore.nwcConnections.length > 0 && (
<Card
style={[$card, {marginTop: spacing.medium, flexShrink: 1}]}
ContentComponent={
<>
<FlatList<NwcConnection>
data={nwcStore.all}
extraData={nwcStore.all}
renderItem={({ item, index }) => {
return <Observer>{() => (
<ListItem
text={item.name}
subText={''}
leftIcon='faWallet'
leftIconColor={iconColor as string}
topSeparator={index === 0 ? false : true}
RightComponent={
<View style={$rightContainer}>
{/*<Button
onPress={() => onCopy(item)}
preset='tertiary'
LeftAccessory={() => <Icon icon='faCopy' color={iconColor} />}
/>*/}
<Button
onPress={() => gotoShare(item)}
preset='secondary'
LeftAccessory={() => <Icon icon='faQrcode' color={iconColor} />}
/>
</View>
}
BottomComponent={
<View style={{flexDirection: 'column'}}>
<Text
text={`Daily limit ${item.dailyLimit} SATS`}
style={$subText}
/>
<Text
text={`Remaining ${item.remainingDailyLimit} SATS`}
style={$subText}
/>
</View>}
style={$item}
onPress={() => onConnectionSelect(item)}
/>
)}</Observer>
}}
keyExtractor={(item) => item.connectionSecret}
style={{}}
/>
</>
}
/>
)}
</View>
<View style={$bottomContainer}>
<View style={$buttonContainer}>
<Button
LeftAccessory={() => (
<Icon
icon='faPlus'
size={spacing.large}
color={mainButtonIcon}
/>
)}
onPress={gotoAdd}
style={[{backgroundColor: mainButtonColor, borderWidth: 1, borderColor: screenBg}, $buttonNew]}
preset='tertiary'
text='Add connection'
/>
</View>
</View>
<BottomModal
isVisible={isMenuConnectionModalVisible ? true : false}
style={{alignItems: 'stretch'}}
ContentComponent={
<View>
<ListItem
leftIcon="faXmark"
onPress={removeConnection}
text="Remove connection"
style={{paddingHorizontal: spacing.medium}}
/>
</View>
}
onBackButtonPress={toggleMenuConnectionModal}
onBackdropPress={toggleMenuConnectionModal}
/>
<BottomModal
isVisible={isShareConnectionModalVisible ? true : false}
ContentComponent={
<View style={$newContainer}>
<QRCodeBlock
qrCodeData={selectedConnection?.connectionString as string}
title='Share NWC connection'
type='NWC'
/>
<Text
size="xxs"
style={{color: labelText, marginTop: spacing.medium, alignSelf: 'center'}}
text="Scan or copy this connection string to another application to allow it to connect to your wallet. Use only with apps you trust."
/>
</View>
}
onBackButtonPress={toggleShareConnectionModal}
onBackdropPress={toggleShareConnectionModal}
/>
<BottomModal
isVisible={isAddConnectionModalVisible ? true : false}
ContentComponent={
<>
<Text text='Create NWC connection' preset="subheading" style={{alignSelf: 'center'}} />
<View style={$newContainer}>
<Text
size="xxs"
style={{color: labelText, marginTop: spacing.medium}}
text="Name your connection by the app you will use it with"
/>
<View style={{
flexDirection: 'row',
marginTop: spacing.small,
}}>
<TextInput
ref={connectionNameInputRef}
onChangeText={(name) => setNewConnectionName(name)}
value={newConnectionName}
autoCapitalize='sentences'
keyboardType='default'
maxLength={64}
placeholder='My NWC application'
selectTextOnFocus={true}
style={[$connInput, {backgroundColor: inputBg}]}
/>
</View>
<Text
size="xxs"
style={{color: labelText, marginTop: spacing.medium}}
text="Set maximal daily limit to spend"
/>
<View style={{
flexDirection: 'row',
marginTop: spacing.small
}}>
<TextInput
ref={dailyLimitInputRef}
onChangeText={(limit) => setNewConnectionDailyLimit(limit)}
value={newConnectionDailyLimit}
keyboardType='numeric'
maxLength={8}
placeholder='Enter daily limit in SATS'
selectTextOnFocus={true}
style={[$connInput, {backgroundColor: inputBg, borderBottomRightRadius: 0, borderTopRightRadius: 0}]}
/>
<Button
preset='secondary'
text="SATS"
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
marginHorizontal: 1,
}}
/>
</View>
<View style={[$buttonContainer, {marginTop: spacing.medium}]}>
<Button
tx={'common.save'}
style={$saveButton}
onPress={onSaveConnection}
/>
<Button preset='tertiary' onPress={toggleAddConnectionModal} style={{marginLeft: spacing.small}} text='Cancel'/>
</View>
</View>
</>
}
onBackButtonPress={toggleAddConnectionModal}
onBackdropPress={toggleAddConnectionModal}
/>
{info && <InfoModal message={info} />}
{error && <ErrorModal error={error} />}
</Screen>
)
},
)
const $screen: ViewStyle = {
flex: 1,
}
const $headerContainer: TextStyle = {
alignItems: 'center',
padding: spacing.extraSmall,
height: spacing.screenHeight * 0.20,
}
const $contentContainer: TextStyle = {
flex: 1,
padding: spacing.extraSmall,
marginTop: -spacing.extraLarge * 2
}
const $newContainer: TextStyle = {
alignSelf: 'stretch',
// padding: spacing.small,
// alignItems: 'center',
}
const $saveButton: ViewStyle = {
borderRadius: spacing.small,
marginLeft: spacing.small,
}
const $connInput: TextStyle = {
flex: 1,
borderRadius: spacing.extraSmall,
fontSize: 16,
padding: spacing.small,
alignSelf: 'stretch',
textAlignVertical: 'top',
}
const $card: ViewStyle = {
// marginVertical: 0,
}
const $item: ViewStyle = {
// paddingHorizontal: spacing.small,
paddingLeft: 0,
}
const $rightContainer: ViewStyle = {
flexDirection: 'row',
padding: spacing.extraSmall,
alignSelf: 'center',
marginLeft: spacing.small,
}
const $buttonContainer: ViewStyle = {
flexDirection: 'row',
marginBottom: spacing.tiny,
justifyContent: 'center',
alignItems: 'center',
}
const $bottomContainer: ViewStyle = {
/*position: 'absolute',
bottom: 0,
left: 0,
right: 0,
flex: 1,
justifyContent: 'flex-end',
marginBottom: spacing.medium,*/
alignSelf: 'center',
// opacity: 0,
}
const $buttonNew: ViewStyle = {
borderRadius: moderateVerticalScale(60 / 2),
height: moderateVerticalScale(60),
minWidth: verticalScale(120),
}
+39 -3
View File
@@ -1,6 +1,6 @@
import {observer} from 'mobx-react-lite'
import React, {FC, useCallback, useEffect, useRef, useState} from 'react'
import {Alert, AppState, FlatList, Switch, TextStyle, View, ViewStyle} from 'react-native'
import {Alert, AppState, TextStyle, View, ViewStyle} from 'react-native'
import notifee from '@notifee/react-native'
import messaging from '@react-native-firebase/messaging'
import {
@@ -11,7 +11,7 @@ import {
import codePush, { RemotePackage } from 'react-native-code-push'
import {colors, spacing, useThemeColor} from '../theme'
import {SettingsStackScreenProps} from '../navigation' // @demo remove-current-line
import {Icon, ListItem, Screen, Text, Card} from '../components'
import {ListItem, Screen, Text, Card, NwcIcon} from '../components'
import {useHeader} from '../utils/useHeader'
import {useStores} from '../models'
import {translate} from '../i18n'
@@ -21,6 +21,7 @@ import { round } from '../utils/number'
import { getCurrency } from '../services/wallet/currency'
import { getMintColor } from './WalletScreen'
import { NotificationService } from '../services/notificationService'
import { SvgXml } from 'react-native-svg'
interface SettingsScreenProps extends SettingsStackScreenProps<'Settings'> {}
@@ -32,7 +33,13 @@ export const SettingsScreen: FC<SettingsScreenProps> = observer(
const {navigation} = _props
useHeader({}) // default header component
const appState = useRef(AppState.currentState)
const {mintsStore, relaysStore, userSettingsStore, walletProfileStore} = useStores()
const {
mintsStore,
relaysStore,
userSettingsStore,
walletProfileStore,
nwcStore
} = useStores()
const [isUpdateAvailable, setIsUpdateAvailable] = useState<boolean>(false)
const [updateDescription, setUpdateDescription] = useState<string>('')
@@ -152,6 +159,10 @@ export const SettingsScreen: FC<SettingsScreenProps> = observer(
})
}
const gotoNwc = function() {
navigation.navigate('Nwc')
}
const openNotificationSettings = async function() {
await notifee.openNotificationSettings()
}
@@ -236,6 +247,31 @@ export const SettingsScreen: FC<SettingsScreenProps> = observer(
bottomSeparator={true}
onPress={openNotificationSettings}
/>
<ListItem
text='Nostr Wallet Connect'
subText={`${nwcStore.all.length} connection${nwcStore.all.length > 1 ? 's' : ''}`}
LeftComponent={
<View style={{
borderRadius: spacing.small,
padding: spacing.tiny,
backgroundColor: 'white',
marginRight: spacing.medium
}}
>
<SvgXml
width={spacing.large}
height={spacing.large}
xml={NwcIcon}
style={{}}
/>
</View>
}
leftIconInverse={true}
style={$item}
bottomSeparator={true}
onPress={gotoNwc}
/>
<ListItem
tx="nostr.relaysTitle"
subText={translate("common.connectedParam", { param: relaysStore.connectedCount })}
+5
View File
@@ -326,6 +326,11 @@ useEffect(() => {
useEffect(() => {
const handleTransferTaskResult = async (result: TransactionTaskResult) => {
log.trace('handleTransferTaskResult event handler triggered', {isInvoiceDonation})
/* if(result.meltQuote && meltQuote && result.meltQuote.quote !== meltQuote.quote) {
log.error('[handleTransferTaskResult] Event belongs to a different meltquote, skipping...')
return
} */
setIsLoading(false)
const {transaction, message, error, finalFee} = result
+1 -1
View File
@@ -133,7 +133,7 @@ export const UpdateScreen: FC<SettingsStackScreenProps<'Update'>> = observer(fun
const headerBg = useThemeColor('header')
return (
<Screen style={$screen}>
<Screen contentContainerStyle={$screen}>
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
<Text preset="heading" tx="updateScreen.updateManagerTitle" style={{color: 'white'}} />
</View>
+1 -1
View File
@@ -15,7 +15,7 @@ import { log } from "../../services"
export const QRCodeBlock = function (props: {
qrCodeData: string
title: string
type: 'EncodedV3Token' | 'EncodedV4Token' | 'Bolt11Invoice' | 'URL'
type: 'EncodedV3Token' | 'EncodedV4Token' | 'Bolt11Invoice' | 'URL' | 'NWC'
size?: number
}
) {
+1
View File
@@ -36,4 +36,5 @@ export * from './RemoteRecoveryScreen'
export * from './LocalRecoveryScreen'
export * from './DeveloperScreen'
export * from './RelaysScreen'
export * from './NwcScreen'
export * from './ErrorScreen/ErrorBoundary'
+10 -7
View File
@@ -51,10 +51,13 @@ export type Nip05VerificationRecord = {
}
}
// TODO cleanup
// TODO cleanup all this shit, move to RelayStore model
const _defaultPublicRelays: string[] = ['wss://relay.damus.io', 'wss://nostr.mom']
const _minibitsRelays: string[] = [MINIBITS_RELAY_URL]
let _pool: any = undefined
const {relaysStore} = rootStoreInstance
const getRelayPool = function () {
@@ -216,16 +219,16 @@ const publish = async function (
throw new AppError(Err.VALIDATION_ERROR, 'Event is invalid and could not be published', signed)
}
log.trace('Event to be published', signed, 'publish')
// log.trace('Event to be published', signed, 'publish')
const pool = getRelayPool()
let pubs = pool.publish(relays, signed)
await delay(1000)
// await Promise.all(pubs)
const published: NostrEvent = await pool.get(relays, {
const published = await pool.get(relays, {
ids: [signed.id]
})
}) as NostrEvent
if(published) {
@@ -239,11 +242,11 @@ const publish = async function (
const getEvent = async function (
relays: string[],
filter: NostrFilter
): Promise<Event | null> {
filters: NostrFilter[]
): Promise<NostrEvent | null> {
const pool = getRelayPool()
const event: Event = await pool.get(relays, filter)
const event = await pool.get(relays, filters) as NostrEvent
if(event) {
log.trace('Event received', event, 'getEvent')
+15
View File
@@ -9,6 +9,7 @@ import { MintUnit, formatCurrency, getCurrency } from './wallet/currency'
import { NostrClient, NostrProfile } from './nostrService'
import AppError, { Err } from '../utils/AppError'
import { Platform } from 'react-native'
import { rootStoreInstance } from '../models';
export type NotifyReceiveToLnurlData = {
type: 'NotifyReceiveToLnurlData',
@@ -20,6 +21,12 @@ export type NotifyReceiveToLnurlData = {
}
}
const {
proofsStore,
relaysStore,
walletStore,
} = rootStoreInstance
// Remote notification receive handler
const onReceiveRemoteNotification = async function(remoteMessage: FirebaseMessagingTypes.RemoteMessage) {
log.debug('[onReceiveRemoteNotification]', {remoteMessage})
@@ -45,12 +52,20 @@ const onReceiveRemoteNotification = async function(remoteMessage: FirebaseMessag
const {amount, unit, comment, zapSenderProfile} = remoteData.data
const currencyCode = getCurrency(unit as MintUnit).code
/* const mint = await walletStore.getMint('https://mint.minibits.cash/Bitcoin')
const {keysets} = await mint.getKeySets()
log.trace('[onReceiveRemoteNotification]', {mint: mint.mintUrl, keysets}) */
await createLocalNotification(
`<b>⚡${formatCurrency(amount, currencyCode)} ${currencyCode}</b> incoming!`,
`${zapSenderProfile ? 'Zap' : 'Payment'} from <b>${zapSenderProfile?.nip05 || 'unknown payer'}</b> is ready to be received.${comment ? ' Message from sender: ' + comment : ''}`,
zapSenderProfile?.picture
)
return
}
+10 -5
View File
@@ -73,7 +73,12 @@ export const sendTask = async function (
transactionId = storedTransaction.id as number
// get ready proofs to send and update proofs and pending proofs storage
const {proofs: proofsToSend, mintFeePaid, mintFeeReserve} = await sendFromMint(
const {
proofs: proofsToSend,
mintFeePaid,
mintFeeReserve,
isSwapNeeded
} = await sendFromMint(
mintBalanceToSendFrom,
amountToSend,
unit,
@@ -86,6 +91,7 @@ export const sendTask = async function (
status: TransactionStatus.PREPARED,
mintFeeReserve,
mintFeePaid,
isSwapNeeded,
createdAt: new Date(),
})
@@ -204,9 +210,7 @@ export const sendFromMint = async function (
Err.VALIDATION_ERROR,
'Could not find mint', {mintUrl}
)
}
}
const proofsFromMint = proofsStore.getByMint(mintUrl, {isPending: false, unit}) as Proof[]
@@ -458,7 +462,8 @@ export const sendFromMint = async function (
return {
proofs: cleanedProofsToSend as ProofV3[],
mintFeeReserve,
mintFeePaid
mintFeePaid,
isSwapNeeded
}
} catch (e: any) {
// release lock
+13 -7
View File
@@ -122,9 +122,12 @@ export const transferTask = async function (
)
proofsToPay = swapResult.proofs
const {mintFeePaid, mintFeeReserve} = swapResult
const {mintFeePaid, mintFeeReserve, isSwapNeeded} = swapResult
const proofsAmount = CashuUtils.getProofsAmount(proofsToPay)
// TODO in case of swap from inactive keysets, different meltFees might apply than above calculated meltFeeReserve
// In such case, we might need to add / substract the fee difference to / from proofsToPay
log.debug('[transfer]', 'Prepared poofsToPay amount', proofsAmount)
// Update transaction status
@@ -132,6 +135,7 @@ export const transferTask = async function (
status: TransactionStatus.PREPARED,
mintFeeReserve,
mintFeePaid,
isSwapNeeded,
createdAt: new Date(),
})
@@ -165,11 +169,7 @@ export const transferTask = async function (
}
)
lockedProofsCounter.decreaseProofsCounter(countOfInFlightProofs)
// We've sent the proofsToPay to the mint, so we remove those pending proofs from model storage.
// Hopefully mint gets important shit done synchronously.
await WalletTask.handleSpentByMint({mintUrl, isPending: true})
lockedProofsCounter.decreaseProofsCounter(countOfInFlightProofs)
// I have no idea yet if this can happen, unpaid call throws, return sent Proofs to the store an track tx as Reverted
if (!isPaid) {
@@ -223,6 +223,10 @@ export const transferTask = async function (
}
}
// We've sent the proofsToPay to the mint, so we remove those pending proofs from model storage.
// Hopefully mint gets important shit done synchronously.
WalletTask.handleSpentByMint({mintUrl, isPending: true})
// If real fees were less then estimated, cash the returned savings.
let lightningFeePaid = meltQuote.fee_reserve
@@ -275,7 +279,9 @@ export const transferTask = async function (
transaction: completedTransaction,
message: `Lightning invoice has been successfully paid and settled with your Minibits ecash. Fee has been ${formatCurrency(lightningFeePaid + mintFeePaid, getCurrency(unit).code)} ${getCurrency(unit).code}.`,
lightningFeePaid,
mintFeePaid
mintFeePaid,
meltQuote,
preimage
} as TransactionTaskResult
} catch (e: any) {
+1 -1
View File
@@ -1059,7 +1059,7 @@ const _sendTopupNotification = async function (pr: PaymentRequest) {
const receiveEventsFromRelays = async function (): Promise<void> {
log.trace('[receiveEventsFromRelays] start')
if(!walletProfileStore.pubkey) { // New profile not yet created in ContactsScreen
if(!walletProfileStore.pubkey) {
const message = `No wallet profile created.`
log.trace('[receiveEventsFromRelays]', message)
return
+1
View File
@@ -67,6 +67,7 @@ const palette = {
iconMagenta200: '#F62586',
iconViolet200: '#AF00C9',
iconViolet300: '#7F38CA',
iconViolet400: '#662482',
} as const
export const colors = {
+9 -1
View File
@@ -1,3 +1,5 @@
import { log } from "../services"
class EventEmitter {
private events: {[eventName: string]: Function[]}
@@ -11,11 +13,17 @@ class EventEmitter {
* @param handler The callback function to be called when the event is emitted.
*/
on(eventName: string, handler: Function) {
log.trace(`[EventEmitter.on] Subscribing to ${eventName}`)
if (!this.events[eventName]) {
this.events[eventName] = []
}
this.events[eventName].push(handler)
if(!this.events[eventName].includes(handler)) {
this.events[eventName].push(handler)
} else {
log.warn(`[EventEmitter.on] ${eventName} event listener with this handler already exists, skipping...`)
}
}
/**