From 0a8bb522413f9371e41568e30e1c0ec769b92bec Mon Sep 17 00:00:00 2001 From: Daniel Gonzalez Date: Mon, 2 Dec 2024 22:19:54 -0400 Subject: [PATCH] feat(offer): Trigger createOffer() from Nostr topic --- src/adapters/localdb/models/offer.js | 3 +- src/controllers/timer-controllers.js | 25 +- src/use-cases/offer/index.js | 91 ++++++-- .../controllers/timer-controllers.unit.js | 87 +++++-- test/unit/mocks/use-cases/index.js | 12 + test/unit/mocks/use-cases/offer-mock-data.js | 21 +- test/unit/use-cases/offer.use-case.unit.js | 213 ++++++++++++------ 7 files changed, 330 insertions(+), 122 deletions(-) diff --git a/src/adapters/localdb/models/offer.js b/src/adapters/localdb/models/offer.js index e38702a..5b1a375 100644 --- a/src/adapters/localdb/models/offer.js +++ b/src/adapters/localdb/models/offer.js @@ -34,7 +34,8 @@ const Offer = new mongoose.Schema({ // SWaP Protocol Properties lokadId: { type: String }, messageType: { type: Number }, - messageClass: { type: Number } + messageClass: { type: Number }, + nostrEventId: { type: String } // Nostr Event Id. }) diff --git a/src/controllers/timer-controllers.js b/src/controllers/timer-controllers.js index 0a6ee27..7fa35c7 100644 --- a/src/controllers/timer-controllers.js +++ b/src/controllers/timer-controllers.js @@ -29,13 +29,12 @@ class TimerControllers { this.gcOrders = this.gcOrders.bind(this) 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 this.checkDupOffersInt = null - - this.startTimers() + this.loadOffersInt = null } // Start all the time-based controllers. @@ -43,6 +42,8 @@ class TimerControllers { this.gcOrdersInt = setInterval(this.gcOrders, 60000 * 5) this.gcOffersInt = setInterval(this.gcOffers, 60000 * 5) this.checkDupOffersInt = setInterval(this.checkDupOffers, 60000 * 4.5) + this.loadOffersInt = setInterval(this.loadOffers, 60000 * 2) + return true } stopTimers () { @@ -55,9 +56,11 @@ class TimerControllers { gcOrders () { try { this.useCases.order.removeStaleOrders() + return true } catch (err) { // Do not throw an error. This is a top-level function. console.log('Error in timer-controllers.js/gcOrders(): ', err) + return false } } @@ -65,9 +68,11 @@ class TimerControllers { gcOffers () { try { this.useCases.offer.removeStaleOffers() + return true } catch (err) { // Do not throw an error. This is a top-level function. console.log('Error in timer-controllers.js/gcOffers(): ', err) + return false } } @@ -75,9 +80,23 @@ class TimerControllers { checkDupOffers () { try { this.useCases.offer.removeDuplicateOffers() + return true } catch (err) { // Do not throw an error. This is a top-level function. console.log('Error in timer-controllers.js/checkDupOffers(): ', err) + return false + } + } + + // Load offers From nostr . + async loadOffers () { + try { + await this.useCases.offer.loadOffers() + return true + } catch (err) { + // Do not throw an error. This is a top-level function. + console.log('Error in timer-controllers.js/loadOffers(): ', err) + return false } } } diff --git a/src/use-cases/offer/index.js b/src/use-cases/offer/index.js index 61a23a6..3bcb52c 100644 --- a/src/use-cases/offer/index.js +++ b/src/use-cases/offer/index.js @@ -37,7 +37,7 @@ class OfferUseCases { this.orderUseCase = localConfig.order if (!this.orderUseCase) { throw new Error( - 'Instance of Offer Use Cases must be passed in when instantiating Offer Use Cases library.' + 'Instance of Order Use Cases must be passed in when instantiating Offer Use Cases library.' ) } @@ -50,17 +50,19 @@ class OfferUseCases { // Bind 'this' object to functions this.detectNsfw = this.detectNsfw.bind(this) + this.loadOffers = this.loadOffers.bind(this) } - // This method is called by the POST /offer REST API controller, which is - // triggered by a P2WDB webhook. + // + // This method is called by timer controller to load offers fron nostr server async createOffer (offerObj) { try { console.log('Use Case createOffer(offerObj): ', offerObj) - // Return if Offer already exists in database with the same P2WDB CID. + // Return if Offer already exists in database with the same utxo transaction id. try { - await this.findOfferByHash(offerObj.hash) + await this.findOfferByTxid(offerObj.data.utxoTxid) console.log('Offer already found in local database.') return false @@ -71,9 +73,9 @@ class OfferUseCases { // Input Validation // TODO: This is a hack. Find a better way to protect against the corner- // case of counter-offers getting routed here. - if (offerObj.data.dataType === 'counter-offer') { - console.log('WARN: Counter Offer innappropriately routed to createOffer()') - } + // if (offerObj.data.dataType === 'counter-offer') { + // console.log('WARN: Counter Offer innappropriately routed to createOffer()') + // } // Verify that UTXO in offer is unspent. If it is spent, then ignore the // offer. @@ -90,6 +92,9 @@ class OfferUseCases { // A new offer gets a status of 'posted' offerObj.data.offerStatus = 'posted' + // Set timestamp + offerObj.timestamp = new Date().getTime() + const offerEntity = this.offerEntity.validate(offerObj) console.log('offerEntity: ', offerEntity) @@ -117,7 +122,7 @@ class OfferUseCases { return true } catch (err) { - console.error('Error in createOffer()') + console.error('Error in createOffer()', err.message) throw err } } @@ -140,7 +145,7 @@ class OfferUseCases { // Retrieve the mutable data from Filecoin/IPFS. // const url = `https://${cid}.ipfs.w3s.link/data.json` const url = `${this.config.ipfsGateway}${cid}/data.json` - const result = await axios.get(url) + const result = await this.axios.get(url) const mutableData = result.data console.log(`mutableData: ${JSON.stringify(mutableData, null, 2)}`) @@ -207,11 +212,11 @@ class OfferUseCases { async listOffers (page = 0) { try { const data = await this.OfferModel.find({}) - // Sort entries so newest entries show first. + // Sort entries so newest entries show first. .sort('-timestamp') - // Skip to the start of the selected page. + // Skip to the start of the selected page. .skip(page * DEFAULT_ENTRIES_PER_PAGE) - // Only return 20 results. + // Only return 20 results. .limit(DEFAULT_ENTRIES_PER_PAGE) return data @@ -227,11 +232,11 @@ class OfferUseCases { displayCategory: { $ne: 'fungible' }, nsfw }) - // Sort entries so newest entries show first. + // Sort entries so newest entries show first. .sort('-timestamp') - // Skip to the start of the selected page. + // Skip to the start of the selected page. .skip(page * NFT_ENTRIES_PER_PAGE) - // Only return 20 results. + // Only return 20 results. .limit(NFT_ENTRIES_PER_PAGE) // console.log('listNftOffers() returning this data: ', data) @@ -246,11 +251,11 @@ class OfferUseCases { async listFungibleOffers (page = 0) { try { const data = await this.OfferModel.find({ displayCategory: 'fungible' }) - // Sort entries so newest entries show first. + // Sort entries so newest entries show first. .sort('-timestamp') - // Skip to the start of the selected page. + // Skip to the start of the selected page. .skip(page * FUNGIBLE_ENTRIES_PER_PAGE) - // Only return 20 results. + // Only return 20 results. .limit(FUNGIBLE_ENTRIES_PER_PAGE) // console.log('listFungibleOffers() returning this data: ', data) @@ -398,7 +403,7 @@ class OfferUseCases { throw new Error('App wallet does not control enough BCH to purchase the tokens.') } - // + // } else { // Buy Offer throw new Error('Buy offers are not supported yet.') @@ -437,10 +442,30 @@ class OfferUseCases { // return offerObject // } catch (err) { // // console.error('Error in findOffer(): ', err) - // throw err + // throw errByHash // } } + async findOfferByTxid (utxoTxid) { + try { + // try { + if (typeof utxoTxid !== 'string' || !utxoTxid) { + throw new Error('utxoTxid must be a string') + } + + const offer = await this.OfferModel.findOne({ utxoTxid }) + + if (!offer) { + throw new Error('offer not found') + } + + return offer + } catch (error) { + console.error('Error in use-cases/offer/findOfferByTxid(): ') + throw error + } + } + // This function is called by the P2WDB webhook REST API handler. When a // Counter Offer is passed to bch-dex by the P2WDB, the data is then passed // to this function. It does due dilligence on the Counter Offer, then signs @@ -627,6 +652,30 @@ class OfferUseCases { return true } + + // Get offers data from nostr. + async loadOffers () { + try { + // Retrieve offers array. + const offers = await this.adapters.nostr.read() + for (let i = 0; i < offers.length; i++) { + try { + const offer = offers[i] + + // offer data + const offerObj = JSON.parse(offer) + + // Try to create new offer + await this.createOffer(offerObj) + } catch (error) { + /* exit quietly */ + } + } + } catch (error) { + console.error('Error in loadOffers(): ', error) + throw error + } + } } export default OfferUseCases diff --git a/test/unit/controllers/timer-controllers.unit.js b/test/unit/controllers/timer-controllers.unit.js index 151da53..6948120 100644 --- a/test/unit/controllers/timer-controllers.unit.js +++ b/test/unit/controllers/timer-controllers.unit.js @@ -56,27 +56,70 @@ describe('#Timer-Controllers', () => { }) }) - // describe('#startTimers', () => { - // it('should start the timers', () => { - // const result = uut.startTimers() - // - // // uut.stopTimers() - // - // assert.equal(result, true) - // }) - // }) + describe('#startTimers', () => { + it('should start the timers', () => { + const result = uut.startTimers() - // describe('#exampleTimerFunc', () => { - // it('should kick off the Use Case', async () => { - // const result = await uut.exampleTimerFunc() - // - // assert.equal(result, true) - // }) - // - // it('should return false on error', async () => { - // const result = await uut.exampleTimerFunc(true) - // - // assert.equal(result, false) - // }) - // }) + // uut.stopTimers() + + assert.equal(result, true) + }) + }) + + describe('#gcOrders', () => { + it('should kick off the Use Case', async () => { + const result = await uut.gcOrders() + + assert.equal(result, true) + }) + + it('should return false on error', async () => { + sandbox.stub(uut.useCases.order, 'removeStaleOrders').throws(new Error('test error')) + const result = await uut.gcOrders() + + assert.equal(result, false) + }) + }) + describe('#gcOffers', () => { + it('should kick off the Use Case', async () => { + const result = await uut.gcOffers() + + assert.equal(result, true) + }) + + it('should return false on error', async () => { + sandbox.stub(uut.useCases.offer, 'removeStaleOffers').throws(new Error('test error')) + const result = await uut.gcOffers() + + assert.equal(result, false) + }) + }) + describe('#checkDupOffers', () => { + it('should kick off the Use Case', async () => { + const result = await uut.checkDupOffers() + + assert.equal(result, true) + }) + + it('should return false on error', async () => { + sandbox.stub(uut.useCases.offer, 'removeDuplicateOffers').throws(new Error('test error')) + const result = await uut.checkDupOffers() + + assert.equal(result, false) + }) + }) + describe('#loadOffers', () => { + it('should kick off the Use Case', async () => { + const result = await uut.loadOffers() + + assert.equal(result, true) + }) + + it('should return false on error', async () => { + sandbox.stub(uut.useCases.offer, 'loadOffers').throws(new Error('test error')) + const result = await uut.loadOffers() + + assert.equal(result, false) + }) + }) }) diff --git a/test/unit/mocks/use-cases/index.js b/test/unit/mocks/use-cases/index.js index 2fdf720..c942630 100644 --- a/test/unit/mocks/use-cases/index.js +++ b/test/unit/mocks/use-cases/index.js @@ -41,12 +41,24 @@ class Offer { async createOffer() { return {} } + async removeStaleOffers(){ + + } + async removeDuplicateOffers(){ + + } + async loadOffers(){ + + } } class Order { async createOrder() { return {} } + async removeStaleOrders(){ + + } } class UseCasesMock { diff --git a/test/unit/mocks/use-cases/offer-mock-data.js b/test/unit/mocks/use-cases/offer-mock-data.js index 41172cc..87ec905 100644 --- a/test/unit/mocks/use-cases/offer-mock-data.js +++ b/test/unit/mocks/use-cases/offer-mock-data.js @@ -126,11 +126,30 @@ const fungibleTokenData01 = { "mutableData": "" } +const offerMockData ={ + data: { + messageType: 1, + messageClass: 1, + tokenId: + '38e97c5d7d3585a2cbf3f9580c82ca33985f9cb0845d4dcce220cb709f9538b0', + buyOrSell: 'sell', + rateInBaseUnit: 1000, + minUnitsToExchange: 10, + numTokens: 0.02, + utxoTxid: + '241c06bf61384b8623477e419bf4779edbcc7e3bc862f0f179a9ed2967069b87', + utxoVout: 0, + makerAddr: 'address', + tokenType: 1 + } +} + export default { nftOffer01, nftTokenData01, simpleNftOffer01, simpleNftTokenData01, fungibleOffer01, - fungibleTokenData01 + fungibleTokenData01, + offerMockData }; diff --git a/test/unit/use-cases/offer.use-case.unit.js b/test/unit/use-cases/offer.use-case.unit.js index 9519ead..76df883 100644 --- a/test/unit/use-cases/offer.use-case.unit.js +++ b/test/unit/use-cases/offer.use-case.unit.js @@ -48,79 +48,67 @@ describe('#offer-use-case', () => { ) } }) + it('should throw an error if order use cases are not passed in', () => { + try { + uut = new OfferLib({ adapters }) + + assert.fail('Unexpected code path') + console.log(uut) // linter + } catch (err) { + assert.include( + err.message, + 'Instance of Order Use Cases must be passed in when instantiating Offer Use Cases library.' + ) + } + }) }) describe('#createOffer', () => { - // it('should ignore an offer if utxo has been spent', async () => { - // const offerObj = { - // appId: 'swapTest555', - // data: { - // messageType: 1, - // messageClass: 1, - // tokenId: - // '38e97c5d7d3585a2cbf3f9580c82ca33985f9cb0845d4dcce220cb709f9538b0', - // buyOrSell: 'sell', - // rateInSats: 1000, - // minSatsToExchange: 10, - // numTokens: 0.02, - // utxoTxid: - // '241c06bf61384b8623477e419bf4779edbcc7e3bc862f0f179a9ed2967069b87', - // utxoVout: 0 - // }, - // timestamp: '2021-09-20T17:54:26.395Z', - // localTimeStamp: '9/20/2021, 10:54:26 AM', - // txid: '46f50f2a0cf44e3ed70dfb0618ef3ebfee57aabcf229b5d2d17c07322b54a8d7', - // hash: 'zdpuB2X25AZCKo3wpr4sSbw44vqPWJRqcxWQRHZccK5BdtoGD' - // } - // - // // Mock dependencies - // // sandbox.stub(uut.adapters.wallet.bchWallet, 'utxoIsValid').resolves(false) - // sandbox.stub(uut.adapters.wallet.bchWallet, 'getTokenData').resolves({}) - // sandbox.stub(uut, 'categorizeToken').resolves('nft') - // sandbox.stub(uut, 'detectNsfw').resolves(false) - // - // const result = await uut.createOffer(offerObj) - // // console.log('result: ', result) - // - // assert.equal(result, false) - // }) + it('should handle error', async () => { + try { + await uut.createOffer() + assert.fail('unexpected code path') + } catch (error) { + assert.include(error.message, 'Cannot read properties of undefined') + } + }) + it('should return false if offer already exist', async () => { + const offerObj = mockData.offerMockData - // it('should create an offer and return the hash', async () => { - // const offerObj = { - // appId: 'swapTest555', - // data: { - // messageType: 1, - // messageClass: 1, - // tokenId: - // '38e97c5d7d3585a2cbf3f9580c82ca33985f9cb0845d4dcce220cb709f9538b0', - // buyOrSell: 'sell', - // rateInBaseUnit: 1000, - // minUnitsToExchange: 10, - // numTokens: 0.02, - // utxoTxid: - // '241c06bf61384b8623477e419bf4779edbcc7e3bc862f0f179a9ed2967069b87', - // utxoVout: 0, - // makerAddr: 'bitcoincash:qzl0d3gcqeypv4cy7gh8rgdszxa9vvm2acv7fqtd00', - // ticker: 'TROUT', - // tokenType: 1 - // }, - // timestamp: '2021-09-20T17:54:26.395Z', - // localTimeStamp: '9/20/2021, 10:54:26 AM', - // txid: '46f50f2a0cf44e3ed70dfb0618ef3ebfee57aabcf229b5d2d17c07322b54a8d7', - // hash: 'zdpuB2X25AZCKo3wpr4sSbw44vqPWJRqcxWQRHZccK5BdtoGD' - // } - // - // // Mock dependencies - // // sandbox.stub(uut.adapters.wallet.bchWallet, 'utxoIsValid').resolves(true) - // sandbox.stub(uut, 'categorizeToken').resolves('fungible') - // sandbox.stub(uut.adapters.wallet.bchWallet, 'getTokenData').resolves({}) - // sandbox.stub(uut, 'detectNsfw').resolves(false) - // - // const result = await uut.createOffer(offerObj) - // // console.log('result: ', result) - // - // assert.equal(result, true) - // }) + // Mock dependencies + // sandbox.stub(uut.adapters.wallet.bchWallet, 'utxoIsValid').resolves(false) + sandbox.stub(uut, 'findOfferByTxid').resolves({}) + + const result = await uut.createOffer(offerObj) + assert.isFalse(result) + }) + it('should return false for invalid utxo', async () => { + const offerObj = mockData.offerMockData + + // Mock dependencies + // sandbox.stub(uut.adapters.wallet.bchWallet, 'utxoIsValid').resolves(false) + sandbox.stub(uut, 'findOfferByTxid').throws(new Error('offer not found')) + sandbox.stub(uut.retryQueue, 'addToQueue').resolves(null) + + const result = await uut.createOffer(offerObj) + assert.isFalse(result) + }) + + it('should create offer', async () => { + const tokenDataMock = mockData.simpleNftTokenData01 + const offerObj = mockData.offerMockData + + // Mock dependencies + // sandbox.stub(uut.adapters.wallet.bchWallet, 'utxoIsValid').resolves(false) + sandbox.stub(uut, 'findOfferByTxid').throws(new Error('offer not found')) + sandbox.stub(uut.retryQueue, 'addToQueue') + .onCall(0).resolves({}) // Utxo Status call + .onCall(1).resolves(tokenDataMock) // Token Data call + .onCall(2).resolves(false) // detectNsfw call + + const result = await uut.createOffer(offerObj) + assert.isTrue(result) + }) }) describe('#categorizeToken', () => { @@ -159,15 +147,28 @@ describe('#offer-use-case', () => { assert.equal(result, 'fungible') }) + it('should unknow type', async () => { + try { + // Mock dependencies + const offerData = mockData.fungibleOffer01 + const tokenData = { genesisData: {} } + + await uut.categorizeToken(offerData, tokenData) + + assert.fail('unexpected code path') + } catch (error) { + assert.include(error.message, 'Unknown token type:') + } + }) }) describe('#removeDuplicateOffers', () => { it('should remove duplicate entries and return true', async () => { // Mock dependencies and force desired code path. sandbox.stub(uut.OfferModel, 'find').resolves([ - { p2wdbHash: 'a', remove: async () => {} }, - { p2wdbHash: 'a', remove: async () => {} }, - { p2wdbHash: 'b', remove: async () => {} } + { p2wdbHash: 'a', remove: async () => { } }, + { p2wdbHash: 'a', remove: async () => { } }, + { p2wdbHash: 'b', remove: async () => { } } ]) // sandbox.stub(uut.OfferModel, 'remove').resolves() @@ -180,8 +181,8 @@ describe('#offer-use-case', () => { it('should return false if there are no duplicate entries', async () => { // Mock dependencies and force desired code path. sandbox.stub(uut.OfferModel, 'find').resolves([ - { p2wdbHash: 'a', remove: async () => {} }, - { p2wdbHash: 'b', remove: async () => {} } + { p2wdbHash: 'a', remove: async () => { } }, + { p2wdbHash: 'b', remove: async () => { } } ]) // sandbox.stub(uut.OfferModel, 'remove').resolves() @@ -191,4 +192,68 @@ describe('#offer-use-case', () => { assert.equal(result, false) }) }) + describe('#findOfferByTxid', () => { + it('should throw an error if input is not provided', async () => { + try { + await uut.findOfferByTxid() + assert.fail('unexpected code path') + } catch (error) { + assert.equal(error.message, 'utxoTxid must be a string') + } + }) + it('should throw an error if offer is not found', async () => { + try { + // Mock dependencies and force desired code path. + sandbox.stub(uut.OfferModel, 'findOne').resolves(null) + + await uut.findOfferByTxid('241c06bf61384b8623477e419bf4779edbcc7e3bc862f0f179a9ed2967069b87') + assert.fail('unexpected code path') + } catch (error) { + assert.equal(error.message, 'offer not found') + } + }) + + it('should return offer', async () => { + // Mock dependencies and force desired code path. + sandbox.stub(uut.OfferModel, 'findOne').resolves(mockData.nftOffer01) + + const result = await uut.findOfferByTxid('241c06bf61384b8623477e419bf4779edbcc7e3bc862f0f179a9ed2967069b87') + assert.isObject(result) + }) + }) + describe('#detectNsfw', () => { + it('should return false for wrong cid format', async () => { + const result = await uut.detectNsfw({ mutableData: '' }) + assert.isFalse(result) + }) + + it('should return true if nft boolean detected', async () => { + // Mock dependencies and force desired code path. + sandbox.stub(uut.axios, 'get').resolves({ data: { nsfw: true } }) + + const result = await uut.detectNsfw({ mutableData: 'ipfs://bafybeibqnsmmh6bkf2wwextetki4tly65z4r4qkrrpl5xwgvzdzjley6wm' }) + assert.isTrue(result) + }) + it('should return true if nft string detected', async () => { + // Mock dependencies and force desired code path. + sandbox.stub(uut.axios, 'get').resolves({ data: { nsfw: 'true' } }) + + const result = await uut.detectNsfw({ mutableData: 'ipfs://bafybeibqnsmmh6bkf2wwextetki4tly65z4r4qkrrpl5xwgvzdzjley6wm' }) + assert.isTrue(result) + }) + it('should return false if nfsw property does not exist', async () => { + // Mock dependencies and force desired code path. + sandbox.stub(uut.axios, 'get').resolves({ data: {} }) + + const result = await uut.detectNsfw({ mutableData: 'ipfs://bafybeibqnsmmh6bkf2wwextetki4tly65z4r4qkrrpl5xwgvzdzjley6wm' }) + assert.isFalse(result) + }) + it('should return false on error', async () => { + // Mock dependencies and force desired code path. + sandbox.stub(uut.axios, 'get').throws(new Error('test error')) + + const result = await uut.detectNsfw({ mutableData: 'ipfs://bafybeibqnsmmh6bkf2wwextetki4tly65z4r4qkrrpl5xwgvzdzjley6wm' }) + assert.isFalse(result) + }) + }) })