import {
Instance,
SnapshotOut,
types,
flow,
destroy,
isStateTreeNode,
detach,
} from 'mobx-state-tree'
import { NWCWalletResponse, NWCWalletInfo, NWCWalletRequest } from 'nostr-tools/kinds'
import {withSetPropAction} from './helpers/withSetPropAction'
import {log} from '../services/logService'
import { getRootStore } from './helpers/getRootStore'
import {
Database,
HANDLE_NWC_REQUEST_TASK,
KeyChain,
NostrClient,
NostrEvent,
NostrKeyPair,
NostrUnsignedEvent,
SyncQueue,
TransactionTaskResult,
WalletTaskResult
} from '../services'
import AppError, { Err } from '../utils/AppError'
import { LightningUtils } from '../services/lightning/lightningUtils'
import EventEmitter from '../utils/eventEmitter'
import { addSeconds } from 'date-fns/addSeconds'
import { Transaction, TransactionStatus, TransactionType } from './Transaction'
import { MeltQuoteBolt11Response } from '@cashu/cashu-ts'
import { WalletStore } from './WalletStore'
import { ProofsStore } from './ProofsStore'
import { isSameDay } from 'date-fns/isSameDay'
import { NotificationService } from '../services/notificationService'
import { roundUp } from '../utils/number'
import { MINIBITS_MINT_URL } from '@env'
import { MintBalance } from './Mint'
import { transferTask } from '../services/wallet/transferTask'
import { topupTask } from '../services/wallet/topupTask'
import { WalletProfileStore } from './WalletProfileStore'
import { Platform } from 'react-native'
import { TransactionsStore } from './TransactionsStore'
import { SubCloser } from 'nostr-tools/abstract-pool'
type NwcError = {
result_type: string,
error: {
code: string,
message: string
}
}
export type NwcRequest = {
method: string,
params: any
}
type NwcResponse = {
result_type: string,
result: any
}
// Internal-only outcome: an NWC command resolved to "no reply" — an async melt
// still pending when the background (NWC listener / foreground service) tore
// down. NIP-47 has no pending wire response, so this is NEVER sent; it just
// tells the dispatcher to skip replying for this command (the payment finalizes
// later; zaps confirm via the NIP-57 receipt).
type NwcPending = {
result_type: string,
pending: true
}
const isNwcPending = (
r: NwcResponse | NwcError | NwcPending,
): r is NwcPending => (r as NwcPending).pending === true
type NwcTransaction = {
type: string,
invoice: string,
description: string | null,
preimage: string | null,
payment_hash: string | null,
amount: number,
fees_paid: number | null,
created_at: number,
settled_at: number | null
expires_at: number | null
}
export const nwcPngUrl = 'https://1044827509-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F0JQfRPMJ4uO7z9wmnAOK%2Fuploads%2FVO76qdgzHHzWSDsHQXdu%2FGroup%201000001143%20(1).png?alt=media&token=0fdb70b7-bb19-4bed-a752-a0560585c2f4&width=512&dpr=1&quality=100&sign=57c41699&sv=1'
const getConnectionRelays = function () {
const minibitsRelays = NostrClient.getMinibitsRelays()
// const publicRelays = NostrClient.getDefaultRelays()
// return [...minibitsRelays, ...publicRelays]
return minibitsRelays
}
export const LISTEN_FOR_NWC_EVENTS = 'listenForNwcEvents'
export const MIN_LIGHTNING_FEE = 2 // sats
export const LIGHTNING_FEE_PERCENT = 1
const MAX_MULTI_PAY_INVOICES = 5
// Safety cap so an in-flight NWC pay_invoice can never hang the SyncQueue forever
// if the listener-closing signal is missed. Normally the wait ends earlier — the
// async melt settles (ev_asyncMeltResult) or the NWC listener / foreground
// service tears down (ev_nwcListenerClosing). Kept just above the 30s listener
// hard cap so it only ever acts as a backstop.
const NWC_ASYNC_MELT_SAFETY_MS = 35 * 1000
/**
* Wait for the async melt of `transactionId` to settle, OR until the NWC listener
* / foreground service is torn down — whichever comes first. This binds the wait
* to the background lifetime instead of a separate timeout: the common fast case
* resolves on settlement (→ preimage reply); on teardown it resolves undefined
* (payment still in flight, finalized later; zaps confirm via the NIP-57 receipt).
*/
const waitForAsyncMeltResult = (
transactionId: number,
): Promise<{transactionId: number; status: TransactionStatus; message: string} | undefined> =>
new Promise((resolve) => {
const cleanup = () => {
clearTimeout(timer)
EventEmitter.off('ev_asyncMeltResult', onResult)
EventEmitter.off('ev_nwcListenerClosing', onClosing)
}
const onResult = (result: {transactionId: number; status: TransactionStatus; message: string}) => {
if (result?.transactionId === transactionId) {
cleanup()
resolve(result)
}
}
const onClosing = () => {
cleanup()
resolve(undefined)
}
const timer = setTimeout(() => {
cleanup()
resolve(undefined)
}, NWC_ASYNC_MELT_SAFETY_MS)
EventEmitter.on('ev_asyncMeltResult', onResult)
EventEmitter.on('ev_nwcListenerClosing', onClosing)
})
const getSupportedMethods = function () {
return [
'pay_invoice',
'get_balance',
'get_info',
'list_transactions',
'make_invoice',
'lookup_invoice'
]
}
export const NwcConnectionModel = types.model('NwcConnection', {
name: types.string,
connectionPubkey: types.string,
connectionSecret: types.identifier,
dailyLimit: types.optional(types.number, 0),
remainingDailyLimit: types.optional(types.number, 0),
currentDay: types.optional(types.Date, new Date()),
lastMeltQuoteId: types.maybe(types.string),
})
.actions(withSetPropAction)
.actions(self => ({
getWalletStore (): WalletStore {
const rootStore = getRootStore(self)
const {walletStore} = rootStore
return walletStore
},
getWalletProfileStore (): WalletProfileStore {
const rootStore = getRootStore(self)
const {walletProfileStore} = rootStore
return walletProfileStore
},
getProofsStore (): ProofsStore {
const rootStore = getRootStore(self)
const {proofsStore} = rootStore
return proofsStore
},
getTransactionsStore (): TransactionsStore {
const rootStore = getRootStore(self)
const {transactionsStore} = rootStore
return transactionsStore
},
setRemainingDailyLimit(limit: number) {
self.remainingDailyLimit = limit
},
setCurrentDay() {
self.currentDay = new Date()
},
setLastMeltQuoteId(quoteId: string | undefined) {
self.lastMeltQuoteId = quoteId
},
}))
.views(self => ({
get walletPubkey(): string {
const walletProfileStore = self.getWalletProfileStore()
return walletProfileStore.pubkey
},
get connectionRelays(): string[] {
return getConnectionRelays()
},
get supportedMethods() {
return getSupportedMethods()
}
}))
.views(self => ({
get connectionString(): string {
const walletProfileStore = self.getWalletProfileStore()
return `nostr+walletconnect://${self.walletPubkey}?relay=${self.connectionRelays.join('&relay=')}&secret=${self.connectionSecret}&lud16=${walletProfileStore.lud16}`
},
}))
.actions(self => ({
sendResponse: flow(function* sendResponse(nwcResponse: NwcResponse | NwcError, requestEvent: NostrEvent) {
log.trace('[Nwc.sendResponse] start', {nwcResponse, connection: self.name})
// eventInFlight.pubkey should = connectionPubkey
log.trace('Encrypt response', {connectionPubkey: self.connectionPubkey, requestEventPubkey: requestEvent.pubkey})
const walletStore = self.getWalletStore()
const keys: NostrKeyPair = (yield walletStore.getCachedWalletKeys()).NOSTR
const encryptedContent = yield NostrClient.encryptNip04(
requestEvent.pubkey,
JSON.stringify(nwcResponse),
keys
)
const responseEvent: NostrUnsignedEvent = {
pubkey: self.walletPubkey,
kind: NWCWalletResponse,
tags: [["p", requestEvent.pubkey], ["e", requestEvent.id]],
content: encryptedContent,
created_at: Math.floor(Date.now() / 1000)
}
// notify errors
if((nwcResponse as NwcError).error) {
let body = ''
if(nwcResponse.result_type === 'pay_invoice') {
body = 'Pay invoice error: '
}
if(nwcResponse.result_type === 'multi_pay_invoice') {
body = 'Multi pay invoice error: '
}
if(nwcResponse.result_type === 'get_balance') {
body = 'Get balance error: '
}
if(nwcResponse.result_type === 'list_transactions') {
body = 'List transactions error: '
}
if(nwcResponse.result_type === 'make_invoice') {
body = 'Create invoice error: '
}
if(nwcResponse.result_type === 'lookup_invoice') {
body = 'Lookup invoice error: '
}
yield NotificationService.createLocalNotification(
Platform.OS === 'android' ? `${self.name} - Nostr Wallet Connect` : `${self.name} - Nostr Wallet Connect`,
body + (nwcResponse as NwcError).error.message,
nwcPngUrl
)
}
yield NostrClient.publish(
responseEvent,
self.connectionRelays,
keys,
false
)
}),
payInvoice: flow(function* payInvoice(nwcRequest: NwcRequest, encodedInvoice: string, requestEvent: NostrEvent) {
log.debug('[Nwc.payInvoice] start')
try {
const walletStore = self.getWalletStore()
const proofsStore = self.getProofsStore()
const invoice = LightningUtils.decodeInvoice(encodedInvoice)
const {
amount: amountToPay,
expiry,
description,
timestamp
} = LightningUtils.getInvoiceData(invoice)
const invoiceExpiry = addSeconds(new Date(timestamp as number * 1000), expiry as number)
// Calculated on device to avoid mintQuote call for minibits mint
const feeReserve = Math.max(MIN_LIGHTNING_FEE, amountToPay * LIGHTNING_FEE_PERCENT / 100)
const totalAmountToPay = amountToPay + feeReserve
let mintBalance: MintBalance | undefined = undefined
const minibitsBalance = proofsStore.getMintBalance(MINIBITS_MINT_URL)
if(minibitsBalance && minibitsBalance.balances.sat! >= totalAmountToPay) {
mintBalance = minibitsBalance
} else {
mintBalance = proofsStore.getMintBalanceWithMaxBalance('sat')
}
const availableBalanceSat = mintBalance?.balances.sat || 0
if(!mintBalance || 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} SAT would be exceeded with this payment.`
return {
result_type: nwcRequest.method,
error: { code: 'QUOTA_EXCEEDED', message}
} as NwcError
}
const meltQuote: MeltQuoteBolt11Response = yield walletStore.createLightningMeltQuote(
mintBalance.mintUrl,
'sat',
encodedInvoice,
)
const result = yield transferTask(
mintBalance,
amountToPay,
'sat',
meltQuote,
description || '',
invoiceExpiry as Date,
encodedInvoice,
requestEvent,
undefined
)
if(result.meltQuote?.quote === self.lastMeltQuoteId) {
throw new AppError(Err.ALREADY_EXISTS_ERROR, 'Already processed', {quote: result.meltQuote?.quote})
}
self.setLastMeltQuoteId(result.meltQuote?.quote)
let txStatus: TransactionStatus | undefined = result.transaction?.status
let completedAmount: number = result.transaction?.amount ?? 0
let completedFee: number = result.transaction?.fee ?? 0
let preimage: string | undefined = result.preimage
// Async melt: the mint ACKed and the payment is in flight. Wait for
// settlement only as long as the background (NWC listener / foreground
// service) is alive. Fast common case → reply with preimage; if the
// background tears down first → no reply (payment finalizes later; zaps
// confirm via the NIP-57 receipt, non-zap clients retry + mint dedups).
if(txStatus === TransactionStatus.PENDING) {
const settled = yield waitForAsyncMeltResult(result.transaction.id)
if(!settled) {
// Still in flight at teardown. Reserve the daily limit
// (conservative — never lets the cap be exceeded) and resolve
// to a typed 'pending' outcome (no wire reply).
self.setRemainingDailyLimit(self.remainingDailyLimit - totalAmountToPay)
log.info('[Nwc.payInvoice] Async melt still pending at teardown; limit reserved, no NWC reply', {tId: result.transaction.id})
return { result_type: nwcRequest.method, pending: true } as NwcPending
}
if(settled.status !== TransactionStatus.COMPLETED) {
return {
result_type: nwcRequest.method,
error: { code: 'INTERNAL', message: settled.message || 'Lightning payment failed.'}
} as NwcError
}
// Settled within the window — read the finalized tx for the
// preimage and actual amount/fee.
const finalTx = Database.getTransactionById(result.transaction.id)
completedAmount = finalTx?.amount ?? completedAmount
completedFee = finalTx?.fee ?? completedFee
preimage = finalTx?.proof ?? preimage
txStatus = TransactionStatus.COMPLETED
}
let nwcResponse: NwcResponse | NwcError
if(txStatus === TransactionStatus.COMPLETED) {
const updatedLimit = self.remainingDailyLimit - (completedAmount + completedFee)
nwcResponse = {
result_type: nwcRequest.method,
result: {
preimage: preimage || 'not-provided',
}
} as NwcResponse
log.trace('[handleTransferTaskResult] Updating remainingLimit', {
connection: self.name,
beforeUpdate: self.remainingDailyLimit,
afterUpdate: updatedLimit
})
self.setRemainingDailyLimit(updatedLimit)
yield NotificationService.createLocalNotification(
Platform.OS === 'android' ? `${self.name} - Nostr Wallet Connect` : `${self.name} - Nostr Wallet Connect`,
`Paid ${completedAmount} SAT${completedFee > 0 ? ', fee ' + completedFee + ' SAT' : ''}. Remaining today's limit is ${self.remainingDailyLimit} SAT`,
nwcPngUrl
)
} else {
nwcResponse = {
result_type: nwcRequest.method,
error: { code: 'INTERNAL', message: result.message}
} as NwcError
}
return nwcResponse
} catch (e: any) {
log.error(`[NwcConnection.handlePayInvoice] ${e.message}`)
return {
result_type: nwcRequest.method,
error: { code: 'INTERNAL', message: e.message}
} as NwcError
}
}),
}))
.actions(self => ({
handleGetInfo (nwcRequest: NwcRequest): NwcResponse {
const nwcResponse: 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
},
handleListTransactions (nwcRequest: NwcRequest): NwcResponse {
const rootStore = getRootStore(self)
const {transactionsStore} = rootStore
const lightningTransactions = transactionsStore.history.filter(
(t: Transaction) => (t.type === TransactionType.TOPUP ||
t.type === TransactionType.TRANSFER) &&
t.status === TransactionStatus.COMPLETED
)
// TODO barebones implementation, no paging commands support
const transactions = lightningTransactions.map((t: Transaction) => {
return {
type: t.type === TransactionType.TOPUP ? 'incoming' : 'outgoing',
invoice: t.paymentRequest,
description: t.memo,
preimage: t.proof,
payment_hash: t.paymentId,
amount: t.amount * 1000,
fees_paid: t.fee,
created_at: Math.floor(t.createdAt.getTime() / 1000),
settled_at: Math.floor(t.createdAt.getTime() / 1000),
expires_at: t.expiresAt ? Math.floor(t.expiresAt.getTime() / 1000) : 0,
} as NwcTransaction
})
const nwcResponse: NwcResponse = {
result_type: nwcRequest.method,
result: {
transactions
}
}
return nwcResponse
},
handleGetBalance(nwcRequest: NwcRequest) {
// Read from SQLite so this works on a lean NWC wake (proof map not
// hydrated). Mirrors proofsStore.getMintBalanceWithMaxBalance('sat').
const balance = Database.getMintBalanceWithMaxBalance('sat')
const limit = self.remainingDailyLimit
let resultBalanceMsat = 0
if(balance && balance > 0 && limit > 0) {
resultBalanceMsat = (Math.min(balance, limit)) * 1000
} else {
resultBalanceMsat = 0
}
const nwcResponse: NwcResponse = {
result_type: nwcRequest.method,
result: {
balance: resultBalanceMsat
}
}
return nwcResponse
},
handleMakeInvoice: flow(function* handleMakeInvoice(nwcRequest: NwcRequest, requestEvent: NostrEvent) {
log.debug('[handleMakeInvoice]', {
connection: self.name,
amountMsat: nwcRequest.params.amount,
})
const proofsStore = self.getProofsStore()
const mintBalance = proofsStore.getMintBalanceWithMaxBalance('sat')
const {amount: amontMsat, description} = nwcRequest.params
if(!mintBalance) {
const message = `Wallet has no mints`
return {
result_type: nwcRequest.method,
error: { code: 'INTERNAL', message}
} as NwcError
}
const result: TransactionTaskResult = yield topupTask(
mintBalance,
roundUp(amontMsat / 1000, 0),
'sat',
description,
undefined,
requestEvent
)
log.debug('Got topup task result', {
connection: self.name,
encodedInvoice: result.encodedInvoice,
caller: 'handleTopupTaskResult'
})
let nwcResponse: NwcResponse | NwcError
if(result.transaction) {
const {transaction} = result
nwcResponse = {
result_type: 'make_invoice',
result: {
type: 'incoming',
invoice: transaction.paymentRequest,
description: transaction.memo,
payment_hash: transaction.paymentId,
amount: transaction.amount * 1000,
fees_paid: transaction.fee,
created_at: Math.floor(transaction.createdAt!.getTime() / 1000),
expires_at: Math.floor(transaction.expiresAt!.getTime() / 1000),
preimage: null,
settled_at: null
} as NwcTransaction
} as NwcResponse
yield NotificationService.createLocalNotification(
Platform.OS === 'android' ? `${self.name} - Nostr Wallet Connect` : `${self.name} - Nostr Wallet Connect`,
`Invoice for ${transaction.amount} SATS has been created.`,
nwcPngUrl
)
} else {
nwcResponse = {
result_type: 'make_invoice',
error: { code: 'INTERNAL', message: result.message}
} as NwcError
}
return nwcResponse
}),
handleLookupInvoice(nwcRequest: NwcRequest) {
let transaction: Transaction | undefined = undefined
let transactionsStore = self.getTransactionsStore()
if(nwcRequest.params.payment_hash) {
transaction = transactionsStore.findLastBy({paymentId: nwcRequest.params.payment_hash})
}
if(nwcRequest.params.invoice) {
transaction = transactionsStore.findLastBy({paymentRequest: nwcRequest.params.invoice})
}
if(!transaction) {
return {
result_type: nwcRequest.method,
error: { code: 'INTERNAL', message: 'Could not find requested invoice'}
} as NwcError
}
return {
result_type: nwcRequest.method,
result: {
type: 'incoming',
invoice: transaction.paymentRequest,
description: transaction.memo,
payment_hash: transaction.paymentId,
amount: transaction.amount * 1000,
fees_paid: transaction.fee,
created_at: Math.floor(transaction.createdAt!.getTime() / 1000),
expires_at: Math.floor(transaction.expiresAt!.getTime() / 1000),
preimage: transaction.proof,
settled_at: Math.floor(transaction.createdAt!.getTime() / 1000)
} as NwcTransaction
} as NwcResponse
},
handlePayInvoice: flow(function* handlePayInvoice(nwcRequest: NwcRequest, requestEvent: NostrEvent) {
log.debug('[Nwc.handlePayInvoice] start')
// reset daily limit if day changed while keeping live connection
if(!isSameDay(self.currentDay, new Date())) {
self.setRemainingDailyLimit(self.dailyLimit)
self.setCurrentDay()
}
const nwcResponse = yield self.payInvoice(nwcRequest, nwcRequest.params.invoice, requestEvent)
return nwcResponse as NwcResponse | NwcError | NwcPending
}),
handleMultiPayInvoice: flow(function* handleMultiPayInvoice(nwcRequest: NwcRequest, requestEvent: NostrEvent) {
log.debug('[Nwc.handleMultiPayInvoice] start')
const encodedInvoices: string[] = nwcRequest.params.invoices
if(encodedInvoices.length > MAX_MULTI_PAY_INVOICES) {
const nwcResponse = {
result_type: 'multi_pay_invoice',
error: { code: 'INTERNAL', message: 'Can not process more than 5 payments at once.'}
} as NwcError
return [nwcResponse] as NwcError[]
}
// reset daily limit if day changed while keeping live connection
if(!isSameDay(self.currentDay, new Date())) {
self.setRemainingDailyLimit(self.dailyLimit)
self.setCurrentDay()
}
const nwcResponses: (NwcResponse | NwcError | NwcPending)[] = []
for (const invoice of encodedInvoices) {
const nwcResponse = yield self.payInvoice(nwcRequest, invoice, requestEvent)
// May be a typed 'pending' outcome (async melt unresolved at teardown);
// the dispatcher skips sending those.
nwcResponses.push(nwcResponse)
}
return nwcResponses
})
}))
.actions(self => ({
handleNwcRequestTask: flow(function* handleNwcRequestTask(requestEvent: NostrEvent, decryptedNwcRequest?: NwcRequest) {
let nwcRequest: NwcRequest
if(!decryptedNwcRequest) {
const walletStore = self.getWalletStore()
const keys: NostrKeyPair = (yield walletStore.getCachedWalletKeys()).NOSTR
const decryptedContent = yield NostrClient.decryptNip04(
requestEvent.pubkey,
requestEvent.content,
keys
)
nwcRequest = JSON.parse(decryptedContent)
} else {
nwcRequest = decryptedNwcRequest
}
let nwcResponse: NwcResponse | NwcError | NwcPending | undefined = undefined
let nwcResponses: (NwcResponse | NwcError | NwcPending)[] = []
log.trace('[Nwc.handleRequest] request event', {requestEvent})
log.trace('[Nwc.handleRequest] decrypted nwc command', {nwcRequest})
// A lean background NWC wake skips bulk proof loading. Mutating commands
// select/derive against the in-memory proof map, so load it on demand
// here (no-op when already hydrated — warm session or full setup).
if (['pay_invoice', 'multi_pay_invoice', 'make_invoice'].includes(nwcRequest.method)) {
yield self.getProofsStore().ensureProofsLoaded()
}
switch (nwcRequest.method) {
case 'get_info':
nwcResponse = self.handleGetInfo(nwcRequest)
break
case 'list_transactions':
nwcResponse = self.handleListTransactions(nwcRequest)
break
case 'get_balance':
nwcResponse = self.handleGetBalance(nwcRequest)
break
case 'make_invoice':
nwcResponse = yield self.handleMakeInvoice(nwcRequest, requestEvent)
break
case 'lookup_invoice':
nwcResponse = self.handleLookupInvoice(nwcRequest)
break
case 'pay_invoice':
nwcResponse = yield self.handlePayInvoice(nwcRequest, requestEvent)
break
case 'multi_pay_invoice':
const responses = yield self.handleMultiPayInvoice(nwcRequest, requestEvent)
nwcResponses = [...responses]
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})
}
// support for multiple responses from one nwc request (multi_pay_invoice)
if(nwcResponse) {
nwcResponses.push(nwcResponse)
}
for (const response of nwcResponses) {
// 'pending' outcomes have no NIP-47 wire response — skip them.
if (isNwcPending(response)) continue
yield self.sendResponse(response, requestEvent)
}
return nwcResponses[0]
}),
}))
export const NwcStoreModel = types
.model('NwcStore', {
nwcConnections: types.array(NwcConnectionModel),
isNwcListenerActive: types.optional(types.boolean, false),
nwcSubscription: types.maybe(types.frozen()),
retrieveEventsSince: types.optional(types.number, Math.floor(Date.now() / 1000) - 5 * 1000), // room for killed app wakeup, if not set
})
.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 walletPubkey(): string {
const rootStore = getRootStore(self)
const {walletProfileStore} = rootStore
return walletProfileStore.pubkey
},
get all() {
return self.nwcConnections
},
get supportedMethods() {
return getSupportedMethods()
},
get connectionRelays() {
return getConnectionRelays()
}
}))
.actions(self => ({
resetDailyLimits () {
for (const c of self.nwcConnections) {
if(!isSameDay(c.currentDay, new Date())) {
c.setRemainingDailyLimit(c.dailyLimit)
c.setCurrentDay()
}
}
},
resetSubscription () {
if(self.nwcSubscription) {
log.trace('[resetSubscription] Closing and removing existing nwcSubscription')
self.nwcSubscription.close()
self.nwcSubscription = undefined
}
},
setRetrieveEventsSince (since: number) {
self.retrieveEventsSince = since
}
}))
.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 filter = {
kinds: [NWCWalletInfo],
authors: [newConnection.walletPubkey],
}
// Not sure we should publish that as we are not always on, TBD
const existingInfoEvent = yield NostrClient.getEvent(newConnection.connectionRelays, filter)
if(!existingInfoEvent) {
// publish info replacable event // seems to be a relict replaced by get_info request?
const infoEvent: NostrUnsignedEvent = {
kind: NWCWalletInfo,
pubkey: newConnection.walletPubkey,
tags: [],
content: self.supportedMethods.join(' '),
created_at: Math.floor(Date.now() / 1000)
}
// Fire and forget - publish without waiting
;(async () => {
const rootStore = getRootStore(self)
const keys: NostrKeyPair = (await rootStore.walletStore.getCachedWalletKeys()).NOSTR
NostrClient.publish(
infoEvent,
newConnection.connectionRelays,
keys,
false
)
})()
}
}),
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')
}
},
/**
* Process an NWC request event delivered IN the FCM push payload, without
* waiting for the WebSocket listener to re-fetch it. Dedup-marks the event
* synchronously (so the follow-up listener, started right after, skips it),
* sets the listener window to start from this event, and dispatches it on
* the SyncQueue — exactly the same handler the WS path uses.
*
* MUST be called BEFORE the follow-up listener is opened, so the dedup mark
* is in place and the same event can never be processed twice (a double
* pay_invoice would be a double payment).
*/
receivePushedEvent (event: NostrEvent) {
if (!event || !event.id) {
log.warn('[receivePushedEvent] No event in push payload, skipping')
return
}
const relaysStore = getRootStore(self).relaysStore
if (relaysStore.eventAlreadyReceived(event.id)) {
log.trace('[receivePushedEvent] Event already processed, skipping', {id: event.id})
return
}
relaysStore.addReceivedEventId(event.id)
// Follow-up listener should start from this event (it is now deduped).
self.setRetrieveEventsSince(event.created_at)
const targetConnection = self.nwcConnections.find(c =>
c.connectionPubkey === event.pubkey
)
if (!targetConnection) {
log.error('[receivePushedEvent] No NWC connection for pushed event', {pubkey: event.pubkey})
return
}
const now = new Date().getTime()
SyncQueue.addTask(
`handleNwcRequestTask-${now}`,
async () => await targetConnection.handleNwcRequestTask(event)
)
},
listenForNwcEvents () {
log.debug('[listenForNwcEvents] got request to start nwcListener', {
walletPubkey: self.walletPubkey,
isNwcListenerActive: self.isNwcListenerActive,
relays: self.connectionRelays
})
if(self.nwcConnections.length === 0) {
log.trace('[listenForNwcEvents] No NWC connections, skipping subscription...')
return
}
if(self.isNwcListenerActive) {
log.trace('[listenForNwcEvents] nwcListener is already OPEN, skipping subscription...')
return
}
// reset daily limits if day changed
self.resetDailyLimits()
try {
// 10s window to get the first event that came with push message
const since = self.retrieveEventsSince
const connectionsPubkeys = self.nwcConnections.map(c => c.connectionPubkey)
let eventsBatch: NostrEvent[] = []
const filter = [{
kinds: [NWCWalletRequest],
authors: connectionsPubkeys,
"#p": [self.walletPubkey],
since
}]
const pool = NostrClient.getRelayPool()
const relaysStore = getRootStore(self).relaysStore
const sub = pool.subscribeMany(self.connectionRelays , filter, {
onevent(event) {
log.trace('[listenForNwcEvents]', `onEvent`)
if (event.kind != NWCWalletRequest) {
return
}
if(relaysStore.eventAlreadyReceived(event.id)) {
log.warn(
Err.ALREADY_EXISTS_ERROR,
'[listenForNwcEvents] Event has been processed in the past, skipping...',
{id: event.id, created_at: event.created_at}
)
return
}
eventsBatch.push(event)
relaysStore.addReceivedEventId(event.id)
// find connection the nwc request is sent to
const targetConnection = self.nwcConnections.find(c =>
c.connectionPubkey === event.pubkey
)
if(!targetConnection) {
throw new AppError(Err.VALIDATION_ERROR, `Your wallet has received a NWC command, but could not find related NWC connection to handle it.`)
}
// dispatch to correct connection, process over sync queue
const now = new Date().getTime()
SyncQueue.addTask(
`handleNwcRequestTask-${now}`,
async () => await targetConnection.handleNwcRequestTask(event)
)
},
oneose() {
log.trace('[listenForNwcEvents]', `onEose: Got ${eventsBatch.length} NWC events`)
eventsBatch = []
const connections = pool.listConnectionStatus()
log.trace('[listenForNwcEvents] onEose', {connections: Array.from(connections)})
},
onclose() {
log.debug('[listenForNwcEvents]', `onClose`)
self.resetSubscription()
self.setRetrieveEventsSince(Math.floor(Date.now() / 1000))
}
})
self.nwcSubscription = sub
} catch (e: any) {
log.error(e.name, e.message)
return
}
},
handleNwcRequestTask: flow(function* handleNwcRequestTask(event: NostrEvent, decryptedNwcRequest?: NwcRequest) {
// find connection the nwc request is sent to
const targetConnection = self.nwcConnections.find(c =>
c.connectionPubkey === event.pubkey
)
if(!targetConnection) {
const message = `Your wallet has received a NWC command, but could not find related NWC connection to handle it.`
log.error('[handleNwcRequestFromNotification]', message, {pubkey: event.pubkey})
yield NotificationService.createLocalNotification(
Platform.OS === 'android' ? `Nostr Wallet Connect error` : `Nostr Wallet Connect error`,
message,
nwcPngUrl
)
return {
taskFunction: HANDLE_NWC_REQUEST_TASK,
message,
error: new AppError(Err.WALLET_ERROR, message)
} as WalletTaskResult
}
if(!event) {
const message = `Your wallet has received a NWC command, but could not retrieve the required data.`
log.error('[handleNwcRequestFromNotification]', message)
yield NotificationService.createLocalNotification(
Platform.OS === 'android' ? `Nostr Wallet Connect error` : `Nostr Wallet Connect error`,
message,
nwcPngUrl
)
return {
taskFunction: HANDLE_NWC_REQUEST_TASK,
message,
error: new AppError(Err.WALLET_ERROR, message)
} as WalletTaskResult
}
const nwcResponse: NwcResponse | NwcError =
yield targetConnection.handleNwcRequestTask(event, decryptedNwcRequest)
return {
taskFunction: HANDLE_NWC_REQUEST_TASK,
message: (nwcResponse as NwcResponse).result || undefined ,
error: (nwcResponse as NwcError).error ? new AppError(Err.WALLET_ERROR, (nwcResponse as NwcError).error.message) : undefined
} as WalletTaskResult
})
}))
.views(self => ({
get all() {
return self.nwcConnections
}
}))
.postProcessSnapshot((snapshot) => {
return {
nwcConnections: snapshot.nwcConnections,
nwcSubscription: undefined,
retrieveEventsSince: snapshot.retrieveEventsSince
}
})
export interface NwcConnection extends Instance {}
export interface NwcStore
extends Instance {}
export interface NwcStoreSnapshot
extends SnapshotOut {}