mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
POC of NWC integration
This commit is contained in:
@@ -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>`
|
||||
@@ -19,3 +19,4 @@ export * from "./UsdIcon"
|
||||
export * from "./EurIcon"
|
||||
export * from "./MintIcon"
|
||||
export * from "./HeaderBg"
|
||||
export * from "./NwcIcon"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
) {
|
||||
|
||||
@@ -36,4 +36,5 @@ export * from './RemoteRecoveryScreen'
|
||||
export * from './LocalRecoveryScreen'
|
||||
export * from './DeveloperScreen'
|
||||
export * from './RelaysScreen'
|
||||
export * from './NwcScreen'
|
||||
export * from './ErrorScreen/ErrorBoundary'
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -67,6 +67,7 @@ const palette = {
|
||||
iconMagenta200: '#F62586',
|
||||
iconViolet200: '#AF00C9',
|
||||
iconViolet300: '#7F38CA',
|
||||
iconViolet400: '#662482',
|
||||
} as const
|
||||
|
||||
export const colors = {
|
||||
|
||||
@@ -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...`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user