From 72ba1c48c25c0a54efed2457fdc5bcb69adbc932 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Mon, 16 Dec 2024 05:29:03 -0800 Subject: [PATCH] Can create and take offers using Nostr --- package-lock.json | 79 ++++++++++++++++++- package.json | 1 + src/adapters/nostr.js | 8 ++ src/controllers/rest-api/offer/controller.js | 4 +- src/controllers/rest-api/order/controller.js | 4 +- src/controllers/timer-controllers.js | 1 + src/use-cases/offer/index.js | 82 +++++++++++++++++--- src/use-cases/order.js | 19 +---- 8 files changed, 166 insertions(+), 32 deletions(-) diff --git a/package-lock.json b/package-lock.json index a3a5e46..1cca1b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ "mongoose": "5.13.14", "node-fetch": "npm:@achingbrain/node-fetch@2.6.7", "nodemailer": "6.7.5", + "nostr-tools": "2.10.4", "p2wdb-esm": "2.2.9", "passport-local": "1.0.0", "public-ip": "6.0.1", @@ -7610,6 +7611,51 @@ "ws": "8.18.0" } }, + "node_modules/bch-nostr/node_modules/@noble/ciphers": { + "version": "0.5.3", + "resolved": "http://94.130.170.209:4873/@noble%2fciphers/-/ciphers-0.5.3.tgz", + "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/bch-nostr/node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "http://94.130.170.209:4873/@noble%2fcurves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/bch-nostr/node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "http://94.130.170.209:4873/@noble%2fhashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/bch-nostr/node_modules/@noble/hashes": { + "version": "1.3.1", + "resolved": "http://94.130.170.209:4873/@noble%2fhashes/-/hashes-1.3.1.tgz", + "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/bch-nostr/node_modules/apidoc": { "version": "0.53.0", "resolved": "http://94.130.170.209:4873/apidoc/-/apidoc-0.53.0.tgz", @@ -7680,6 +7726,31 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/bch-nostr/node_modules/nostr-tools": { + "version": "2.10.1", + "resolved": "http://94.130.170.209:4873/nostr-tools/-/nostr-tools-2.10.1.tgz", + "integrity": "sha512-fnAxLi92UgyMAEw4fMEhDKzH53uBJ0WkkVPTNfX0b3bspeWWn0n5tDZtKRJbvW0wAGOPmU6LYWi/IKPOsq3p2Q==", + "license": "Unlicense", + "dependencies": { + "@noble/ciphers": "^0.5.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.1", + "@scure/base": "1.1.1", + "@scure/bip32": "1.3.1", + "@scure/bip39": "1.2.1" + }, + "optionalDependencies": { + "nostr-wasm": "v0.1.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/bch-token-sweep": { "version": "2.2.1", "resolved": "http://94.130.170.209:4873/bch-token-sweep/-/bch-token-sweep-2.2.1.tgz", @@ -18234,9 +18305,9 @@ } }, "node_modules/nostr-tools": { - "version": "2.10.1", - "resolved": "http://94.130.170.209:4873/nostr-tools/-/nostr-tools-2.10.1.tgz", - "integrity": "sha512-fnAxLi92UgyMAEw4fMEhDKzH53uBJ0WkkVPTNfX0b3bspeWWn0n5tDZtKRJbvW0wAGOPmU6LYWi/IKPOsq3p2Q==", + "version": "2.10.4", + "resolved": "http://94.130.170.209:4873/nostr-tools/-/nostr-tools-2.10.4.tgz", + "integrity": "sha512-biU7sk+jxHgVASfobg2T5ttxOGGSt69wEVBC51sHHOEaKAAdzHBLV/I2l9Rf61UzClhliZwNouYhqIso4a3HYg==", "license": "Unlicense", "dependencies": { "@noble/ciphers": "^0.5.1", @@ -18247,7 +18318,7 @@ "@scure/bip39": "1.2.1" }, "optionalDependencies": { - "nostr-wasm": "v0.1.0" + "nostr-wasm": "0.1.0" }, "peerDependencies": { "typescript": ">=5.0.0" diff --git a/package.json b/package.json index ca64ad7..729ed63 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "mongoose": "5.13.14", "node-fetch": "npm:@achingbrain/node-fetch@2.6.7", "nodemailer": "6.7.5", + "nostr-tools": "2.10.4", "p2wdb-esm": "2.2.9", "passport-local": "1.0.0", "public-ip": "6.0.1", diff --git a/src/adapters/nostr.js b/src/adapters/nostr.js index 2ff4089..07bdb63 100644 --- a/src/adapters/nostr.js +++ b/src/adapters/nostr.js @@ -6,6 +6,7 @@ import BchNostr from 'bch-nostr' import { RelayPool } from 'nostr' import RetryQueue from '@chris.troutner/retry-queue' +import * as nip19 from 'nostr-tools/nip19' class NostrAdapter { constructor (localConfig = { nostrRelay: '', nostrTopic: '' }) { @@ -37,6 +38,7 @@ class NostrAdapter { this.start = this.start.bind(this) this.post = this.post.bind(this) this.read = this.read.bind(this) + this.eventId2note = this.eventId2note.bind(this) } // Create nostr keys. @@ -123,6 +125,12 @@ class NostrAdapter { throw error } } + + // Convert an Event ID into a `noteabc..` syntax that Astral expects. + // This can be used to generate a link to Astral to display the post. + eventId2note (eventId) { + return nip19.noteEncode(eventId) + } } export default NostrAdapter diff --git a/src/controllers/rest-api/offer/controller.js b/src/controllers/rest-api/offer/controller.js index 10daa64..198c3d8 100644 --- a/src/controllers/rest-api/offer/controller.js +++ b/src/controllers/rest-api/offer/controller.js @@ -106,9 +106,9 @@ class OfferRESTControllerLib { const nostrEventId = ctx.request.body.nostrEventId - const eventId = await this.useCases.offer.takeOffer(nostrEventId) + const { eventId, noteId } = await this.useCases.offer.takeOffer(nostrEventId) - ctx.body = { eventId } + ctx.body = { eventId, noteId } } catch (err) { wlogger.error('Error in takeOffer() REST API handler.') this.handleError(ctx, err) diff --git a/src/controllers/rest-api/order/controller.js b/src/controllers/rest-api/order/controller.js index 555a802..6c49209 100644 --- a/src/controllers/rest-api/order/controller.js +++ b/src/controllers/rest-api/order/controller.js @@ -37,9 +37,9 @@ class OrderRESTControllerLib { const orderObj = ctx.request.body.order console.log('orderObj: ', orderObj) - const hash = await _this.useCases.order.createOrder(orderObj) + const { eventId, noteId } = await _this.useCases.order.createOrder(orderObj) - ctx.body = { hash } + ctx.body = { eventId, noteId } } catch (err) { // console.log(`err.message: ${err.message}`) // console.log('err: ', err) diff --git a/src/controllers/timer-controllers.js b/src/controllers/timer-controllers.js index 7fa35c7..be9725e 100644 --- a/src/controllers/timer-controllers.js +++ b/src/controllers/timer-controllers.js @@ -30,6 +30,7 @@ class TimerControllers { this.gcOffers = this.gcOffers.bind(this) this.checkDupOffers = this.checkDupOffers.bind(this) this.loadOffers = this.loadOffers.bind(this) + // State this.gcOrdersInt = null this.gcOffersInt = null diff --git a/src/use-cases/offer/index.js b/src/use-cases/offer/index.js index 017ed91..08fd071 100644 --- a/src/use-cases/offer/index.js +++ b/src/use-cases/offer/index.js @@ -49,11 +49,25 @@ class OfferUseCases { this.retryQueue = new RetryQueue({ retryPeriod: 1000, attempts: 3 }) // Bind 'this' object to functions + this.createOffer = this.createOffer.bind(this) this.detectNsfw = this.detectNsfw.bind(this) + this.categorizeToken = this.categorizeToken.bind(this) + this.listOffers = this.listOffers.bind(this) + this.listNftOffers = this.listNftOffers.bind(this) + this.listFungibleOffers = this.listFungibleOffers.bind(this) + this.takeOffer = this.takeOffer.bind(this) + this.ensureFunds = this.ensureFunds.bind(this) + this.findOfferByEvent = this.findOfferByEvent.bind(this) + this.findOfferByTxid = this.findOfferByTxid.bind(this) + this.acceptCounterOffer = this.acceptCounterOffer.bind(this) + this.removeDuplicateOffers = this.removeDuplicateOffers.bind(this) + this.removeStaleOffers = this.removeStaleOffers.bind(this) + this.flagOffer = this.flagOffer.bind(this) this.loadOffers = this.loadOffers.bind(this) // State this.seenOffers = [] + this.seenCounterOffers = [] } // This method is called by timer controller to load offers from a Nostr topic. @@ -373,18 +387,20 @@ class OfferUseCases { // TODO: This will trigger the webhook. Find some way of triggering the // webhook on new offers, but not on counteroffers const nostrData = { - wif: this.adapters.wallet.bchWallet.walletInfo.privateKey, - data: takenOfferInfo, - appId: this.config.p2wdbAppId + // wif: this.adapters.wallet.bchWallet.walletInfo.privateKey, + data: takenOfferInfo + // appId: this.config.p2wdbAppId } const resultEventId = await this.adapters.nostr.post(JSON.stringify(nostrData)) + const noteId = this.adapters.nostr.eventId2note(resultEventId) + // Delete the Offer from the database, so that the user doesn't attempt // to take the offer more than once. // offerInfo.remove() // Return the P2WDB CID - return resultEventId + return { eventId: resultEventId, noteId } // return 'fake-hash' } catch (err) { @@ -501,22 +517,68 @@ class OfferUseCases { // and broadcasts the transaction to accept the Counter Offer. async acceptCounterOffer (offerData) { try { - console.log(`acceptCounterOffer() offerData: ${JSON.stringify(offerData, null, 2)}`) + // console.log(`acceptCounterOffer() offerData: ${JSON.stringify(offerData, null, 2)}`) + + // Quickly skip over offers that have already been processed. + const eventId = offerData.data.nostrEventId + if (this.seenOffers.includes(eventId)) { + // console.log(`Offer with event ID ${eventId} already processed. Skipping.`) + return false + } // See if this instance of bch-dex is managing the Order associated with // the incoming Counter Offer. - console.log('ping11') + // Note : this should be handled by nostrEvent id or UtxoId? // const orderHash = offerData.data.nostrEventId let orderData = {} try { orderData = await this.orderUseCase.findOrderByUtxo(offerData) - console.log(`orderData: ${JSON.stringify(orderData, null, 2)}`) + // console.log(`orderData: ${JSON.stringify(orderData, null, 2)}`) } catch (err) { console.log('Order matching this Counter Offer is not managed by this instance of bch-dex. Skipping.') + + // Add order to list of seen orders, so that we don't spent time trying to validate it again. + this.seenOffers.push(eventId) + + return 'N/A' + } + + // Verify that the UTXO for sale is valid. + let utxoStatus = null + try { + // Get the status of the UTXO associate with this Offer. + const utxo = { + tx_hash: offerData.data.utxoTxid, + tx_pos: offerData.data.utxoVout + } + // console.log(`Checking this UTXO: ${JSON.stringify(utxo, null, 2)}`) + + // utxoStatus = await this.adapters.wallet.bchWallet.utxoIsValid(utxo) + utxoStatus = await this.retryQueue.addToQueue(this.adapters.wallet.bchWallet.utxoIsValid, utxo) + console.log('utxoStatus: ', utxoStatus) + } catch (err) { + console.log('acceptCounterOffer() err validating UTXO: ', err) + + // Handle corner case of bad-data in the Offer model. + if (err.isAxiosError) { + console.log('Error trying to contact wallet service: ', err) + return 'N/A' + } else { + return 'N/A' + } + } + + // If the Offer UTXO is spent, exit. + if (utxoStatus === false) { + // Add order to list of seen orders, so that we don't spent time trying to validate it again. + this.seenOffers.push(eventId) + + console.log(`Aborting Counter Offer from Event ID ${eventId}`) + console.log(`https://astral.psfoundation.info/${this.adapters.nostr.eventId2note(eventId)}`) + return 'N/A' } - console.log('ping12') // Deserialize the partially signed transaction. const txHex = offerData.data.partialTxHex @@ -718,7 +780,9 @@ class OfferUseCases { } if (offerObj.data.dataType === 'counter-offer') { - console.log('Counter offer detected: ', offerObj) + // console.log('Counter offer detected: ', offerObj) + // console.log('Counter offer detected: ', offerObj.data.nostrEventId) + console.log(`Counter offer detected: https://astral.psfoundation.info/${this.adapters.nostr.eventId2note(offerObj.data.nostrEventId)}`) await this.acceptCounterOffer(offerObj) } diff --git a/src/use-cases/order.js b/src/use-cases/order.js index 1048a3d..68aa2e1 100644 --- a/src/use-cases/order.js +++ b/src/use-cases/order.js @@ -104,7 +104,9 @@ class OrderLib { const order = new this.OrderModel(orderEntity) await order.save() - return eventId + const noteId = this.adapters.nostr.eventId2note(eventId) + + return { eventId, noteId } } catch (err) { // console.log("Error in use-cases/entry.js/createEntry()", err.message) wlogger.error('Error in use-cases/order.js/createOrder(): ', err) @@ -196,18 +198,7 @@ class OrderLib { // Retrieve an Order model from the database. Find it by its UTXO (TXID & Vout) async findOrderByUtxo (offerData = {}) { try { - console.log('findOrderByUtxo() offerData: ', offerData) - - // if (typeof nostrEventId !== 'string' || !nostrEventId) { - // throw new Error('nostrEventId must be a string') - // } - - console.log('ping01') - - // const allOrders = await this.OrderModel.find({}) - // console.log('allOrders: ', allOrders) - - console.log('ping02') + // console.log('findOrderByUtxo() offerData: ', offerData) // const order = await this.OrderModel.findOne({ nostrEventId }) const order = await this.OrderModel.findOne({ utxoTxid: offerData.data.utxoTxid, utxoVout: offerData.data.utxoVout }) @@ -215,8 +206,6 @@ class OrderLib { // const order = await this.OrderModel.findOne({ tokenId: offerData.data.tokenId }) console.log('findOrderByUtxo() order: ', order) - console.log('ping03') - if (!order) { throw new Error('order not found') }