Files
app.zeusln.zeus/utils/NostrConnectUtils.ts
T

1655 lines
58 KiB
TypeScript

import { Platform } from 'react-native';
import { Notifications } from 'react-native-notifications';
import { relayInit, generatePrivateKey, getPublicKey } from 'nostr-tools';
import {
Nip47NotificationType,
Nip47SingleMethod,
Nip47Transaction,
Nip47ListTransactionsRequest,
Nip47LookupInvoiceRequest,
Nip47MakeInvoiceRequest
} from '@getalby/sdk';
import {
BudgetRenewalType,
PermissionType,
TimeUnit,
ConnectionActivity
} from '../models/NWCConnection';
import Invoice from '../models/Invoice';
import Payment from '../models/Payment';
import CashuPayment from '../models/CashuPayment';
import CashuInvoice from '../models/CashuInvoice';
import CashuToken from '../models/CashuToken';
import Transaction from '../models/Transaction';
import { localeString } from './LocaleUtils';
import dateTimeUtils from './DateTimeUtils';
import Bolt11Utils from './Bolt11Utils';
import BackendUtils from './BackendUtils';
import { millisatsToSats, satsToMillisats } from './AmountUtils';
import { numberWithCommas } from './UnitsUtils';
export interface PermissionOption {
key: PermissionType;
title: string;
description: string;
}
export interface IndividualPermissionOption {
key: Nip47SingleMethod;
title: string;
description: string;
}
export interface BudgetRenewalOption {
key: BudgetRenewalType;
title: string;
}
const PRESET_INDEX = {
FIRST: 0,
SECOND: 1,
THIRD: 2,
NEVER: 3,
CUSTOM: 4
} as const;
const NWC_TRAY_NOTIFICATION_KEYS = {
outgoingTitle:
'stores.NostrWalletConnectStore.paymentSentNotificationTitle',
outgoingBody: 'stores.NostrWalletConnectStore.paymentSentNotificationBody',
outgoingFailedTitle:
'stores.NostrWalletConnectStore.paymentFailedNotificationTitle',
outgoingFailedBody:
'stores.NostrWalletConnectStore.paymentFailedNotificationBody',
invoiceReadyTitle:
'stores.NostrWalletConnectStore.invoiceCreatedNotificationTitle',
invoiceReadyBody:
'stores.NostrWalletConnectStore.invoiceCreatedNotificationBody',
invoiceReadyBodyWithDescription:
'stores.NostrWalletConnectStore.invoiceCreatedNotificationBodyWithDescription'
} as const;
export const NWC_ACTIVITY_NOTIF_KEYS = {
action: 'zeusNwcAction',
connectionId: 'zeusNwcConnectionId',
failedActivityId: 'zeusNwcFailedActivityId',
openActivity: 'connection_activity'
} as const;
export type NwcActivityNotif = {
connectionId: string;
failedActivityId?: string;
};
function nonEmptyString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
export function parseNwcActivityNotif(
payload: unknown
): NwcActivityNotif | null {
if (!payload || typeof payload !== 'object') return null;
const p = payload as Record<string, unknown>;
const k = NWC_ACTIVITY_NOTIF_KEYS;
if (p[k.action] !== k.openActivity) return null;
const connectionId = nonEmptyString(p[k.connectionId]);
if (!connectionId) return null;
const failedActivityId = nonEmptyString(p[k.failedActivityId]);
return failedActivityId
? { connectionId, failedActivityId }
: { connectionId };
}
export const DEFAULT_INVOICE_EXPIRY_SECONDS = 3600;
export enum Nip47ErrorCode {
INTERNAL_ERROR = 'INTERNAL_ERROR',
RATE_LIMITED = 'RATE_LIMITED',
INVALID_INVOICE = 'INVALID_INVOICE',
FAILED_TO_PAY_INVOICE = 'FAILED_TO_PAY_INVOICE',
FAILED_TO_CREATE_INVOICE = 'FAILED_TO_CREATE_INVOICE',
NOT_FOUND = 'NOT_FOUND',
INSUFFICIENT_BALANCE = 'INSUFFICIENT_BALANCE',
INVOICE_EXPIRED = 'INVOICE_EXPIRED'
}
export default class NostrConnectUtils {
static getNotifications(): Nip47NotificationType[] {
return ['payment_received', 'payment_sent', 'hold_invoice_accepted'];
}
static get TIME_UNITS(): TimeUnit[] {
return [
localeString('time.hours'),
localeString('time.days'),
localeString('time.weeks'),
localeString('time.months'),
localeString('time.years')
];
}
static getAvailablePermissions(): IndividualPermissionOption[] {
return [
{
key: 'get_info',
title: localeString(
'views.Settings.NostrWalletConnect.permissions.getInfo'
),
description: localeString(
'views.Settings.NostrWalletConnect.permissions.getInfoDescription'
)
},
{
key: 'get_balance',
title: localeString(
'views.Settings.NostrWalletConnect.permissions.getBalance'
),
description: localeString(
'views.Settings.NostrWalletConnect.permissions.getBalanceDescription'
)
},
{
key: 'pay_invoice',
title: localeString(
'views.Settings.NostrWalletConnect.permissions.payInvoice'
),
description: localeString(
'views.Settings.NostrWalletConnect.permissions.payInvoiceDescription'
)
},
{
key: 'make_invoice',
title: localeString(
'views.Settings.NostrWalletConnect.permissions.makeInvoice'
),
description: localeString(
'views.Settings.NostrWalletConnect.permissions.makeInvoiceDescription'
)
},
{
key: 'lookup_invoice',
title: localeString(
'views.Settings.NostrWalletConnect.permissions.lookupInvoice'
),
description: localeString(
'views.Settings.NostrWalletConnect.permissions.lookupInvoiceDescription'
)
},
{
key: 'list_transactions',
title: localeString(
'views.Settings.NostrWalletConnect.permissions.listTransactions'
),
description: localeString(
'views.Settings.NostrWalletConnect.permissions.listTransactionsDescription'
)
},
{
key: 'sign_message',
title: localeString('views.Settings.signMessage.button'),
description: localeString(
'views.Settings.NostrWalletConnect.permissions.signMessageDescription'
)
}
];
}
static getBudgetRenewalOptions(): BudgetRenewalOption[] {
return [
{
key: BudgetRenewalType.Never,
title: localeString('models.Invoice.never')
},
{
key: BudgetRenewalType.Daily,
title: localeString(
'views.Settings.NostrWalletConnect.budgetRenewal.daily'
)
},
{
key: BudgetRenewalType.Weekly,
title: localeString(
'views.Settings.NostrWalletConnect.budgetRenewal.weekly'
)
},
{
key: BudgetRenewalType.Monthly,
title: localeString(
'views.Settings.NostrWalletConnect.budgetRenewal.monthly'
)
},
{
key: BudgetRenewalType.Yearly,
title: localeString(
'views.Settings.NostrWalletConnect.budgetRenewal.yearly'
)
}
];
}
static getPermissionTypes(): PermissionOption[] {
return [
{
key: PermissionType.FullAccess,
title: localeString(
'views.Settings.NostrWalletConnect.fullAccess'
),
description: localeString(
'views.Settings.NostrWalletConnect.fullAccessDescription'
)
},
{
key: PermissionType.ReadOnly,
title: localeString(
'views.Settings.NostrWalletConnect.readOnly'
),
description: localeString(
'views.Settings.NostrWalletConnect.readOnlyDescription'
)
},
{
key: PermissionType.Custom,
title: localeString('views.Settings.NostrWalletConnect.custom'),
description: localeString(
'views.Settings.NostrWalletConnect.customDescription'
)
}
];
}
static getExpiryPresetButtons(): string[] {
return [
localeString('time.1W'),
localeString('time.1mo'),
localeString('time.12mo'),
localeString('models.Invoice.never'),
localeString('general.custom')
];
}
static getExpiryPresetIndex(expiryAt: Date, createdAt: Date): number {
const expiryDays = NostrConnectUtils.calculateExpiryDays(
expiryAt,
createdAt
)?.toString();
if (!expiryDays) return PRESET_INDEX.NEVER;
if (expiryDays === '7') return PRESET_INDEX.FIRST;
if (expiryDays === '30') return PRESET_INDEX.SECOND;
if (expiryDays === '365') return PRESET_INDEX.THIRD;
return PRESET_INDEX.CUSTOM;
}
static getExpiryDateFromPreset(
presetIndex: number,
customExpiryValue?: number,
customExpiryUnit?: TimeUnit
): Date | undefined {
const now = new Date();
switch (presetIndex) {
case PRESET_INDEX.FIRST:
return new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
case PRESET_INDEX.SECOND:
return new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
case PRESET_INDEX.THIRD:
return new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
case PRESET_INDEX.NEVER:
return undefined;
case PRESET_INDEX.CUSTOM:
if (customExpiryValue && customExpiryUnit) {
return NostrConnectUtils.calculateCustomExpiryDate(
customExpiryValue,
customExpiryUnit
);
}
return new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
default:
return new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
}
}
static calculateCustomExpiryDate(value: number, unit: TimeUnit): Date {
const now = new Date();
const unitLower = unit.toLowerCase();
if (unitLower.includes('hours')) {
return new Date(now.getTime() + value * 60 * 60 * 1000);
} else if (unitLower.includes('days')) {
return new Date(now.getTime() + value * 24 * 60 * 60 * 1000);
} else if (unitLower.includes('weeks')) {
return new Date(now.getTime() + value * 7 * 24 * 60 * 60 * 1000);
} else if (unitLower.includes('months')) {
const newDate = new Date(now);
newDate.setMonth(newDate.getMonth() + value);
return newDate;
} else if (unitLower.includes('years')) {
const newDate = new Date(now);
newDate.setFullYear(newDate.getFullYear() + value);
return newDate;
}
return new Date(now.getTime() + value * 24 * 60 * 60 * 1000);
}
static calculateExpiryDays(expiresAt: Date, createdAt: Date): string {
const expiry = new Date(expiresAt);
expiry.setHours(0, 0, 0, 0);
const created = new Date(createdAt);
created.setHours(0, 0, 0, 0);
const diffTime = expiry.getTime() - created.getTime();
const diffDays = Math.round(diffTime / (1000 * 60 * 60 * 24));
return diffDays > 0 ? diffDays.toString() : '';
}
static getFullAccessPermissions(): Nip47SingleMethod[] {
return [
'get_info',
'get_balance',
'pay_invoice',
'make_invoice',
'lookup_invoice',
'list_transactions',
'sign_message'
];
}
static getReadOnlyPermissions(): Nip47SingleMethod[] {
return [
'get_info',
'get_balance',
'lookup_invoice',
'list_transactions'
];
}
static getPermissionShortDescription(
permission: Nip47SingleMethod
): string {
const descriptions: { [key: string]: string } = {
get_info: localeString(
'views.Settings.NostrWalletConnect.permissions.getInfoShort'
),
get_balance: localeString(
'views.Settings.NostrWalletConnect.permissions.getBalanceShort'
),
pay_invoice: localeString(
'views.Settings.NostrWalletConnect.permissions.payInvoiceShort'
),
make_invoice: localeString(
'views.Settings.NostrWalletConnect.permissions.makeInvoiceShort'
),
lookup_invoice: localeString(
'views.Settings.NostrWalletConnect.permissions.lookupInvoiceShort'
),
list_transactions: localeString(
'views.Settings.NostrWalletConnect.permissions.listTransactionsShort'
)
};
return descriptions[permission] || permission.replace(/_/g, ' ');
}
static getPermissionsForType(
permissionType: PermissionType,
currentPermissions: Nip47SingleMethod[] = []
): { permissions: Nip47SingleMethod[] } {
switch (permissionType) {
case PermissionType.FullAccess:
return {
permissions: NostrConnectUtils.getFullAccessPermissions()
};
case PermissionType.ReadOnly:
return {
permissions: NostrConnectUtils.getReadOnlyPermissions()
};
case PermissionType.Custom:
return {
permissions: currentPermissions
};
default:
return {
permissions: []
};
}
}
static determinePermissionType(
permissions: Nip47SingleMethod[]
): PermissionType {
const connectionPermissions = permissions.slice().sort();
const fullAccessSorted = NostrConnectUtils.getFullAccessPermissions()
.slice()
.sort();
const readOnlySorted = NostrConnectUtils.getReadOnlyPermissions()
.slice()
.sort();
if (
JSON.stringify(connectionPermissions) ===
JSON.stringify(fullAccessSorted)
) {
return PermissionType.FullAccess;
} else if (
JSON.stringify(connectionPermissions) ===
JSON.stringify(readOnlySorted)
) {
return PermissionType.ReadOnly;
}
return PermissionType.Custom;
}
/** True when `pay_invoice` is among granted methods (budget / payment UI applies). */
static hasPaymentPermissions(permissions: Nip47SingleMethod[]): boolean {
return permissions.includes('pay_invoice');
}
static getBudgetRenewalIndex(budgetRenewal?: string): number {
const options = NostrConnectUtils.getBudgetRenewalOptions();
const index = options.findIndex(
(option) => option.key === budgetRenewal
);
return index >= 0 ? index : 0;
}
/**
* Decodes invoice and extracts payment hash, description hash, and expiry time
* Used for NIP-47 transaction creation
* @param paymentRequest - Bolt11 payment request string
* @param fallbackExpirySeconds - Fallback expiry time in seconds (default: 3600)
* @returns Object containing payment hash, description hash, and expiry time
* @throws Error if invoice decoding fails
*/
static async decodeInvoiceTags(
invoice: string,
checkForPaidStatus: boolean = false
): Promise<{
paymentHash: string;
descriptionHash: string;
description: string;
amount: number;
expiryTime: number;
createdAt: number;
isExpired: boolean;
paymentRequest: string;
network: string;
isPaid?: boolean;
}> {
try {
const decoded = Bolt11Utils.decode(invoice);
const paymentHash = decoded.payment_hash || '';
const descriptionHash = decoded.description_hash || '';
const description = decoded.description || '';
const createdAt = decoded.timestamp || 0;
const expireTime = decoded.timeExpireDate || 0;
const currentTime = Math.floor(Date.now() / 1000);
const isExpired = expireTime > 0 && currentTime > expireTime;
let isPaid = false;
if (paymentHash && checkForPaidStatus) {
try {
const result = await BackendUtils.lookupInvoice({
r_hash: paymentHash
});
isPaid = new Invoice(result).isPaid;
} catch (e) {}
}
return {
paymentHash,
descriptionHash,
description,
amount:
decoded.satoshis ||
millisatsToSats(Number(decoded?.millisatoshis)) ||
0,
expiryTime: expireTime,
createdAt,
isExpired,
paymentRequest: decoded.paymentRequest,
network: decoded.network?.bech32 || 'bitcoin',
isPaid
};
} catch (decodeError) {
console.error('Failed to decode invoice:', decodeError);
throw decodeError;
}
}
static async lookupInvoicePaidFromNode(options: {
paymentRequest?: string;
rHash?: string;
}): Promise<boolean> {
const pr = options.paymentRequest?.trim() || '';
if (/^ln/i.test(pr)) {
try {
const decoded = await NostrConnectUtils.decodeInvoiceTags(
pr,
true
);
return !!decoded.isPaid;
} catch {
return false;
}
}
const hash = options.rHash?.trim();
if (!hash) return false;
try {
const result = await BackendUtils.lookupInvoice({ r_hash: hash });
return new Invoice(result).isPaid;
} catch {
return false;
}
}
/**
* Finds a Cashu invoice matching NIP-47 lookup_invoice (BOLT11 string and/or payment_hash).
*/
static async findCashuInvoiceForNwcLookup(
invoices: CashuInvoice[],
request: Nip47LookupInvoiceRequest
): Promise<CashuInvoice | undefined> {
for (const inv of invoices) {
if (inv.getPaymentRequest === request.invoice) {
return inv;
}
if (!request.payment_hash) {
continue;
}
try {
const decoded = await NostrConnectUtils.decodeInvoiceTags(
inv.getPaymentRequest
);
if (decoded.paymentHash === request.payment_hash) {
return inv;
}
} catch {
// Skip malformed/undecodable invoices and continue lookup.
}
}
return undefined;
}
/** NIP-47 transaction for a Cashu invoice matched by lookup_invoice. */
static async buildNip47TransactionForCashuInvoiceLookup(
invoices: CashuInvoice[],
request: Nip47LookupInvoiceRequest
): Promise<Nip47Transaction> {
const invoice = await NostrConnectUtils.findCashuInvoiceForNwcLookup(
invoices || [],
request
);
if (!invoice) {
throw new Error(
localeString(
'stores.NostrWalletConnectStore.error.invoiceNotFound'
)
);
}
const isPaid = invoice.isPaid || false;
const amtSat = invoice.getAmount;
const timestamp =
Number(invoice.getTimestamp) || dateTimeUtils.getCurrentTimestamp();
const expiresAt =
Number(invoice.expires_at) ||
timestamp + DEFAULT_INVOICE_EXPIRY_SECONDS;
return NostrConnectUtils.createNip47Transaction({
type: 'incoming',
state: isPaid ? 'settled' : 'pending',
invoice: invoice.getPaymentRequest,
payment_hash: request.payment_hash!,
amount: satsToMillisats(amtSat || 0),
...(isPaid && {
preimage:
invoice.getPaymentRequest ||
request.invoice ||
request.payment_hash
}),
description: invoice.getMemo,
settled_at: isPaid ? invoice.settleDate.getTime() / 1000 : 0,
created_at: timestamp,
expires_at: expiresAt
});
}
static async buildNip47TransactionForLightningInvoiceLookup(
r_hash: string
): Promise<Nip47Transaction> {
const rawInvoice = await BackendUtils.lookupInvoice({
r_hash
});
if (!rawInvoice || typeof rawInvoice !== 'object') {
throw new Error(
localeString(
'stores.NostrWalletConnectStore.error.invoiceNotFound'
)
);
}
const invoice = new Invoice(rawInvoice);
const now = Math.floor(Date.now() / 1000);
const isExpired =
Number(invoice.expiry) > 0 &&
Number(invoice.timestamp) + Number(invoice.expiry) < now;
const state = invoice.isPaid
? 'settled'
: isExpired
? 'failed'
: 'pending';
return NostrConnectUtils.createNip47Transaction({
type: 'incoming',
state,
invoice: invoice.getPaymentRequest,
payment_hash: invoice.getRHash || r_hash,
amount: satsToMillisats(invoice.getAmount),
description: invoice.getMemo,
...(invoice.isPaid && {
preimage: invoice.getRPreimage
}),
description_hash: invoice.getDescriptionHash,
settled_at: invoice.settleDate.getTime() / 1000,
created_at: invoice.getCreationDate.getTime() / 1000,
expires_at: invoice.getCreationDate.getTime() / 1000
});
}
static async decodeInvoiceTagsForMakeInvoice(
paymentRequest: string,
rHash?: string
): Promise<{
paymentHash: string;
descriptionHash: string;
expiryTime: number;
}> {
const isLightning = rHash !== undefined;
let paymentHash = isLightning ? rHash || '' : '';
let descriptionHash = '';
let expiryTime = 0;
try {
const decoded = await NostrConnectUtils.decodeInvoiceTags(
paymentRequest
);
if (isLightning) {
paymentHash = decoded.paymentHash || rHash!;
descriptionHash = decoded.descriptionHash;
expiryTime =
decoded?.expiryTime ||
dateTimeUtils.getCurrentTimestamp() +
DEFAULT_INVOICE_EXPIRY_SECONDS;
} else {
paymentHash = decoded.paymentHash;
descriptionHash = decoded.descriptionHash;
expiryTime =
decoded?.expiryTime ??
dateTimeUtils.getCurrentTimestamp() +
DEFAULT_INVOICE_EXPIRY_SECONDS;
}
} catch (decodeError) {
if (!isLightning) {
console.error(
'decodeInvoiceTagsForMakeInvoice: failed to decode Cashu invoice tags',
decodeError
);
expiryTime =
dateTimeUtils.getCurrentTimestamp() +
DEFAULT_INVOICE_EXPIRY_SECONDS;
return { paymentHash: '', descriptionHash: '', expiryTime };
}
if (!paymentHash && rHash) {
paymentHash = rHash;
}
if (!paymentHash) {
throw new Error(
localeString(
'stores.NostrWalletConnectStore.error.failedToDecodeInvoice'
)
);
}
expiryTime =
dateTimeUtils.getCurrentTimestamp() +
DEFAULT_INVOICE_EXPIRY_SECONDS;
}
return { paymentHash, descriptionHash, expiryTime };
}
static buildMakeInvoiceSuccessPayload(
connectionDisplayName: string,
request: Nip47MakeInvoiceRequest,
fields: {
paymentRequest: string;
paymentHash: string;
descriptionHash: string;
expiryTime: number;
}
): { result: Nip47Transaction; error: undefined } {
NostrConnectUtils.notifyNwcInvoiceReady(
millisatsToSats(request.amount),
connectionDisplayName,
request.description
);
const result = NostrConnectUtils.createNip47Transaction({
type: 'incoming',
state: 'pending',
invoice: fields.paymentRequest,
payment_hash: fields.paymentHash || '',
amount: request.amount,
description: request.description,
description_hash: fields.descriptionHash,
expires_at: fields.expiryTime
});
return { result, error: undefined };
}
/**
* Creates a NIP-47 transaction object with sensible defaults
* @param params - Transaction parameters
* @param params.type - Transaction type: 'incoming' or 'outgoing'
* @param params.state - Transaction state: 'pending', 'settled', or 'failed'
* @param params.invoice - Payment request/invoice string
* @param params.payment_hash - Payment hash
* @param params.amount - Amount in millisatoshis
* @param params.description - Optional description
* @param params.description_hash - Optional description hash
* @param params.preimage - Optional preimage
* @param params.fees_paid - Optional fees paid in millisatoshis (default: 0)
* @param params.settled_at - Optional settlement timestamp (default: 0)
* @param params.created_at - Optional creation timestamp (default: current time)
* @param params.expires_at - Optional expiry timestamp (default: created_at + 3600)
* @param params.metadata - Optional metadata object
* @returns NIP-47 transaction object
*/
static createNip47Transaction(params: {
type: 'incoming' | 'outgoing';
state: 'pending' | 'settled' | 'failed';
invoice: string;
payment_hash: string;
amount: number;
description?: string;
description_hash?: string;
preimage?: string;
fees_paid?: number;
settled_at?: number;
created_at?: number;
expires_at?: number;
metadata?: any;
}): Nip47Transaction {
const now = dateTimeUtils.getCurrentTimestamp();
const DEFAULT_EXPIRY_SECONDS = 3600;
return {
type: params.type,
state: params.state,
invoice: params.invoice || '',
description: params.description || '',
description_hash: params.description_hash || '',
preimage: params.preimage || '',
payment_hash: params.payment_hash,
amount: params.amount,
fees_paid: params.fees_paid ?? 0,
settled_at: params.settled_at ?? 0,
created_at: params.created_at ?? now,
expires_at:
params.expires_at ??
(params.created_at ?? now) + DEFAULT_EXPIRY_SECONDS,
...(params.metadata && { metadata: params.metadata })
};
}
static isIgnorableError(error: string): boolean {
const msg = error.toLowerCase();
return (
msg.includes('already paid') ||
msg.includes('already been settled') ||
msg.includes('invoice expired') ||
msg.includes('has expired') ||
msg.includes('not payable') ||
msg.includes('invoice canceled') ||
msg.includes('invoice cancelled')
);
}
private static extractInvoiceFromActivity(
activity: ConnectionActivity
): string {
if (activity.invoice?.getPaymentRequest) {
return activity.invoice.getPaymentRequest;
}
if (activity.payment?.getPaymentRequest) {
return activity.payment.getPaymentRequest;
}
return activity.id || '';
}
private static extractPaymentHashFromActivity(
activity: ConnectionActivity
): string {
if (activity.paymentHash) {
return activity.paymentHash;
}
if (activity.payment?.paymentHash) {
return activity.payment.paymentHash;
}
if (activity.invoice) {
const invoiceHash = (activity.invoice as Invoice).payment_hash;
if (invoiceHash) return invoiceHash;
}
return activity.id || '';
}
private static extractAmountFromActivity(
activity: ConnectionActivity
): number {
if (activity.satAmount !== undefined) {
return satsToMillisats(activity.satAmount);
}
if (activity.payment?.getAmount !== undefined) {
return satsToMillisats(Number(activity.payment.getAmount) || 0);
}
if (activity.invoice) {
const invoiceAmount = activity.invoice.getAmount;
if (invoiceAmount !== undefined && invoiceAmount !== null) {
return satsToMillisats(Number(invoiceAmount));
}
const invoiceObj = activity.invoice as any;
if (invoiceObj?.decoded?.satoshis !== undefined) {
return satsToMillisats(Number(invoiceObj.decoded.satoshis));
}
if (invoiceObj?.decoded?.millisatoshis !== undefined) {
return Number(invoiceObj.decoded.millisatoshis);
}
if (invoiceObj?.amount !== undefined) {
return satsToMillisats(Number(invoiceObj.amount));
}
}
return 0;
}
private static extractCreatedAtFromActivity(
activity: ConnectionActivity
): number {
const now = Math.floor(Date.now() / 1000);
if (activity.invoice) {
const invoiceObj = activity.invoice as any;
if (invoiceObj?.decoded?.timestamp !== undefined) {
return Number(invoiceObj.decoded.timestamp);
}
if (invoiceObj?.getTimestamp) {
return Number(invoiceObj.getTimestamp);
}
if (invoiceObj?.created_at) {
return Number(invoiceObj.created_at);
}
if (invoiceObj?.creation_date) {
return Number(invoiceObj.creation_date);
}
}
if (activity.payment) {
const paymentObj = activity.payment as any;
if (paymentObj?.getTimestamp) {
return Number(paymentObj.getTimestamp);
}
if (paymentObj?.created_at) {
return Number(paymentObj.created_at);
}
if (paymentObj?.creation_date) {
return Number(paymentObj.creation_date);
}
if (paymentObj?.timestamp) {
return Number(paymentObj.timestamp);
}
}
return now;
}
private static extractExpiresAtFromActivity(
activity: ConnectionActivity
): number {
const explicitExpiresAt = Number(activity?.expiresAt);
if (explicitExpiresAt > 0) {
return explicitExpiresAt;
}
if (activity.invoice) {
if (activity.invoice.expires_at) {
return Number(activity.invoice.expires_at);
}
if (activity.invoice.expiry) {
return Number(activity.invoice.expiry);
}
const invoiceObj = activity.invoice as any;
if (invoiceObj?.decoded?.timeExpireDate) {
return Number(invoiceObj.decoded.timeExpireDate);
}
}
return 0;
}
private static extractSettledAtFromActivity(
activity: ConnectionActivity
): number {
if (activity.invoice) {
const invoiceObj = activity.invoice as any;
if (invoiceObj?.settled_at) {
return Number(invoiceObj.settled_at);
}
if (invoiceObj?.settle_date) {
return Number(invoiceObj.settle_date);
}
if (invoiceObj?.paid_at) {
return Number(invoiceObj.paid_at);
}
if (invoiceObj?.settleDate) {
const settleDate = invoiceObj.settleDate;
if (settleDate instanceof Date) {
return Math.floor(settleDate.getTime() / 1000);
}
return Number(settleDate);
}
}
if (activity.payment) {
const paymentObj = activity.payment as any;
if (paymentObj?.getTimestamp) {
return Number(paymentObj.getTimestamp);
}
if (paymentObj?.created_at) {
return Number(paymentObj.created_at);
}
if (paymentObj?.creation_date) {
return Number(paymentObj.creation_date);
}
if (paymentObj?.timestamp) {
return Number(paymentObj.timestamp);
}
}
return 0;
}
/**
* Converts a ConnectionActivity to a NIP-47 transaction
* @param activity - Connection activity to convert
* @returns NIP-47 transaction object
*/
static convertConnectionActivityToNip47Transaction(
activity: ConnectionActivity
): Nip47Transaction {
const type: 'incoming' | 'outgoing' =
activity.type === 'make_invoice' ? 'incoming' : 'outgoing';
const state: 'settled' | 'pending' | 'failed' =
activity.status === 'success'
? 'settled'
: activity.status === 'failed' || activity.status === 'expired'
? 'failed'
: 'pending';
const invoice = NostrConnectUtils.extractInvoiceFromActivity(activity);
const paymentHash =
NostrConnectUtils.extractPaymentHashFromActivity(activity);
const amount = NostrConnectUtils.extractAmountFromActivity(activity);
const created_at =
NostrConnectUtils.extractCreatedAtFromActivity(activity);
const expires_at =
NostrConnectUtils.extractExpiresAtFromActivity(activity);
const feesPaid = activity.fees_paid
? satsToMillisats(activity.fees_paid)
: activity.payment
? satsToMillisats(Number(activity.payment.getFee) || 0)
: 0;
const description =
activity.invoice?.getMemo || activity.payment?.getMemo || '';
const preimage =
activity.preimage || activity.payment?.getPreimage || '';
const settled_at =
state === 'settled'
? NostrConnectUtils.extractSettledAtFromActivity(activity) ||
created_at
: 0;
return NostrConnectUtils.createNip47Transaction({
type,
state,
invoice,
payment_hash: paymentHash,
amount,
description,
preimage,
fees_paid: feesPaid,
settled_at,
created_at,
expires_at
});
}
/**
* Converts Cashu payments, invoices, and tokens to NIP-47 transactions
* @param cashuData - Object containing Cashu payments, invoices, and tokens
* @returns Array of NIP-47 transactions
*/
static convertCashuDataToNip47Transactions(cashuData: {
payments?: CashuPayment[];
invoices?: CashuInvoice[];
receivedTokens?: CashuToken[];
sentTokens?: CashuToken[];
}): Nip47Transaction[] {
const transactions: Nip47Transaction[] = [];
// Convert Cashu payments
if (cashuData.payments) {
const paymentTransactions = cashuData.payments.map((payment) => {
const timestamp =
Number(payment.getTimestamp) || Date.now() / 1000;
return NostrConnectUtils.createNip47Transaction({
type: 'outgoing',
state: 'settled',
invoice: payment.getPaymentRequest || '',
payment_hash: payment.paymentHash || '',
amount: satsToMillisats(Number(payment.getAmount) || 0),
description: payment.getMemo,
fees_paid: satsToMillisats(Number(payment.getFee) || 0),
settled_at: Math.floor(timestamp),
created_at: Math.floor(timestamp),
expires_at: 0
});
});
transactions.push(...paymentTransactions);
}
// Convert Cashu invoices
if (cashuData.invoices) {
const invoiceTransactions = cashuData.invoices.map((invoice) => {
const timestamp =
Number(invoice.getTimestamp) || Date.now() / 1000;
const expiresAt = Number(invoice.expires_at) || 0;
return NostrConnectUtils.createNip47Transaction({
type: 'incoming',
state: invoice.isPaid ? 'settled' : 'pending',
invoice: invoice.getPaymentRequest || '',
payment_hash: invoice.quote || '',
amount: satsToMillisats(invoice.getAmount || 0),
description: invoice.getMemo,
settled_at: invoice.isPaid
? Math.floor(
invoice.settleDate?.getTime()
? invoice.settleDate.getTime() / 1000
: Number(invoice.getTimestamp) ||
Date.now() / 1000
)
: 0,
created_at: Math.floor(timestamp),
expires_at: Math.floor(expiresAt)
});
});
transactions.push(...invoiceTransactions);
}
// Convert received tokens
if (cashuData.receivedTokens) {
const receivedTokenTransactions = cashuData.receivedTokens.map(
(token) => {
const receivedAt =
Number(token.received_at) || Date.now() / 1000;
const createdAt = Number(token.created_at) || receivedAt;
return NostrConnectUtils.createNip47Transaction({
type: 'incoming',
state: 'settled',
invoice: '',
payment_hash: token.encodedToken || '',
amount: satsToMillisats(token.getAmount || 0),
description:
token.memo ||
localeString(
'stores.NostrWalletConnectStore.receivedCashuToken'
),
settled_at: Math.floor(receivedAt),
created_at: Math.floor(createdAt),
expires_at: 0
});
}
);
transactions.push(...receivedTokenTransactions);
}
// Convert sent tokens
if (cashuData.sentTokens) {
const sentTokenTransactions = cashuData.sentTokens.map((token) => {
const createdAt = Number(token.created_at) || Date.now() / 1000;
return NostrConnectUtils.createNip47Transaction({
type: 'outgoing',
state: token.spent ? 'settled' : 'pending',
invoice: '',
payment_hash: token.encodedToken || '',
amount: satsToMillisats(token.getAmount || 0),
description:
token.memo ||
localeString(
'stores.NostrWalletConnectStore.sentCashuToken'
),
settled_at: token.spent
? Math.floor(
Number(token.received_at) ||
Number(token.created_at) ||
Date.now() / 1000
)
: 0,
created_at: Math.floor(createdAt),
expires_at: 0
});
});
transactions.push(...sentTokenTransactions);
}
return transactions;
}
/**
* Converts Lightning payments and invoices to NIP-47 transactions
* @param lightningData - Object containing Lightning payments and invoices
* @returns Array of NIP-47 transactions
*/
static convertLightningDataToNip47Transactions(lightningData: {
payments?: Payment[];
invoices?: Invoice[];
}): Nip47Transaction[] {
const transactions: Nip47Transaction[] = [];
// Convert Lightning payments
if (lightningData.payments) {
const paymentTransactions = lightningData.payments.map(
(payment: Payment) => {
const amount = Number(payment.getAmount) || 0;
const timestamp =
Number(payment.getTimestamp) || Date.now() / 1000;
const paymentHash = payment.paymentHash || '';
const invoice = payment.getPaymentRequest || '';
const feesPaid = satsToMillisats(
Number(payment.getFee) || 0
);
const description = payment.getMemo || '';
const preimage = payment.getPreimage || '';
// Determine state based on payment status
let state: 'settled' | 'pending' | 'failed' = 'pending';
if (payment.isFailed) {
state = 'failed';
} else if (!payment.isIncomplete) {
state = 'settled';
}
return NostrConnectUtils.createNip47Transaction({
type: 'outgoing',
state,
invoice,
payment_hash: paymentHash,
amount: satsToMillisats(amount),
description,
preimage,
fees_paid: feesPaid,
settled_at: state === 'settled' ? timestamp : 0,
created_at: timestamp,
expires_at: 0
});
}
);
transactions.push(...paymentTransactions);
}
// Convert Lightning invoices
if (lightningData.invoices) {
const invoiceTransactions = lightningData.invoices.map(
(invoice: Invoice) => {
const amount = Number(invoice.getAmount) || 0;
const timestamp =
Number(invoice.getTimestamp) || Date.now() / 1000;
const paymentHash = invoice.payment_hash || '';
const invoiceString = invoice.getPaymentRequest || '';
const description = invoice.getMemo || '';
const expiresAt = Number(invoice.expires_at) || 0;
let state: 'settled' | 'pending' | 'failed' = 'pending';
if (invoice.isPaid) {
state = 'settled';
}
return NostrConnectUtils.createNip47Transaction({
type: 'incoming',
state,
invoice: invoiceString,
payment_hash: paymentHash,
amount: satsToMillisats(amount),
description,
fees_paid: 0,
settled_at: state === 'settled' ? timestamp : 0,
created_at: timestamp,
expires_at: expiresAt
});
}
);
transactions.push(...invoiceTransactions);
}
return transactions;
}
/**
* Converts on-chain transactions to NIP-47 transactions
* @param transactions - Array of on-chain transactions
* @returns Array of NIP-47 transactions
*/
static convertOnChainTransactionsToNip47Transactions(
transactions: Transaction[]
): Nip47Transaction[] {
return (transactions || []).map((tx: Transaction) => {
const amount = Number(tx.amount);
const type: 'incoming' | 'outgoing' =
amount >= 0 ? 'incoming' : 'outgoing';
let state: 'settled' | 'pending' | 'failed' = 'pending';
if (
tx.status &&
(tx.status === 'failed' ||
tx.status === 'FAILED' ||
tx.status.toLowerCase().includes('fail'))
) {
state = 'failed';
} else if (tx.num_confirmations > 0) {
state = 'settled';
}
const amountMsats = satsToMillisats(Math.abs(amount));
const feesMsats = satsToMillisats(Number(tx.total_fees) || 0);
const timestamp = Number(tx.time_stamp) || 0;
const txHash = tx.tx_hash || tx.txid || '';
return NostrConnectUtils.createNip47Transaction({
type,
state,
invoice: '',
payment_hash: txHash,
amount: amountMsats,
description: tx.note || undefined,
fees_paid: feesMsats,
settled_at: state === 'settled' ? timestamp : 0,
created_at: timestamp,
expires_at: 0, // On-chain transactions don't expire
metadata: {
block_height: tx.block_height,
block_hash: tx.block_hash,
num_confirmations: tx.num_confirmations,
dest_addresses: tx.dest_addresses,
raw_tx_hex: tx.raw_tx_hex
}
});
});
}
/**
* Filters and paginates NIP-47 transactions based on request parameters
* @param transactions - Array of transactions to filter
* @param request - Filter and pagination parameters
* @returns Filtered and paginated transactions with total count
*/
static filterAndPaginateTransactions(
transactions: Nip47Transaction[],
request: Nip47ListTransactionsRequest
): {
transactions: Nip47Transaction[];
totalCount: number;
} {
let filtered = [...transactions];
// Filter by type
if (request.type && request.type.trim() !== '') {
filtered = filtered.filter((tx) => tx.type === request.type);
}
// Filter by from timestamp
if (request.from) {
filtered = filtered.filter((tx) => tx.created_at >= request.from!);
}
// Filter by until timestamp
if (request.until) {
filtered = filtered.filter((tx) => tx.created_at <= request.until!);
}
// Filter by unpaid status
if (request.unpaid !== undefined) {
if (request.unpaid) {
filtered = filtered.filter((tx) => tx.state === 'pending');
} else {
filtered = filtered.filter((tx) => tx.state === 'settled');
}
}
// Calculate pagination
const totalCount = filtered.length;
const offset = Math.max(0, request.offset || 0);
const MAX_LIMIT = 1000;
const limit = request.limit
? Math.min(request.limit, MAX_LIMIT)
: Math.min(totalCount, MAX_LIMIT);
// Apply pagination
const paginated = filtered.slice(offset, offset + limit);
return {
transactions: paginated,
totalCount
};
}
static buildWalletConnectConnectionUrl(
walletPubKeyHex: string,
relayUrl: string,
connectionSecretKeyHex: string,
lud16?: string | null
): string {
let url = `nostr+walletconnect://${walletPubKeyHex}?relay=${relayUrl}&secret=${connectionSecretKeyHex}`;
if (lud16) {
url += `&lud16=${lud16}`;
}
return url;
}
static generateConnectionSecret(
walletPubKeyHex: string,
relayUrl: string,
lud16?: string | null
): {
connectionUrl: string;
connectionPrivateKey: string;
connectionPublicKey: string;
} {
const connectionPrivateKey = generatePrivateKey();
const connectionPublicKey = getPublicKey(connectionPrivateKey);
const connectionUrl = NostrConnectUtils.buildWalletConnectConnectionUrl(
walletPubKeyHex,
relayUrl,
connectionPrivateKey,
lud16
);
return {
connectionUrl,
connectionPrivateKey,
connectionPublicKey
};
}
static notifyOutgoingNwcPayment(
amountSats: number,
connectionDisplayName: string
): void {
const amountLabel = numberWithCommas(amountSats.toString());
const unit = localeString('general.sats');
const title = localeString(NWC_TRAY_NOTIFICATION_KEYS.outgoingTitle);
const body = localeString(NWC_TRAY_NOTIFICATION_KEYS.outgoingBody, {
amount: amountLabel,
unit,
connectionName: connectionDisplayName
});
NostrConnectUtils.emitOsNotification(title, body);
}
static notifyOutgoingNwcPaymentFailed(
amountSats: number,
connectionDisplayName: string,
connectionId: string,
failedActivityId: string
): void {
const amountLabel = numberWithCommas(amountSats.toString());
const unit = localeString('general.sats');
const title = localeString(
NWC_TRAY_NOTIFICATION_KEYS.outgoingFailedTitle
);
const body = localeString(
NWC_TRAY_NOTIFICATION_KEYS.outgoingFailedBody,
{
amount: amountLabel,
unit,
connectionName: connectionDisplayName
}
);
const k = NWC_ACTIVITY_NOTIF_KEYS;
const extras: Record<string, string> = {
[k.action]: k.openActivity,
[k.connectionId]: connectionId,
[k.failedActivityId]: failedActivityId
};
NostrConnectUtils.emitOsNotification(title, body, extras);
}
static notifyNwcInvoiceReady(
amountSats: number,
connectionDisplayName: string,
description?: string
): void {
const amountLabel = numberWithCommas(amountSats.toString());
const unit = localeString('general.sats');
const title = localeString(
NWC_TRAY_NOTIFICATION_KEYS.invoiceReadyTitle
);
let body: string;
if (description) {
body = localeString(
NWC_TRAY_NOTIFICATION_KEYS.invoiceReadyBodyWithDescription,
{
amount: amountLabel,
unit,
connectionName: connectionDisplayName,
description
}
);
} else {
body = localeString(NWC_TRAY_NOTIFICATION_KEYS.invoiceReadyBody, {
amount: amountLabel,
unit,
connectionName: connectionDisplayName
});
}
NostrConnectUtils.emitOsNotification(title, body);
}
private static emitOsNotification(
title: string,
body: string,
extraPayload?: Record<string, string>
): void {
const base = { title, body, ...extraPayload };
if (Platform.OS === 'android') {
// @ts-ignore:next-line
Notifications.postLocalNotification(base);
} else if (Platform.OS === 'ios') {
// @ts-ignore:next-line
Notifications.postLocalNotification({
...base,
sound: 'chime.aiff'
});
}
}
static createNip47Error(
message: string,
code: Nip47ErrorCode = Nip47ErrorCode.INTERNAL_ERROR
): {
result: undefined;
error: {
code: Nip47ErrorCode;
message: string;
};
} {
return {
result: undefined,
error: {
code,
message
}
};
}
/** Integer sats ≥ 0 from a scalar field on a decoded payreq payload. */
private static nonNegativeSats(value: unknown): number {
return Math.max(0, Math.floor(Number(value) || 0));
}
/**
* Sats from the Lightning wallet payreq decode (num_satoshis, satoshis,
* millisatoshis) — same shape as decodePaymentRequest / typical LND decode fields.
*/
private static satsFromLightningDecodedPayReq(decodedPayReq: any): number {
if (!decodedPayReq || typeof decodedPayReq !== 'object') {
return 0;
}
const { nonNegativeSats: n } = NostrConnectUtils;
const fromNumSat = n(decodedPayReq.num_satoshis);
if (fromNumSat > 0) {
return fromNumSat;
}
if (decodedPayReq.satoshis !== undefined) {
const fromSat = n(decodedPayReq.satoshis);
if (fromSat > 0) {
return fromSat;
}
}
if (decodedPayReq.millisatoshis !== undefined) {
const ms = Number(decodedPayReq.millisatoshis) || 0;
if (ms > 0) {
return millisatsToSats(ms);
}
}
return 0;
}
/**
* Amount in sats from a decoded payreq / Invoice (Cashu getPayReq, bolt11 decode, etc.).
* Prefer getRequestAmount when present, then getAmount, then raw BOLT11 fields.
*/
static payInvoiceAmountSatsFromDecodedInvoice(invoice: any): number {
if (!invoice) return 0;
const { nonNegativeSats: n } = NostrConnectUtils;
let sats = n(invoice.getRequestAmount);
if (sats <= 0) {
sats = n(invoice.getAmount);
}
if (sats <= 0 && invoice.satoshis != null) {
sats = n(invoice.satoshis);
}
if (sats <= 0 && invoice.num_satoshis != null) {
sats = n(invoice.num_satoshis);
}
if (sats <= 0 && invoice.millisatoshis != null) {
const ms = Number(invoice.millisatoshis) || 0;
if (ms > 0) {
sats = millisatsToSats(ms);
}
}
return sats;
}
/**
* Single resolver for NWC pay_invoice: Lightning (wallet payreq decode) or
* Cashu (decoded Invoice / payreq model).
*/
static async getPayInvoiceAmountSats(params: {
paymentRequest: string;
/** Lightning: object from decodePaymentRequest */
lightningDecodedPayReq?: any;
/** Cashu: Invoice (or similar) from getPayReq */
decodedInvoice?: any;
}): Promise<number> {
const { paymentRequest, lightningDecodedPayReq, decodedInvoice } =
params;
if (lightningDecodedPayReq) {
const fromLightning =
NostrConnectUtils.satsFromLightningDecodedPayReq(
lightningDecodedPayReq
);
if (fromLightning > 0) {
return fromLightning;
}
const { amount } = await NostrConnectUtils.decodeInvoiceTags(
paymentRequest
);
if (amount > 0) {
if (__DEV__) {
console.log(
'NWC: Lightning decode had no amount; using BOLT11 tags:',
amount
);
}
return amount;
}
return 0;
}
const fromDecoded =
NostrConnectUtils.payInvoiceAmountSatsFromDecodedInvoice(
decodedInvoice
);
if (fromDecoded > 0) {
return fromDecoded;
}
const { amount } = await NostrConnectUtils.decodeInvoiceTags(
paymentRequest
);
return amount > 0 ? amount : 0;
}
static async pingRelay(relayUrl: string): Promise<{
status: boolean;
error?: string | null;
}> {
let relay: ReturnType<typeof relayInit> | undefined;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
relay = relayInit(relayUrl);
const timeout = new Promise<void>((_, reject) => {
timeoutId = setTimeout(
() =>
reject(
new Error(
localeString(
'views.Settings.NostrWalletConnect.connectionTimeout'
)
)
),
5000
);
});
await Promise.race([relay.connect(), timeout]);
return { status: true, error: null };
} catch (_e) {
return {
status: false,
error: localeString(
'stores.NostrWalletConnectStore.error.failedToConnectRelay',
{ relayUrl }
)
};
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
relay?.close();
}
}
}