Files
com.blitzwallet/app/functions/nwc/backgroundNofifications.js
T
Blake KaufmanandGitHub a5df591ac7 Audit repo (#1011)
* remove unused deps

* bump bitcoin address parser

* bump spark
2026-07-20 08:24:11 -04:00

675 lines
18 KiB
JavaScript

import {
getNWCData,
getSupportedMethods,
isWithinNWCBalanceTimeFrame,
splitAndStoreNWCData,
} from '.';
import {
decryptMessage,
encriptMessage,
} from '../messaging/encodingAndDecodingMessages';
import { publishToSingleRelay } from './publishResponse';
import {
getNWCLightningReceiveRequest,
getNWCSparkBalance,
getNWCSparkTransactions,
initializeNWCWallet,
NWCSparkLightningPaymentStatus,
nwcWallet,
sendNWCSparkLightningPayment,
} from './wallet';
import bolt11 from '../decodeBolt11';
import { getSparkPaymentStatus, sparkPaymentType } from '../spark';
import { pushInstantNotification } from '../notifications';
import NWCInvoiceManager from './cachedNWCTxs';
import { NOSTR_RELAY_URL, NWC_IDENTITY_PUB_KEY } from '../../constants';
import { finalizeEvent } from 'nostr-tools';
import { getFunctions } from '@react-native-firebase/functions';
import { Platform } from 'react-native';
import fetchBackend from '../../../db/handleBackend';
import { getLocalStorageItem } from '../localStorage';
import sha256Hash from '../hash';
import { transformTxToPaymentObject } from '../spark/transformTxToPayment';
const handledEventIds = new Set();
let nwcAccounts, fullStorageObject;
let walletInitializationPromise = null;
const RELAY_URL = NOSTR_RELAY_URL;
const ERROR_CODES = {
INTERNAL: 'INTERNAL',
RESTRICTED: 'RESTRICTED',
QUOTA_EXCEEDED: 'QUOTA_EXCEEDED',
};
const createErrorResponse = (method, code, message) => ({
result_type: method,
error: { code, message },
});
const ensureWalletConnection = async () => {
if (nwcWallet) {
return { isConnected: true };
}
if (walletInitializationPromise) {
console.log('Wallet initialization already in progress, waiting...');
return await walletInitializationPromise;
}
walletInitializationPromise = initializeNWCWallet();
try {
const result = await walletInitializationPromise;
// Clear the promise on successful completion
walletInitializationPromise = null;
return result;
} catch (error) {
// Clear the promise on error so retry is possible
walletInitializationPromise = null;
throw error;
}
};
const handleGetInfo = selectedNWCAccount => ({
result_type: 'get_info',
result: {
alias: 'N/A',
color: 'N/A',
pubkey: 'N/A',
network: 'mainnet',
block_height: 1,
block_hash: 'N/A',
methods: getSupportedMethods(selectedNWCAccount.permissions),
},
});
const handleGetTransactions = async requestParams => {
const connectResponse = await ensureWalletConnection();
if (!connectResponse.isConnected) {
return createErrorResponse(
'list_transactions',
ERROR_CODES.INTERNAL,
'Unable to connect to wallet',
);
}
const { from, until, limit = 20, offset = 0, type } = requestParams;
let allTransactions = [];
let currentOffset = 0;
const chunkSize = limit * 1.5;
let hasMore = true;
while (hasMore) {
const chunk = await getNWCSparkTransactions(chunkSize, currentOffset);
if (!chunk || chunk.transfers.length === 0) {
hasMore = false;
break;
}
allTransactions = allTransactions.concat(chunk.transfers);
currentOffset += chunkSize;
// Stop fetching if we have enough for this request (with buffer for filtering)
if (allTransactions.length >= offset + limit) {
break;
}
// Stop if we got less than requested (end of data)
if (chunk.transfers.length < chunkSize) {
hasMore = false;
}
}
let filteredTransactions = allTransactions.filter(tx => {
// Filter by timestamp range if provided
const type = sparkPaymentType(tx);
if (type === 'spark') return false;
if (from || until) {
const txTime = tx.createdTime
? new Date(tx.createdTime).getTime() / 1000
: null;
if (!txTime) return false;
if (from && txTime < from) return false;
if (until && txTime > until) return false;
}
// Filter by transaction type if specified
if (type) {
const isIncoming = tx.transferDirection === 'INCOMING';
const isOutgoing = tx.transferDirection === 'OUTGOING';
if (type === 'incoming' && !isIncoming) return false;
if (type === 'outgoing' && !isOutgoing) return false;
}
return true;
});
const paginatedTransactions = filteredTransactions.slice(
offset,
offset + limit,
);
const formatted = await Promise.all(
paginatedTransactions.map(async tx => {
const transformedObjct = await transformTxToPaymentObject(
tx,
undefined,
undefined,
false,
[],
undefined,
1,
);
return {
type: tx.transferDirection?.toLowerCase(),
invoice: transformedObjct.details.address,
description: transformedObjct.details.description,
description_hash: null,
preimage: transformedObjct.details.preimage,
payment_hash: sha256Hash(transformedObjct.details.preimage),
amount: tx.totalValue * 1000,
fees_paid: transformedObjct.details.fee,
created_at: tx.createdTime
? Math.floor(new Date(tx.createdTime).getTime() / 1000)
: null,
settled_at: tx.expiryTime
? Math.floor(new Date(tx.updatedTime).getTime() / 1000)
: null,
metadata: {},
};
}),
);
return {
result_type: 'list_transactions',
result: {
transactions: formatted,
},
};
};
const handleMakeInvoice = async (
requestParams,
selectedNWCAccount,
fullStorageObject,
event,
) => {
const sparkPubKey = JSON.parse(
await getLocalStorageItem(NWC_IDENTITY_PUB_KEY),
);
if (!sparkPubKey) {
return createErrorResponse(
'make_invoice',
ERROR_CODES.INTERNAL,
'Unable to connect to wallet',
);
}
await fetchBackend(
'handleNWCEvent',
{
requestMethod: 'make_invoice',
amount: Math.round(requestParams.amount / 1000),
description: requestParams.description,
expiry: requestParams.expiry,
publicKey: selectedNWCAccount.publicKey,
privateKey: selectedNWCAccount.privateKey,
sparkIdentityPubKey: sparkPubKey,
event: { clientPubKey: event.clientPubKey, id: event.id },
},
selectedNWCAccount.privateKey,
selectedNWCAccount.publicKey,
);
// await splitAndStoreNWCData({
// ...fullStorageObject,
// accounts: {
// ...fullStorageObject.accounts,
// [selectedNWCAccount.publicKey]: {
// ...selectedNWCAccount,
// shouldGetNewBalance: true,
// },
// },
// });
};
const handleLookupInvoice = async (
requestParams,
event,
selectedNWCAccount,
) => {
let foundInvoice = null;
const { payment_hash, invoice } = requestParams;
try {
foundInvoice = await NWCInvoiceManager.handleLookupInvoice(requestParams);
} catch (err) {
console.log('Error handling lookup', err);
return createErrorResponse(
'lookup_invoice',
ERROR_CODES.INTERNAL,
err.message,
);
}
if (foundInvoice) {
const { sparkID, ...invoiceWithoutSparkID } = foundInvoice;
if (invoiceWithoutSparkID.status !== 'pending') {
return {
result_type: 'lookup_invoice',
result: invoiceWithoutSparkID,
};
}
const connectResponse = await ensureWalletConnection();
if (!connectResponse.isConnected) {
return createErrorResponse(
'lookup_invoice',
ERROR_CODES.INTERNAL,
'Unable to connect to wallet',
);
}
let sparkPaymentResponse;
if (invoiceWithoutSparkID.type === 'INCOMING') {
sparkPaymentResponse = await getNWCLightningReceiveRequest(sparkID);
} else {
sparkPaymentResponse = await NWCSparkLightningPaymentStatus(sparkID);
}
if (!sparkPaymentResponse.didWork)
return createErrorResponse(
'lookup_invoice',
ERROR_CODES.INTERNAL,
'Unable to lookup invoice.',
);
const data = sparkPaymentResponse.paymentResponse;
const status = getSparkPaymentStatus(data.status);
if (status !== 'pending') {
await NWCInvoiceManager.markInvoiceAsNotPending(
invoiceWithoutSparkID.payment_hash,
status,
data.paymentPreimage,
);
return {
result_type: 'lookup_invoice',
result: {
...invoiceWithoutSparkID,
status: status,
preimage: data.paymentPreimage || '',
settled_at: Date.now(),
},
};
}
return {
result_type: 'lookup_invoice',
result: invoiceWithoutSparkID,
};
}
await fetchBackend(
'handleNWCEvent',
{
requestMethod: 'lookup_invoice',
invoice: invoice,
payment_hash: payment_hash,
publicKey: selectedNWCAccount.publicKey,
privateKey: selectedNWCAccount.privateKey,
event: { clientPubKey: event.clientPubKey, id: event.id },
},
selectedNWCAccount.privateKey,
selectedNWCAccount.publicKey,
);
};
const handlePayInvoice = async (
requestParams,
selectedNWCAccount,
fullStorageObject,
) => {
const connectResponse = await ensureWalletConnection();
const decoded = bolt11.decode(requestParams.invoice);
const paymentAmount = Math.round(decoded.millisatoshis / 1000);
const timeFrame = isWithinNWCBalanceTimeFrame(
selectedNWCAccount.budgetRenewalSettings.option,
selectedNWCAccount.lastRotated,
);
if (
selectedNWCAccount.budgetRenewalSettings.amount !== 'Unlimited' &&
selectedNWCAccount.budgetRenewalSettings.amount <
selectedNWCAccount.totalSent + paymentAmount &&
timeFrame
) {
return createErrorResponse(
'pay_invoice',
ERROR_CODES.QUOTA_EXCEEDED,
'The wallet has exceeded its spending quota.',
);
} else if (!timeFrame) {
await splitAndStoreNWCData({
...fullStorageObject,
accounts: {
...fullStorageObject.accounts,
[selectedNWCAccount.publicKey]: {
...selectedNWCAccount,
lastRotated: new Date().getTime(),
totalSent: 0,
},
},
});
}
if (!connectResponse.isConnected) {
// const currentExpense
return createErrorResponse(
'pay_invoice',
ERROR_CODES.INTERNAL,
'Unable to connect to wallet',
);
}
const invoice = await sendNWCSparkLightningPayment({
invoice: requestParams.invoice,
});
if (!invoice.didWork) {
return createErrorResponse(
'pay_invoice',
ERROR_CODES.INTERNAL,
'Unable to send payment',
);
}
const response = invoice.paymentResponse;
await new Promise(res => setTimeout(res, 1000));
const status = await NWCSparkLightningPaymentStatus(response.id);
await NWCInvoiceManager.storeCreatedInvoice({
payment_hash: sha256Hash(status?.paymentResponse?.paymentPreimage || ''),
invoice: response.encodedInvoice,
amount: paymentAmount,
fee: Math.round(response.fee.originalValue / 1000),
description: '',
status: getSparkPaymentStatus(status?.paymentResponse.status),
created_at: response.createdAt,
sparkID: response.id,
type: 'OUTGOING',
preimage: status?.paymentResponse?.paymentPreimage || '',
});
if (!status.didWork) {
return createErrorResponse(
'pay_invoice',
ERROR_CODES.INTERNAL,
'Unable to retrieve payment status',
);
}
// await splitAndStoreNWCData({
// ...fullStorageObject,
// accounts: {
// ...fullStorageObject.accounts,
// [selectedNWCAccount.publicKey]: {
// ...selectedNWCAccount,
// shouldGetNewBalance: true,
// },
// },
// });
return {
result_type: 'pay_invoice',
result: {
preimage: status.paymentResponse.paymentPreimage || '',
},
};
};
const handleGetBalance = async (selectedNWCAccount, fullStorageObject) => {
console.log('running in get balance');
// if (
// // !selectedNWCAccount.shouldGetNewBalance &&
// // selectedNWCAccount.walletBalance &&
// // selectedNWCAccount.lastChecked - Date.now() < 60 * 5
// ) {
// console.log('running cached wallet balance');
// return {
// result_type: 'get_balance',
// result: {
// balance: Number(selectedNWCAccount.walletBalance) * 1000,
// },
// };
// }
const connectResponse = await ensureWalletConnection();
console.log('conection response');
if (!connectResponse.isConnected) {
return createErrorResponse(
'get_balance',
ERROR_CODES.INTERNAL,
'Unable to connect to wallet',
);
}
const balance = await getNWCSparkBalance();
// await splitAndStoreNWCData({
// ...fullStorageObject,
// accounts: {
// ...fullStorageObject.accounts,
// [selectedNWCAccount.publicKey]: {
// ...selectedNWCAccount,
// shouldGetNewBalance: false,
// walletBalance: Number(balance.balance),
// lastChecked: Date.now(),
// },
// },
// });
return {
result_type: 'get_balance',
result: {
balance: Number(balance.balance) * 1000,
},
};
};
const processEvent = async (event, selectedNWCAccount) => {
const { requestMethod, requestParams } = event;
console.log('request method', requestMethod);
console.log('request params', requestParams);
let returnObject;
switch (requestMethod) {
case 'get_info':
returnObject = handleGetInfo(selectedNWCAccount);
break;
case 'list_transactions':
if (!selectedNWCAccount.permissions.transactionHistory) {
returnObject = createErrorResponse(
requestMethod,
ERROR_CODES.RESTRICTED,
'Requested service is not authorized',
);
break;
}
returnObject = await handleGetTransactions(requestParams);
break;
case 'make_invoice':
if (!selectedNWCAccount.permissions.receivePayments) {
returnObject = createErrorResponse(
requestMethod,
ERROR_CODES.RESTRICTED,
'Requested service is not authorized',
);
break;
}
await handleMakeInvoice(
requestParams,
selectedNWCAccount,
fullStorageObject,
event,
);
break;
case 'lookup_invoice':
if (!selectedNWCAccount.permissions.lookupInvoice) {
returnObject = createErrorResponse(
requestMethod,
ERROR_CODES.RESTRICTED,
'Requested service is not authorized',
);
break;
}
await handleLookupInvoice(requestParams, event, selectedNWCAccount);
break;
case 'pay_invoice':
if (!selectedNWCAccount.permissions.sendPayments) {
returnObject = createErrorResponse(
requestMethod,
ERROR_CODES.RESTRICTED,
'Requested service is not authorized',
);
break;
}
returnObject = await handlePayInvoice(
requestParams,
selectedNWCAccount,
fullStorageObject,
);
break;
case 'get_balance':
if (!selectedNWCAccount.permissions.getBalance) {
returnObject = createErrorResponse(
requestMethod,
ERROR_CODES.RESTRICTED,
'Requested service is not authorized',
);
break;
}
returnObject = await handleGetBalance(
selectedNWCAccount,
fullStorageObject,
);
break;
default:
returnObject = createErrorResponse(
requestMethod,
ERROR_CODES.RESTRICTED,
'Requested service is not authorized',
);
}
if (typeof returnObject !== 'object' || returnObject === null) {
console.log('Invalid return object from event handler:', returnObject);
return;
}
return returnObject;
};
function decryptEventMessage(selectedNWCAccount, event) {
const decryptedContent = decryptMessage(
selectedNWCAccount.privateKey,
event.clientPubKey,
event.content,
);
const data = JSON.parse(decryptedContent);
return data;
}
export default async function handleNWCBackgroundEvent(notificationData) {
try {
// Initialize accounts if not already done
if (__DEV__) {
getFunctions().useEmulator(process.env.DEVICE_IP, 5001);
}
let {
data: { body: nwcEvent },
} = notificationData;
console.log('background nwc event', nwcEvent);
if (!nwcEvent) return;
try {
nwcEvent = JSON.parse(nwcEvent);
} catch (err) {}
// // Filter out already handled events upfront
const newEvents = nwcEvent?.events;
if (!newEvents) return;
fullStorageObject = await getNWCData();
nwcAccounts = fullStorageObject.accounts;
pushInstantNotification(
`Received ${newEvents.length} event${newEvents.length === 1 ? '' : 's'}`,
'Nostr Connect',
);
const filteredEvents = newEvents
.map((event, index) => {
const selectedNWCAccount = nwcAccounts[event.pubkey];
const parsedData = decryptEventMessage(selectedNWCAccount, event);
const { method: requestMethod, params: requestParams } = parsedData;
const handledKey = `${event.clientPubKey}-${requestMethod}`;
if (handledEventIds.has(handledKey)) return false;
handledEventIds.add(handledKey);
return { requestMethod, requestParams, ...event, content: parsedData };
})
.filter(Boolean);
await filteredEvents.forEach(async (event, index) => {
const selectedNWCAccount = nwcAccounts[event.pubkey];
console.log(selectedNWCAccount, 'SELECTED NWC ACCOUNT');
if (!selectedNWCAccount) return null;
try {
const returnObject = await processEvent(event, selectedNWCAccount);
if (!returnObject) return;
const eventTemplate = {
kind: 23195,
created_at: Math.floor(Date.now() / 1000),
tags: [
['p', event.clientPubKey],
['e', event.id],
],
content: encriptMessage(
selectedNWCAccount.privateKey,
event.clientPubKey,
JSON.stringify(returnObject),
),
};
const finalizedEvent = finalizeEvent(
eventTemplate,
Buffer.from(selectedNWCAccount.privateKey, 'hex'),
);
await publishToSingleRelay([finalizedEvent], RELAY_URL);
} catch (error) {
console.error('Error processing event:', event.id, error);
return null;
}
});
handledEventIds.clear();
} catch (err) {
console.error('Error handling background nwc event', err);
}
}