From 0a8bb522413f9371e41568e30e1c0ec769b92bec Mon Sep 17 00:00:00 2001 From: Daniel Gonzalez Date: Mon, 2 Dec 2024 22:19:54 -0400 Subject: [PATCH 1/6] 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) + }) + }) }) From b3ba7f03e4b96adbdcbc432fad1cb95cd914dfda Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Tue, 3 Dec 2024 09:28:52 -0800 Subject: [PATCH 2/6] Commenting out POST /offer/ webhook endpoint --- src/controllers/rest-api/offer/index.js | 7 ++++++- src/use-cases/offer/index.js | 4 +--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/controllers/rest-api/offer/index.js b/src/controllers/rest-api/offer/index.js index 5370c77..77fe1bb 100644 --- a/src/controllers/rest-api/offer/index.js +++ b/src/controllers/rest-api/offer/index.js @@ -48,8 +48,13 @@ class OfferRouter { ) } + // 12/3/24 CT: + // Note: The createOffer() path was used by a P2WDB webhook to generate an + // Offer from and Order. This has been deprecated and Offers are now created + // by a Timer Controller monitoring a Nostr topic. + // Define the routes and attach the controller. - this.router.post('/', _this.offerRESTController.createOffer) + // this.router.post('/', _this.offerRESTController.createOffer) // Deprecated. this.router.post('/take', _this.offerRESTController.takeOffer) this.router.get('/list/all/:page', _this.offerRESTController.listOffers) this.router.get('/list/nft/:page', _this.offerRESTController.listNftOffers) diff --git a/src/use-cases/offer/index.js b/src/use-cases/offer/index.js index 3bcb52c..d73ed0d 100644 --- a/src/use-cases/offer/index.js +++ b/src/use-cases/offer/index.js @@ -53,9 +53,7 @@ class OfferUseCases { this.loadOffers = this.loadOffers.bind(this) } - // - // This method is called by timer controller to load offers fron nostr server + // This method is called by timer controller to load offers from a Nostr topic. async createOffer (offerObj) { try { console.log('Use Case createOffer(offerObj): ', offerObj) From 9dbd18d0d6b6e0ed5c4ced878d47e5fec740047b Mon Sep 17 00:00:00 2001 From: Daniel Gonzalez Date: Wed, 4 Dec 2024 06:51:28 -0400 Subject: [PATCH 3/6] Increased test coverage --- src/controllers/rest-api/offer/controller.js | 6 +- src/use-cases/offer/index.js | 123 ++--- test/unit/mocks/adapters/index.js | 6 +- test/unit/mocks/adapters/wallet.js | 1 + test/unit/mocks/use-cases/offer-mock-data.js | 102 ++-- test/unit/use-cases/offer.use-case.unit.js | 484 +++++++++++++++++++ 6 files changed, 625 insertions(+), 97 deletions(-) diff --git a/src/controllers/rest-api/offer/controller.js b/src/controllers/rest-api/offer/controller.js index 968e3bc..7f89ea8 100644 --- a/src/controllers/rest-api/offer/controller.js +++ b/src/controllers/rest-api/offer/controller.js @@ -100,11 +100,11 @@ class OfferRESTControllerLib { try { console.log('REST API controller, body: ', ctx.request.body) - const offerCid = ctx.request.body.offerCid + const nostrEventId = ctx.request.body.nostrEventId - const hash = await _this.useCases.offer.takeOffer(offerCid) + const eventId = await _this.useCases.offer.takeOffer(nostrEventId) - ctx.body = { hash } + ctx.body = { eventId } } catch (err) { wlogger.error('Error in takeOffer() REST API handler.') _this.handleError(ctx, err) diff --git a/src/use-cases/offer/index.js b/src/use-cases/offer/index.js index d73ed0d..cfb8e9b 100644 --- a/src/use-cases/offer/index.js +++ b/src/use-cases/offer/index.js @@ -273,12 +273,12 @@ class OfferUseCases { // provide functionality to take less than the total amount of tokens offered // (offerInfo.numTokens). Taking less than the offered amount will be added // in the future. - async takeOffer (offerCid) { + async takeOffer (eventId) { try { - console.log('offerCid: ', offerCid) + if (!eventId || typeof eventId !== 'string') throw new Error('eventId must be a string') // Get the Offer information - const offerInfo = await this.findOfferByHash(offerCid) + const offerInfo = await this.findOfferByEvent(eventId) console.log(`offerInfo: ${JSON.stringify(offerInfo, null, 2)}`) // Ensure the offer is in a 'posted' state and not already 'taken' @@ -291,6 +291,8 @@ class OfferUseCases { tx_hash: offerInfo.utxoTxid, tx_pos: offerInfo.utxoVout } + + // Note : should be added to retry-queue? const utxoStatus = await this.adapters.wallet.bchWallet.utxoIsValid(utxo) console.log('utxoStatus: ', utxoStatus) if (!utxoStatus) { @@ -310,6 +312,7 @@ class OfferUseCases { // Calculate amount of sats to generate a counter offer. let satsToMove = Math.ceil(offerInfo.numTokens * parseInt(offerInfo.rateInBaseUnit)) + console.log('satsToMove', satsToMove, offerInfo) if (isNaN(satsToMove)) { throw new Error('Could not calculate the amount of BCH to generate counter offer') } @@ -335,9 +338,9 @@ class OfferUseCases { // Create valid Offer object const takenOfferInfo = Object.assign({}, offerInfo) takenOfferInfo.partialTxHex = partialTxHex - delete takenOfferInfo.p2wdbHash + delete takenOfferInfo.nostrEventId delete takenOfferInfo._id - takenOfferInfo.offerHash = offerInfo.p2wdbHash + takenOfferInfo.offerHash = offerInfo.nostrEventId // Add P2WDB specific flag for signaling that this is a new offer. takenOfferInfo.dataType = 'counter-offer' @@ -345,19 +348,19 @@ class OfferUseCases { // Write offer info to the P2WDB // TODO: This will trigger the webhook. Find some way of triggering the // webhook on new offers, but not on counteroffers - const p2wdbObj = { + const nostrData = { wif: this.adapters.wallet.bchWallet.walletInfo.privateKey, data: takenOfferInfo, appId: this.config.p2wdbAppId } - const hash = await this.adapters.p2wdb.write(p2wdbObj) + const resultEventId = await this.adapters.nostr.post(nostrData) // 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 hash + return resultEventId // return 'fake-hash' } catch (err) { @@ -380,6 +383,7 @@ class OfferUseCases { await this.adapters.wallet.bchWallet.initialize() // Ensure the app wallet has enough funds to write to the P2WDB. + // Note : this validation should be deprecated for nostr functionality? const wif = this.adapters.wallet.bchWallet.walletInfo.privateKey const canWriteToP2WDB = await this.adapters.p2wdb.checkForSufficientFunds(wif) if (!canWriteToP2WDB) throw new Error('App wallet does not have funds for writing to the P2WDB.') @@ -420,28 +424,27 @@ class OfferUseCases { } } - async findOfferByHash (p2wdbHash) { - // try { - if (typeof p2wdbHash !== 'string' || !p2wdbHash) { - throw new Error('p2wdbHash must be a string') + // Retrieve an Order model from the database. Find it by its event Id. + async findOfferByEvent (nostrEventId) { + try { + if (typeof nostrEventId !== 'string' || !nostrEventId) { + throw new Error('nostrEventId must be a string') + } + + const order = await this.OfferModel.findOne({ nostrEventId }) + + if (!order) { + throw new Error('offer not found') + } + + const orderObject = order.toObject() + // return this.offerEntity.validateFromModel(offerObject) + + return orderObject + } catch (err) { + console.error('Error in findOfferByEvent()') + throw err } - - const offer = await this.OfferModel.findOne({ p2wdbHash }) - - if (!offer) { - throw new Error('offer not found') - } - - return offer - - // const offerObject = offer.toObject() - // return this.offerEntity.validateFromModel(offerObject) - - // return offerObject - // } catch (err) { - // // console.error('Error in findOffer(): ', err) - // throw errByHash - // } } async findOfferByTxid (utxoTxid) { @@ -468,13 +471,15 @@ class OfferUseCases { // 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 // and broadcasts the transaction to accept the Counter Offer. - async acceptCounterOffer (p2wdbData) { + async acceptCounterOffer (offerData) { try { - console.log(`acceptCounterOffer() p2wdbData: ${JSON.stringify(p2wdbData, null, 2)}`) + console.log(`acceptCounterOffer() offerData: ${JSON.stringify(offerData, null, 2)}`) // See if this instance of bch-dex is managing the Order associated with // the incoming Counter Offer. - const orderHash = p2wdbData.data.offerHash + + // Note : this should be handle by nostrEvent id or UtxoId? + const orderHash = offerData.data.nostrEventId let orderData = {} try { orderData = await this.orderUseCase.findOrderByEvent(orderHash) @@ -485,12 +490,13 @@ class OfferUseCases { } // Deserialize the partially signed transaction. - const txHex = p2wdbData.data.partialTxHex + const txHex = offerData.data.partialTxHex const txObj = await this.adapters.wallet.deseralizeTx(txHex) console.log(`txObj: ${JSON.stringify(txObj, null, 2)}`) // Ensure the 3rd output (vout=2) contains the required amount of BCH. const satsToReceive = Math.ceil(orderData.numTokens * parseInt(orderData.rateInBaseUnit)) + console.log('Ceil', satsToReceive) if (isNaN(satsToReceive)) { throw new Error('Could not calculate the amount of BCH offered in the Counter Offer') } @@ -623,32 +629,39 @@ class OfferUseCases { } } - async flagOffer (flagData) { - console.log(`flagData: ${JSON.stringify(flagData, null, 2)}`) + async flagOffer (flagData = {}) { + try { + if (!flagData.data) throw new Error('"data" property is required') - const p2wdbHash = flagData.data.p2wdbHash + console.log(`flagData: ${JSON.stringify(flagData, null, 2)}`) - // Get the offer from the database. - const offer = await this.findOfferByHash(p2wdbHash) - console.log(`Flagging this offer: ${JSON.stringify(offer, null, 2)}`) + const eventId = flagData.data.nostrEventId - if (!offer) { - throw new Error(`Offer ${p2wdbHash} not found in the database.`) + // Get the offer from the database. + const offer = await this.findOfferByEvent(eventId) + console.log(`Flagging this offer: ${JSON.stringify(offer, null, 2)}`) + + if (!offer) { + throw new Error(`Offer ${eventId} not found in the database.`) + } + + // Add the raw flag data to the database model. + offer.flags.push(flagData) + + // If flag count is 3 or more, mark the Offer as NSFW + const flagCnt = offer.flags.length + if (flagCnt >= 3) { + offer.nsfw = true + } + + // Save the updated offer data to the database. + await offer.save() + + return true + } catch (error) { + console.error('Error in flagOffer(): ', error) + throw error } - - // Add the raw flag data to the database model. - offer.flags.push(flagData) - - // If flag count is 3 or more, mark the Offer as NSFW - const flagCnt = offer.flags.length - if (flagCnt >= 3) { - offer.nsfw = true - } - - // Save the updated offer data to the database. - await offer.save() - - return true } // Get offers data from nostr. diff --git a/test/unit/mocks/adapters/index.js b/test/unit/mocks/adapters/index.js index fb4f9da..72e68d2 100644 --- a/test/unit/mocks/adapters/index.js +++ b/test/unit/mocks/adapters/index.js @@ -151,7 +151,11 @@ const wallet = { }, bchWallet: new MockBchWallet(), moveTokens: async () => {}, - reclaimTokens: async ()=>{} + moveBch: async () => {}, + reclaimTokens: async ()=>{}, + generatePartialTx: async ()=>{}, + deseralizeTx:async ()=>{}, + completeTx:async ()=>{ return ''}, } const p2wdb = { diff --git a/test/unit/mocks/adapters/wallet.js b/test/unit/mocks/adapters/wallet.js index 09e8378..70af917 100644 --- a/test/unit/mocks/adapters/wallet.js +++ b/test/unit/mocks/adapters/wallet.js @@ -24,6 +24,7 @@ class MockBchWallet { this.sendTokens = async () => { return 'fakeTxid'; }; + this.utxoIsValid =async ()=>{} this.getUtxos = async () => { }; this.getBalance = async () => { }; this.listTokens = async () => { }; diff --git a/test/unit/mocks/use-cases/offer-mock-data.js b/test/unit/mocks/use-cases/offer-mock-data.js index 87ec905..83fc742 100644 --- a/test/unit/mocks/use-cases/offer-mock-data.js +++ b/test/unit/mocks/use-cases/offer-mock-data.js @@ -3,45 +3,45 @@ */ const nftOffer01 = { - "messageType": 1, - "messageClass": 1, - "tokenId": "eb93f05553ff088bffb0ec687519e83c59e5108c160f7c25a4b6c45109d7e40b", - "buyOrSell": "sell", - "rateInBaseUnit": 7672536, - "minUnitsToExchange": 7672536, - "numTokens": 1, - "utxoTxid": "c736d73e274df20e9b069b4990d1a264fb80aa98d67cc6c7a39e42bff48e7c04", - "utxoVout": 1, - "timestamp": 1662930309998, - "globaltimestamp": "2022-09-11T21:05:09.998Z", - "localTimestamp": "9/11/2022, 9:05:09 PM", - "txid": "4266862b8358664996038d8c29d49dc0f3d3058fd1ef3d567b084e6b16ceb5b2", - "p2wdbHash": "zdpuArq7rCuVCGPyTnWpLjy9AYsh8yrbwjGdKwg2GDDALkM8t", - "offerStatus": "posted", - "makerAddr": "bitcoincash:qrqlz63cwmu0hcmsrfnd8jemn3atkpaqds6tf4ksrr", - "ticker": "TV001", - "tokenType": 65 + "messageType": 1, + "messageClass": 1, + "tokenId": "eb93f05553ff088bffb0ec687519e83c59e5108c160f7c25a4b6c45109d7e40b", + "buyOrSell": "sell", + "rateInBaseUnit": 7672536, + "minUnitsToExchange": 7672536, + "numTokens": 1, + "utxoTxid": "c736d73e274df20e9b069b4990d1a264fb80aa98d67cc6c7a39e42bff48e7c04", + "utxoVout": 1, + "timestamp": 1662930309998, + "globaltimestamp": "2022-09-11T21:05:09.998Z", + "localTimestamp": "9/11/2022, 9:05:09 PM", + "txid": "4266862b8358664996038d8c29d49dc0f3d3058fd1ef3d567b084e6b16ceb5b2", + "p2wdbHash": "zdpuArq7rCuVCGPyTnWpLjy9AYsh8yrbwjGdKwg2GDDALkM8t", + "offerStatus": "posted", + "makerAddr": "bitcoincash:qrqlz63cwmu0hcmsrfnd8jemn3atkpaqds6tf4ksrr", + "ticker": "TV001", + "tokenType": 65 } const nftTokenData01 = { - "genesisData": { - "type": 65, - "ticker": "TV001", - "name": "Introduction to NFTs on BCH", - "tokenId": "eb93f05553ff088bffb0ec687519e83c59e5108c160f7c25a4b6c45109d7e40b", - "documentUri": "ipfs://bafybeibmgilu4lk3uivwyzhtm7jorl5zbsluewqmcsxs6otzrphh33okk4", - "documentHash": "c1731268f4873f1928438abdaf6ffc546d86a1817dc6f3c6bc73fbdfb4664f10", - "decimals": 0, - "mintBatonIsActive": false, - "tokensInCirculationBN": "1", - "tokensInCirculationStr": "1", - "blockCreated": 740395, - "totalBurned": "0", - "totalMinted": "1", - "parentGroupId": "030563ddd65772d8e9b79b825529ed53c7d27037507b57c528788612b4911107" - }, - "immutableData": "ipfs://bafybeibmgilu4lk3uivwyzhtm7jorl5zbsluewqmcsxs6otzrphh33okk4", - "mutableData": "ipfs://bafybeifzhunfpodsztsj5x4ypopngkxuapbxwaxaxkahaybdktk6joqtlq" + "genesisData": { + "type": 65, + "ticker": "TV001", + "name": "Introduction to NFTs on BCH", + "tokenId": "eb93f05553ff088bffb0ec687519e83c59e5108c160f7c25a4b6c45109d7e40b", + "documentUri": "ipfs://bafybeibmgilu4lk3uivwyzhtm7jorl5zbsluewqmcsxs6otzrphh33okk4", + "documentHash": "c1731268f4873f1928438abdaf6ffc546d86a1817dc6f3c6bc73fbdfb4664f10", + "decimals": 0, + "mintBatonIsActive": false, + "tokensInCirculationBN": "1", + "tokensInCirculationStr": "1", + "blockCreated": 740395, + "totalBurned": "0", + "totalMinted": "1", + "parentGroupId": "030563ddd65772d8e9b79b825529ed53c7d27037507b57c528788612b4911107" + }, + "immutableData": "ipfs://bafybeibmgilu4lk3uivwyzhtm7jorl5zbsluewqmcsxs6otzrphh33okk4", + "mutableData": "ipfs://bafybeifzhunfpodsztsj5x4ypopngkxuapbxwaxaxkahaybdktk6joqtlq" } const simpleNftOffer01 = { @@ -126,7 +126,7 @@ const fungibleTokenData01 = { "mutableData": "" } -const offerMockData ={ +const offerMockData = { data: { messageType: 1, messageClass: 1, @@ -144,6 +144,31 @@ const offerMockData ={ } } +const deserealizeTxMock = { + //... + vout: [ + { + value: 0, + scriptPubKey: { + addresses: ['bitcoincash:qzy97glp47ut7tstm5g0tlrmkhk742795gkmyc7478'] + } + }, + { + value: 0, + scriptPubKey: { + addresses: ['bitcoincash:qzy97glp47ut7tstm5g0tlrmkhk742795gkmyc7478'] + } + }, + { + value: 0, + scriptPubKey: { + addresses: ['bitcoincash:qzy97glp47ut7tstm5g0tlrmkhk742795gkmyc7478'] + } + }, + //... + ] +} + export default { nftOffer01, nftTokenData01, @@ -151,5 +176,6 @@ export default { simpleNftTokenData01, fungibleOffer01, fungibleTokenData01, - offerMockData + offerMockData, + deserealizeTxMock }; diff --git a/test/unit/use-cases/offer.use-case.unit.js b/test/unit/use-cases/offer.use-case.unit.js index 76df883..03c07c3 100644 --- a/test/unit/use-cases/offer.use-case.unit.js +++ b/test/unit/use-cases/offer.use-case.unit.js @@ -191,6 +191,69 @@ describe('#offer-use-case', () => { assert.equal(result, false) }) + it('should handle error', async () => { + try { + // Mock dependencies and force desired code path. + sandbox.stub(uut.OfferModel, 'find').throws(new Error('test error')) + // sandbox.stub(uut.OfferModel, 'remove').resolves() + + await uut.removeDuplicateOffers() + assert.fail('unexpected code path') + } catch (error) { + assert.equal(error.message, 'test error') + } + }) + }) + describe('#removeStaleOffers', () => { + it('remove offer with wrong utxoState', async () => { + // Mock dependencies and force desired code path. + sandbox.stub(uut.OfferModel, 'find').resolves([{ remove: async () => { } }]) + sandbox.stub(uut.retryQueue, 'addToQueue').resolves(false) + + await uut.removeStaleOffers() + }) + it('remove offer with wrong txid', async () => { + // Mock dependencies and force desired code path. + sandbox.stub(uut.OfferModel, 'find').resolves([{ remove: async () => { } }]) + sandbox.stub(uut.retryQueue, 'addToQueue').throws(new Error('txid needs to be a proper transaction ID')) + + await uut.removeStaleOffers() + }) + it('remove expired offer ', async () => { + const tsMock = new Date() + tsMock.setMonth(tsMock.getMonth() - 3) + + const timestamp = tsMock.getTime() + // Mock dependencies and force desired code path. + sandbox.stub(uut.OfferModel, 'find').resolves([{ timestamp, remove: async () => { } }]) + sandbox.stub(uut.retryQueue, 'addToQueue').resolves(true) + + await uut.removeStaleOffers() + }) + it('should handle axios error ', async () => { + const testErr = new Error() + testErr.isAxiosError = true + + // Mock dependencies and force desired code path. + sandbox.stub(uut.OfferModel, 'find').resolves([{ remove: async () => { } }]) + sandbox.stub(uut.retryQueue, 'addToQueue').throws(testErr) + + await uut.removeStaleOffers() + }) + it('should handle error ', async () => { + try { + const testErr = new Error('unknow error') + + // Mock dependencies and force desired code path. + sandbox.stub(uut.OfferModel, 'find').resolves([{ remove: async () => { } }]) + sandbox.stub(uut.retryQueue, 'addToQueue').throws(testErr) + + await uut.removeStaleOffers() + assert.fail('unexpected code path') + } catch (error) { + assert.equal(error.message, 'unknow error') + } + }) }) describe('#findOfferByTxid', () => { it('should throw an error if input is not provided', async () => { @@ -256,4 +319,425 @@ describe('#offer-use-case', () => { assert.isFalse(result) }) }) + describe('#listOffers', () => { + it('should handle error', async () => { + try { + // Mock dependencies + sandbox.stub(uut.OfferModel, 'find').throws(new Error('test error')) + + await uut.listOffers() + + assert.fail('unexpected code path') + } catch (err) { + assert.include(err.message, 'test error') + } + }) + + it('should list orders', async () => { + const queryMock = { + sort () { + return this + }, + skip () { + return this + }, + limit () { return [] } + } + // Mock dependencies + sandbox.stub(uut.OfferModel, 'find').returns(queryMock) + + const result = await uut.listOffers() + assert.isArray(result) + }) + }) + describe('#listNftOffers', () => { + it('should handle error', async () => { + try { + // Mock dependencies + sandbox.stub(uut.OfferModel, 'find').throws(new Error('test error')) + + await uut.listNftOffers() + assert.fail('unexpected code path') + } catch (err) { + assert.include(err.message, 'test error') + } + }) + + it('should list orders', async () => { + const queryMock = { + sort () { + return this + }, + skip () { + return this + }, + limit () { return [] } + } + // Mock dependencies + sandbox.stub(uut.OfferModel, 'find').returns(queryMock) + + const result = await uut.listNftOffers(1, true) + assert.isArray(result) + }) + }) + describe('#listFungibleOffers', () => { + it('should handle error', async () => { + try { + // Mock dependencies + sandbox.stub(uut.OfferModel, 'find').throws(new Error('test error')) + + await uut.listFungibleOffers() + assert.fail('unexpected code path') + } catch (err) { + assert.include(err.message, 'test error') + } + }) + + it('should list orders', async () => { + const queryMock = { + sort () { + return this + }, + skip () { + return this + }, + limit () { return [] } + } + + // Mock dependencies + sandbox.stub(uut.OfferModel, 'find').returns(queryMock) + + const result = await uut.listFungibleOffers() + assert.isArray(result) + }) + }) + describe('#takeOffer', () => { + it('should handle error if input is not provided', async () => { + try { + await uut.takeOffer() + assert.fail('unexpected code path') + } catch (err) { + assert.include(err.message, 'eventId must be a string') + } + }) + it('should handle error for wrong offer status', async () => { + try { + // Mock dependencies + sandbox.stub(uut, 'findOfferByEvent').returns({ offerStatus: 'completed' }) + + await uut.takeOffer('eventId') + + assert.fail('unexpected code path') + } catch (err) { + assert.include(err.message, 'offer status is not "posted", so offer is dead and can not be countered.') + } + }) + it('should handle error for invalid utxo', async () => { + try { + // Mock dependencies + sandbox.stub(uut, 'findOfferByEvent').returns({ offerStatus: 'posted' }) + sandbox.stub(uut.adapters.wallet.bchWallet, 'utxoIsValid').returns(false) + + await uut.takeOffer('eventId') + + assert.fail('unexpected code path') + } catch (err) { + assert.include(err.message, 'UTXO does not exist. Aborting.') + } + }) + + it('should handle insufficient funds', async () => { + try { + // Mock dependencies + sandbox.stub(uut, 'findOfferByEvent').returns({ offerStatus: 'posted' }) + sandbox.stub(uut.adapters.wallet.bchWallet, 'utxoIsValid').returns(true) + sandbox.stub(uut, 'ensureFunds').throws(new Error('test error')) + + await uut.takeOffer('eventId') + + assert.fail('unexpected code path') + } catch (err) { + assert.include(err.message, 'test error') + } + }) + it('should handle error if counter offer cant be calculated', async () => { + try { + // Mock dependencies + sandbox.stub(uut, 'findOfferByEvent').returns({ offerStatus: 'posted' }) + sandbox.stub(uut.adapters.wallet.bchWallet, 'utxoIsValid').returns(true) + sandbox.stub(uut, 'ensureFunds').resolves(true) + + await uut.takeOffer('eventId') + + assert.fail('unexpected code path') + } catch (err) { + assert.include(err.message, 'Could not calculate the amount of BCH to generate counter offer') + } + }) + it('should handle error if counter offer cant be calculated', async () => { + try { + // Mock dependencies + sandbox.stub(uut, 'findOfferByEvent').returns({ offerStatus: 'posted' }) + sandbox.stub(uut.adapters.wallet.bchWallet, 'utxoIsValid').returns(true) + sandbox.stub(uut, 'ensureFunds').resolves(true) + + await uut.takeOffer('eventId') + + assert.fail('unexpected code path') + } catch (err) { + assert.include(err.message, 'Could not calculate the amount of BCH to generate counter offer') + } + }) + it('should take offer', async () => { + // Mock data + const offerMock = Object.assign({}, mockData.offerMockData.data) + offerMock.remove = async () => { } + offerMock.offerStatus = 'posted' + + // Mock dependencies + sandbox.stub(uut, 'findOfferByEvent').returns(offerMock) + sandbox.stub(uut.adapters.wallet.bchWallet, 'utxoIsValid').returns(true) + sandbox.stub(uut, 'ensureFunds').resolves(true) + sandbox.stub(uut.adapters.wallet, 'moveBch').resolves({ sats: 1 }) + + await uut.takeOffer('eventId') + }) + }) + describe('#ensureFunds', () => { + it('should handle insufficient funds to use p2wdb', async () => { + try { + // Mock dependencies + sandbox.stub(uut.adapters.p2wdb, 'checkForSufficientFunds').resolves(false) + + await uut.ensureFunds(mockData.offerMockData.data) + + assert.fail('Unexpected code path') + } catch (err) { + assert.include(err.message, 'App wallet does not have funds for writing to the P2WDB') + } + }) + + it('should throw error if sats needed could no be able to calculated', async () => { + try { + // Mock dependencies + sandbox.stub(uut.adapters.p2wdb, 'checkForSufficientFunds').resolves(true) + + // Mock Input + const mock = Object.assign({}, mockData.offerMockData.data) + mock.rateInBaseUnit = null + + await uut.ensureFunds(mock) + + assert.fail('Unexpected code path') + } catch (err) { + assert.include(err.message, 'Could not calculate sats needed!') + } + }) + + it('should throw error if app wallet does not have enough bch', async () => { + try { + // Mock dependencies + sandbox.stub(uut.adapters.p2wdb, 'checkForSufficientFunds').resolves(true) + sandbox.stub(uut.adapters.wallet.bchWallet, 'getBalance').resolves(0) + + await uut.ensureFunds(mockData.offerMockData.data) + + assert.fail('Unexpected code path') + } catch (err) { + assert.include(err.message, 'App wallet does not control enough BCH to purchase the tokens.') + } + }) + + it('should handle BUY offer', async () => { + try { + const mock = Object.assign({}, mockData.offerMockData.data) + mock.buyOrSell = 'buy' + + await uut.ensureFunds(mock) + + assert.fail('Unexpected code path') + } catch (err) { + assert.include(err.message, 'Buy offers are not supported yet.') + } + }) + it('should return true', async () => { + // Mock dependencies + sandbox.stub(uut.adapters.p2wdb, 'checkForSufficientFunds').resolves(true) + sandbox.stub(uut.adapters.wallet.bchWallet, 'getBalance').resolves(10 * 10 ** 6) + + const result = await uut.ensureFunds(mockData.offerMockData.data) + assert.isTrue(result) + }) + }) + describe('#findOrderByEvent', () => { + it('should throw an error if hash is not provided', async () => { + try { + await uut.findOfferByEvent() + assert.fail('unexpected code path') + } catch (err) { + assert.include(err.message, 'nostrEventId must be a string') + } + }) + it('should throw an error if order is not found!', async () => { + try { + // Mock dependencies + sandbox.stub(uut.OfferModel, 'findOne').resolves(null) + + await uut.findOfferByEvent('eventId') + + assert.fail('unexpected code path') + } catch (err) { + assert.include(err.message, 'offer not found') + } + }) + it('should return offer by eventId', async () => { + // Mock dependencies + sandbox.stub(uut.OfferModel, 'findOne').resolves({ toObject: () => { return { hash: 'hash' } } }) + + const result = await uut.findOfferByEvent('eventId') + assert.isObject(result) + }) + }) + describe('#flagOffer', () => { + it('should throw an error if input is not provided', async () => { + try { + await uut.flagOffer() + assert.fail('unexpected code path') + } catch (err) { + assert.include(err.message, '"data" property is required') + } + }) + it('should throw an error if offer is not found!', async () => { + try { + // Mock dependencies + sandbox.stub(uut, 'findOfferByEvent').resolves(null) + + const input = { + data: + { + nostrEventId: 'eventId' + } + } + await uut.flagOffer(input) + assert.fail('unexpected code path') + } catch (err) { + assert.include(err.message, 'not found in the database') + } + }) + it('should flag offer', async () => { + // Mock dependencies + sandbox.stub(uut, 'findOfferByEvent').resolves({ flags: ['a', 'b', 'c'], save: () => { } }) + + const input = { + data: + { + nostrEventId: 'eventId' + } + } + const result = await uut.flagOffer(input) + assert.isTrue(result) + }) + }) + + describe('#loadOffers', () => { + it('should handle nostr error', async () => { + try { + // Mock dependencies + sandbox.stub(uut.adapters.nostr, 'read').throws(new Error('test error')) + + await uut.loadOffers() + assert.fail('unexpected code path') + } catch (err) { + assert.include(err.message, 'test error') + } + }) + it('should skip internal function errors ', async () => { + // Mock dependencies + sandbox.stub(uut.adapters.nostr, 'read').resolves([mockData.offerMockData]) + + await uut.loadOffers() + }) + it('should review and load offers', async () => { + // Mock dependencies + sandbox.stub(uut.adapters.nostr, 'read').resolves([JSON.stringify(mockData.offerMockData)]) + + await uut.loadOffers() + }) + }) + describe('#acceptCounterOffer', () => { + it('should return if order is not found!', async () => { + // Mock dependencies + sandbox.stub(uut.orderUseCase, 'findOrderByEvent').throws(new Error('test error')) + + const result = await uut.acceptCounterOffer({ data: { /** .... */ } }) + assert.equal(result, 'N/A') + }) + it('should handle error if counter offer cant be calculated', async () => { + try { + // Mock Data + const mock = Object.assign({}, mockData.offerMockData.data) + mock.rateInBaseUnit = null + + // Mock dependencies + sandbox.stub(uut.orderUseCase, 'findOrderByEvent').resolves(mock) + + await uut.acceptCounterOffer({ data: { /** .... */ } }) + assert.fail('unexpected code path') + } catch (err) { + assert.include(err.message, 'Could not calculate the amount of BCH offered in the Counter Offer') + } + }) + it('should handle error for wrong transaction output', async () => { + try { + // Mock dependencies + + sandbox.stub(uut.orderUseCase, 'findOrderByEvent').resolves(mockData.offerMockData.data) + sandbox.stub(uut.adapters.wallet.bchWallet.bchjs.BitcoinCash, 'toSatoshi').returns(0) + sandbox.stub(uut.adapters.wallet, 'deseralizeTx').resolves(mockData.deserealizeTxMock) + + await uut.acceptCounterOffer({ data: { /** .... */ } }) + assert.fail('unexpected code path') + } catch (err) { + assert.include(err.message, 'The Counter Offer has an output of ') + assert.include(err.message, 'which does not match the required') + } + }) + it('should handle error for wrong transaction output address', async () => { + try { + // Mock data + const mock = Object.assign({}, mockData.offerMockData.data) + mock.makerAddr = 'bitcoincash:qzy97glp47ut7tstm5g0tlrmkhk742795gkmyc7477' // Unknow Adress + mock.rateInBaseUnit = 0 + mock.numTokens = 0 + + // Mock dependencies + sandbox.stub(uut.orderUseCase, 'findOrderByEvent').resolves(mock) + sandbox.stub(uut.adapters.wallet.bchWallet.bchjs.BitcoinCash, 'toSatoshi').returns(0) + + sandbox.stub(uut.adapters.wallet, 'deseralizeTx').resolves(mockData.deserealizeTxMock) + + await uut.acceptCounterOffer({ data: { /** .... */ } }) + assert.fail('unexpected code path') + } catch (err) { + assert.include(err.message, 'The Counter Offer has an output address of') + assert.include(err.message, 'which does not match the Maker address') + } + }) + it('should return tx id', async () => { + // Mock data + const mock = Object.assign({}, mockData.offerMockData.data) + mock.makerAddr = mockData.deserealizeTxMock.vout[2].scriptPubKey.addresses[0] // Maker Address + mock.rateInBaseUnit = 0 + mock.numTokens = 0 + + // Mock dependencies + sandbox.stub(uut.orderUseCase, 'findOrderByEvent').resolves(mock) + sandbox.stub(uut.adapters.wallet.bchWallet.bchjs.BitcoinCash, 'toSatoshi').returns(0) + sandbox.stub(uut.adapters.wallet, 'deseralizeTx').resolves(mockData.deserealizeTxMock) + + // + const result = await uut.acceptCounterOffer({ data: { /** .... */ } }) + assert.isString(result) + }) + }) }) From 003480bcb52b964bd02cf8767c97fede933dcc62 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 4 Dec 2024 17:12:26 -0800 Subject: [PATCH 4/6] fix(offer): Refactored _this in rest-api controller --- src/controllers/rest-api/offer/controller.js | 29 ++++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/controllers/rest-api/offer/controller.js b/src/controllers/rest-api/offer/controller.js index 7f89ea8..d647193 100644 --- a/src/controllers/rest-api/offer/controller.js +++ b/src/controllers/rest-api/offer/controller.js @@ -4,7 +4,6 @@ import wlogger from '../../../adapters/wlogger.js' -let _this class OfferRESTControllerLib { constructor (localConfig = {}) { @@ -26,7 +25,13 @@ class OfferRESTControllerLib { this.OfferModel = this.adapters.localdb.Offer // this.userUseCases = this.useCases.user - _this = this + // Bind 'this' object to all subfunctions. + this.createOffer = this.createOffer.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.handleError = this.handleError.bind(this) } // No api-doc documentation because this wont be a public endpoint @@ -36,7 +41,7 @@ class OfferRESTControllerLib { const offerObj = ctx.request.body - await _this.useCases.offer.createOffer(offerObj) + await this.useCases.offer.createOffer(offerObj) ctx.body = { success: true @@ -45,7 +50,7 @@ class OfferRESTControllerLib { // console.log(`err.message: ${err.message}`) // console.log('err: ', err) // ctx.throw(422, err.message) - _this.handleError(ctx, err) + this.handleError(ctx, err) } } @@ -55,12 +60,12 @@ class OfferRESTControllerLib { let page = ctx.params.page if (!page) page = 0 - const offers = await _this.useCases.offer.listOffers(page) + const offers = await this.useCases.offer.listOffers(page) ctx.body = offers } catch (err) { console.log('Error in listOffers REST API handler: ', err) - _this.handleError(ctx, err) + this.handleError(ctx, err) } } @@ -70,12 +75,12 @@ class OfferRESTControllerLib { let page = ctx.params.page if (!page) page = 0 - const offers = await _this.useCases.offer.listNftOffers(page) + const offers = await this.useCases.offer.listNftOffers(page) ctx.body = offers } catch (err) { console.log('Error in listNftOffers REST API handler: ', err) - _this.handleError(ctx, err) + this.handleError(ctx, err) } } @@ -85,12 +90,12 @@ class OfferRESTControllerLib { let page = ctx.params.page if (!page) page = 0 - const offers = await _this.useCases.offer.listFungibleOffers(page) + const offers = await this.useCases.offer.listFungibleOffers(page) ctx.body = offers } catch (err) { console.log('Error in Fungible REST API handler: ', err) - _this.handleError(ctx, err) + this.handleError(ctx, err) } } @@ -102,12 +107,12 @@ class OfferRESTControllerLib { const nostrEventId = ctx.request.body.nostrEventId - const eventId = await _this.useCases.offer.takeOffer(nostrEventId) + const eventId = await this.useCases.offer.takeOffer(nostrEventId) ctx.body = { eventId } } catch (err) { wlogger.error('Error in takeOffer() REST API handler.') - _this.handleError(ctx, err) + this.handleError(ctx, err) } } From bf74d50bb0d0eef32201621a209753c56b42365f Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 4 Dec 2024 17:12:40 -0800 Subject: [PATCH 5/6] linting --- src/controllers/rest-api/offer/controller.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/controllers/rest-api/offer/controller.js b/src/controllers/rest-api/offer/controller.js index d647193..10daa64 100644 --- a/src/controllers/rest-api/offer/controller.js +++ b/src/controllers/rest-api/offer/controller.js @@ -4,7 +4,6 @@ import wlogger from '../../../adapters/wlogger.js' - class OfferRESTControllerLib { constructor (localConfig = {}) { // Dependency Injection. From 03b7ed980ad0f0e13737b94ad7d3b82efe4f148d Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 6 Dec 2024 09:35:33 -0800 Subject: [PATCH 6/6] fix(IPFS): Updating IPFS gateway for getting token data --- config/env/common.js | 2 +- src/use-cases/offer/index.js | 13 +++++++---- src/use-cases/order.js | 2 +- test/unit/use-cases/offer.use-case.unit.js | 25 +++++++++++----------- util/wallet/sweep-funds.js | 9 ++++---- 5 files changed, 29 insertions(+), 22 deletions(-) diff --git a/config/env/common.js b/config/env/common.js index 399917a..9848110 100644 --- a/config/env/common.js +++ b/config/env/common.js @@ -166,7 +166,7 @@ export default { chatPubSubChan: 'psf-ipfs-chat-001', // IPFS gateway - ipfsGateway: process.env.IPFS_GATEWAY ? process.env.IPFS_GATEWAY : 'https://p2wdb-gateway-678.fullstack.cash/ipfs/', + ipfsGateway: process.env.IPFS_GATEWAY ? process.env.IPFS_GATEWAY : 'https://pin.fullstack.cash/ipfs/download/', // This can add specific Circuit Relay v2 servers to connect to. bootstrapRelays: [ diff --git a/src/use-cases/offer/index.js b/src/use-cases/offer/index.js index cfb8e9b..3f61050 100644 --- a/src/use-cases/offer/index.js +++ b/src/use-cases/offer/index.js @@ -384,9 +384,9 @@ class OfferUseCases { // Ensure the app wallet has enough funds to write to the P2WDB. // Note : this validation should be deprecated for nostr functionality? - const wif = this.adapters.wallet.bchWallet.walletInfo.privateKey - const canWriteToP2WDB = await this.adapters.p2wdb.checkForSufficientFunds(wif) - if (!canWriteToP2WDB) throw new Error('App wallet does not have funds for writing to the P2WDB.') + // const wif = this.adapters.wallet.bchWallet.walletInfo.privateKey + // const canWriteToP2WDB = await this.adapters.p2wdb.checkForSufficientFunds(wif) + // if (!canWriteToP2WDB) throw new Error('App wallet does not have funds for writing to the P2WDB.') if (offerEntity.buyOrSell.includes('sell')) { // Sell Offer @@ -456,13 +456,17 @@ class OfferUseCases { const offer = await this.OfferModel.findOne({ utxoTxid }) + // TODO: Offer should be found by TXID, then if there is more than one + // result, they should be filtered by the vout property. That will leave + // one remaining UTXO. + if (!offer) { throw new Error('offer not found') } return offer } catch (error) { - console.error('Error in use-cases/offer/findOfferByTxid(): ') + console.error('Error in use-cases/offer/findOfferByTxid(): ', error.message) throw error } } @@ -629,6 +633,7 @@ class OfferUseCases { } } + // Flag offers as NSFW. async flagOffer (flagData = {}) { try { if (!flagData.data) throw new Error('"data" property is required') diff --git a/src/use-cases/order.js b/src/use-cases/order.js index 505e402..bed43f5 100644 --- a/src/use-cases/order.js +++ b/src/use-cases/order.js @@ -55,7 +55,7 @@ class OrderLib { // Ensure sufficient tokens exist to create the order. // await this.ensureFunds(orderEntity) - await this.retryQueue.addToQueue(this.ensureFunds, orderEntity) + // await this.retryQueue.addToQueue(this.ensureFunds, orderEntity) // Get Ticker for token ID. // const tokenData = await this.adapters.wallet.bchWallet.getTxData([entryObj.tokenId]) diff --git a/test/unit/use-cases/offer.use-case.unit.js b/test/unit/use-cases/offer.use-case.unit.js index 03c07c3..005b857 100644 --- a/test/unit/use-cases/offer.use-case.unit.js +++ b/test/unit/use-cases/offer.use-case.unit.js @@ -503,19 +503,20 @@ describe('#offer-use-case', () => { await uut.takeOffer('eventId') }) }) + describe('#ensureFunds', () => { - it('should handle insufficient funds to use p2wdb', async () => { - try { - // Mock dependencies - sandbox.stub(uut.adapters.p2wdb, 'checkForSufficientFunds').resolves(false) - - await uut.ensureFunds(mockData.offerMockData.data) - - assert.fail('Unexpected code path') - } catch (err) { - assert.include(err.message, 'App wallet does not have funds for writing to the P2WDB') - } - }) + // it('should handle insufficient funds to use p2wdb', async () => { + // try { + // // Mock dependencies + // sandbox.stub(uut.adapters.p2wdb, 'checkForSufficientFunds').resolves(false) + // + // await uut.ensureFunds(mockData.offerMockData.data) + // + // assert.fail('Unexpected code path') + // } catch (err) { + // assert.include(err.message, 'App wallet does not have funds for writing to the P2WDB') + // } + // }) it('should throw error if sats needed could no be able to calculated', async () => { try { diff --git a/util/wallet/sweep-funds.js b/util/wallet/sweep-funds.js index acd098d..fce2588 100644 --- a/util/wallet/sweep-funds.js +++ b/util/wallet/sweep-funds.js @@ -9,10 +9,10 @@ // Public npm libraries import BCHJS from '@psf/bch-js' -import BchTokenSweep from 'bch-token-sweep/index' +import BchTokenSweep from 'bch-token-sweep' // Local libraries -import WalletAdapter from '../../src/adapters/wallet' +import WalletAdapter from '../../src/adapters/wallet.js' // Constants const EMTPY_ADDR_CUTOFF = 15 @@ -22,6 +22,7 @@ async function sweepFunds () { // Open the wallet files. const wallet = new WalletAdapter() const walletInfo = await wallet.openWallet() + const bchWallet = await wallet.instanceWallet(walletInfo) console.log('walletInfo: ', walletInfo) const rootAddr = walletInfo.cashAddress @@ -49,7 +50,7 @@ async function sweepFunds () { const sweeper = new BchTokenSweep( wifToSweep, rootWif, - bchjs, + bchWallet, 550, rootAddr ) @@ -68,7 +69,7 @@ async function sweepFunds () { // Wait between loop iterations. await bchjs.Util.sleep(3000) } catch (err) { - console.log(`error message with index ${hdIndex}: ${err.message}`) + console.log(`error message with index ${hdIndex}: ${err}`) emptyAddrCnt++ }