From 664a9028b975bf2e9f6dc3b71f4530c7088e7200 Mon Sep 17 00:00:00 2001 From: minibits-cash Date: Wed, 21 Aug 2024 16:21:25 +0200 Subject: [PATCH] Nostr Wallet Connect - proof of concept implementation for testing --- package.json | 2 +- src/i18n_messages/en.json | 2 +- src/models/NwcStore.ts | 166 ++++++++++++++++++++++------ src/screens/SettingsScreen.tsx | 2 +- src/services/nostrService.ts | 2 +- src/services/notificationService.ts | 4 +- 6 files changed, 139 insertions(+), 39 deletions(-) diff --git a/package.json b/package.json index e31229f6..93ef9d46 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minibits_wallet", - "version": "0.1.8-beta.48", + "version": "0.1.8-beta.49", "private": true, "scripts": { "android:clean": "cd android && ./gradlew clean", diff --git a/src/i18n_messages/en.json b/src/i18n_messages/en.json index 5cda348c..92380514 100644 --- a/src/i18n_messages/en.json +++ b/src/i18n_messages/en.json @@ -38,7 +38,7 @@ "confirm": "Confirm", "confirmAlertTitle": "Confirmation", "confirmCreateInvoice": "Create invoice", - "connectedParam": "Connected: %{param}", + "connectedParam": "%{param} relays connected", "copy": "Copy", "copyFailParam": "Couldn't copy %{param}", "create": "Create", diff --git a/src/models/NwcStore.ts b/src/models/NwcStore.ts index 5b6c6d19..65dd2d1f 100644 --- a/src/models/NwcStore.ts +++ b/src/models/NwcStore.ts @@ -7,6 +7,8 @@ import { isStateTreeNode, detach, } from 'mobx-state-tree' +import QuickCrypto from 'react-native-quick-crypto' +import {secp256k1} from '@noble/curves/secp256k1' import {withSetPropAction} from './helpers/withSetPropAction' import {log} from '../services/logService' import EventEmitter from '../utils/eventEmitter' @@ -15,11 +17,12 @@ import { KeyChain, KeyPair, NostrClient, NostrEvent, NostrUnsignedEvent, Transac import AppError, { Err } from '../utils/AppError' import { LightningUtils } from '../services/lightning/lightningUtils' import { addSeconds } from 'date-fns/addSeconds' -import { TransactionStatus } from './Transaction' +import { Transaction, TransactionStatus, TransactionType } from './Transaction' import { MeltQuoteResponse } from '@cashu/cashu-ts' import { WalletStore } from './WalletStore' import { Proofs } from './ProofsStore' import { isSameDay } from 'date-fns/isSameDay' +import { NotificationService } from '../services/notificationService' type NwcError = { result_type: string, @@ -45,6 +48,21 @@ type NwcResponse = { result: any } +type nwcTransaction = { + type: string, + invoice: string, + description: string | null, + preimage: string | null, + payment_hash: string | null, + amount: number, + fees_paid: number | null, + created_at: number, + settled_at: number | null + expires_at: number | null +} + +const nwcPngUrl = 'https://1044827509-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F0JQfRPMJ4uO7z9wmnAOK%2Fuploads%2FVO76qdgzHHzWSDsHQXdu%2FGroup%201000001143%20(1).png?alt=media&token=0fdb70b7-bb19-4bed-a752-a0560585c2f4&width=512&dpr=1&quality=100&sign=57c41699&sv=1' + export const NwcConnectionModel = types.model('NwcConnection', { name: types.string, connectionPubkey: types.string, @@ -62,20 +80,21 @@ export const NwcConnectionModel = types.model('NwcConnection', { const {walletProfileStore} = rootStore return walletProfileStore.pubkey }, - get relays(): string[] { - /* const publicRelays = NostrClient.getDefaultRelays() - const minibitsRelays = NostrClient.getMinibitsRelays() - - return [...publicRelays, ...minibitsRelays]*/ + get connectionRelays(): string[] { return NostrClient.getMinibitsRelays() }, + get responseRelays(): string[] { + const minibitsRelays = NostrClient.getMinibitsRelays() + const publicRelays = ["wss://relay.primal.net", "wss://relay.damus.io", "wss://relay.8333.space/", "wss://relay.snort.social", "wss://nostr.mutinywallet.com"] + return [...publicRelays, ...minibitsRelays] + }, get supportedMethods() { - return ['pay_invoice', 'get_balance', 'get_info'] + return ['pay_invoice', 'get_balance', 'get_info', 'list_transactions'] } })) .views(self => ({ get connectionString(): string { - return `nostr+walletconnect://${self.walletPubkey}?relay=${self.relays.join('&relay=')}&secret=${self.connectionSecret}` + return `nostr+walletconnect://${self.walletPubkey}?relay=${self.connectionRelays.join('&relay=')}&secret=${self.connectionSecret}` }, })) .actions(self => ({ @@ -113,33 +132,50 @@ export const NwcConnectionModel = types.model('NwcConnection', { }, })) .actions(self => ({ - sendResponse: flow(function* sendResponse(nwcResponse: NwcResponse | NwcError) { + sendResponse: flow(function* sendResponse(nwcResponse: NwcResponse | NwcError, requestEvent: NostrEvent) { log.trace('[Nwc.sendResponse] start', {nwcResponse}) - if(!self.eventInFlight) { - throw new AppError(Err.VALIDATION_ERROR, 'Missing eventInFlight', {caller: 'Nwc.sendResponse'}) - } - // eventInFlight.pubkey should = connectionPubkey + log.trace('Encrypt response', {connectionPubkey: self.connectionPubkey, requestEventPubkey: requestEvent.pubkey}) + const encryptedContent = yield NostrClient.encryptNip04( - self.eventInFlight.pubkey, + requestEvent.pubkey, JSON.stringify(nwcResponse) ) const responseEvent: NostrUnsignedEvent = { pubkey: self.walletPubkey, kind: NwcKind.response, - tags: [["p", self.eventInFlight.pubkey], ["e", self.eventInFlight.id]], + tags: [["p", requestEvent.pubkey], ["e", requestEvent.id]], content: encryptedContent, created_at: Math.floor(Date.now() / 1000) } - self.resetEventInFlight() - self.resetMeltQuoteInFlight() + // notify errors + if((nwcResponse as NwcError).error) { + let body = '' + if(nwcResponse.result_type === 'pay_invoice') { + body = 'Pay invoice error: ' + } + + if(nwcResponse.result_type === 'get_balance') { + body = 'Get balance error: ' + } + + if(nwcResponse.result_type === 'list_transactions') { + body = 'List transactions error: ' + } + + yield NotificationService.createLocalNotification( + `${self.name} - Nostr Wallet Connect`, + body + (nwcResponse as NwcError).error.message, + nwcPngUrl + ) + } const publishedEvent: Event | undefined = yield NostrClient.publish( responseEvent, - self.relays + self.responseRelays ) return publishedEvent @@ -164,6 +200,13 @@ export const NwcConnectionModel = types.model('NwcConnection', { } as NwcResponse self.setRemainingDailyLimit(updatedLimit) + + // notify completed payment + yield NotificationService.createLocalNotification( + `${self.name} - Nostr Wallet Connect`, + `Invoice for ${result.transaction.amount} SATS paid${result.transaction.fee > 0 && ', fee ' + result.transaction.fee + ' SATS'}. Remaining today's limit is ${self.remainingDailyLimit} SATS`, + nwcPngUrl + ) } else { nwcResponse = { @@ -172,11 +215,49 @@ export const NwcConnectionModel = types.model('NwcConnection', { } as NwcError } - yield self.sendResponse(nwcResponse) + if(!self.eventInFlight) { + throw new AppError(Err.VALIDATION_ERROR, 'Missing eventInFLight') + } + + yield self.sendResponse(nwcResponse, self.eventInFlight) + + self.resetEventInFlight() + self.resetMeltQuoteInFlight() }) })) .actions(self => ({ + handleListTransactions (nwcRequest: NwcRequest): NwcResponse { + const rootStore = getRootStore(self) + const {transactionsStore} = rootStore + const lightningTransactions = transactionsStore.all.filter( + t => t.type === TransactionType.TOPUP || + t.type === TransactionType.TRANSFER + ) + const transactions = lightningTransactions.map(t => { + return { + type: t.type === TransactionType.TOPUP ? 'incoming' : 'outgoing', + invoice: null, + description: t.memo, + preimage: null, + payment_hash: null, + amount: t.amount, + fees_paid: t.fee, + created_at: Math.floor(t.createdAt!.getTime() / 1000), + settled_at: null, + expires_at: null + } + }) + + const nwcResponse = { + result_type: nwcRequest.method, + result: { + transactions + } + } + + return nwcResponse + }, handleGetInfo (nwcRequest: NwcRequest): NwcResponse { const nwcResponse = { result_type: nwcRequest.method, @@ -193,11 +274,8 @@ export const NwcConnectionModel = types.model('NwcConnection', { return nwcResponse }, - handleGetBalance (nwcRequest: NwcRequest): NwcResponse { - const rootStore = getRootStore(self) - const {proofsStore} = rootStore - - const balance = proofsStore.getMintBalanceWithMaxBalance('sat')?.balances.sat + handleGetBalance(nwcRequest: NwcRequest) { + const balance = self.remainingDailyLimit let balanceMsat = 0 if(balance && balance > 0) { @@ -206,6 +284,13 @@ export const NwcConnectionModel = types.model('NwcConnection', { balanceMsat = 0 } + // notify balance request + /* yield NotificationService.createLocalNotification( + `${self.name} - Nostr Wallet Connect`, + `Daily balance is ${balance} SATS`, + nwcPngUrl + )*/ + const nwcResponse = { result_type: nwcRequest.method, result: { @@ -217,13 +302,21 @@ export const NwcConnectionModel = types.model('NwcConnection', { }, handlePayInvoice: flow(function* handlePayInvoice(nwcRequest: NwcRequest) { log.trace('[Nwc.handlePayInvoice] start') + const encoded = nwcRequest.params.invoice const walletStore = self.getWalletStore() const proofsStore = self.getProofsStore() try { const invoice = LightningUtils.decodeInvoice(encoded) - const {amount: amountToPay, expiry, description, timestamp} = LightningUtils.getInvoiceData(invoice) + + const { + amount: amountToPay, + expiry, + description, + timestamp + } = LightningUtils.getInvoiceData(invoice) + const invoiceExpiry = addSeconds(new Date(timestamp as number * 1000), expiry as number) const mintBalance = proofsStore.getMintBalanceWithMaxBalance('sat') @@ -308,10 +401,14 @@ export const NwcConnectionModel = types.model('NwcConnection', { switch (nwcRequest.method) { case 'get_info': nwcResponse = self.handleGetInfo(nwcRequest) - break + break + case 'list_transactions': + nwcResponse = self.handleListTransactions(nwcRequest) + break case 'get_balance': nwcResponse = self.handleGetBalance(nwcRequest) - break + break + case 'pay_invoice': // payInvoice is long running and async, make sure we do not overwrite event // by a new one @@ -343,8 +440,9 @@ export const NwcConnectionModel = types.model('NwcConnection', { log.error(message, {nwcRequest}) } + // needs to be set before sendResponse but after switch / pay_invoice self.setEventInFlight(requestEvent) - yield self.sendResponse(nwcResponse) + yield self.sendResponse(nwcResponse, requestEvent) }), })) @@ -377,7 +475,7 @@ export const NwcConnectionModel = types.model('NwcConnection', { const pool = NostrClient.getRelayPool() - const sub = pool.sub(self.relays , filters) + const sub = pool.sub(self.connectionRelays , filters) const relaysConnections = pool._conn const rootStore = getRootStore(self) const {relaysStore} = rootStore @@ -410,7 +508,9 @@ export const NwcConnectionModel = types.model('NwcConnection', { sub.on('event', async (event: NostrEvent) => { if (event.kind != NwcKind.request) { return - } + } + + // log.trace('[receiveNwcEvents] incoming event', {event}) if(self.eventInFlight && self.eventInFlight.id === event.id) { log.warn( @@ -461,7 +561,7 @@ export const NwcStoreModel = types return self.nwcConnections }, get supportedMethods() { - return ['pay_invoice', 'get_balance', 'get_info'] + return ['pay_invoice', 'get_balance', 'get_info', 'list_transactions'] } })) .actions(self => ({ @@ -499,7 +599,7 @@ export const NwcStoreModel = types authors: [newConnection.walletPubkey], }] - const existingInfoEvent = yield NostrClient.getEvent(newConnection.relays, filters) + const existingInfoEvent = yield NostrClient.getEvent(newConnection.responseRelays, filters) if(!existingInfoEvent) { // publish info replacable event // seems to be a relict replaced by get_info request? @@ -513,7 +613,7 @@ export const NwcStoreModel = types NostrClient.publish( infoEvent, - newConnection.relays + newConnection.responseRelays ) } }), diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 668f72fe..5408a4c8 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -249,7 +249,7 @@ export const SettingsScreen: FC = observer( /> 1 ? 's' : ''}`} + subText={`${nwcStore.all.length} allowed app${nwcStore.all.length > 1 ? 's' : ''}`} LeftComponent={