1655 lines
58 KiB
TypeScript
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();
|
|
}
|
|
}
|
|
}
|