From 88e27f47f529219010bc907a9804c81081454eb0 Mon Sep 17 00:00:00 2001 From: Daniel Gonzalez Date: Fri, 21 Nov 2025 17:34:05 -0400 Subject: [PATCH] feat(offers): Track Counter Offers --- src/adapters/localdb/index.js | 2 + src/adapters/localdb/models/counter-offer.js | 11 ++ src/controllers/rest-api/offer/controller.js | 47 +++++++ src/controllers/rest-api/offer/index.js | 1 + src/entities/counterOffer.js | 53 ++++++++ src/use-cases/offer/index.js | 29 +++++ .../offer/offer.rest.controller.unit.js | 22 ++++ .../unit/entities/counterOffer.entity.unit.js | 116 ++++++++++++++++++ test/unit/mocks/adapters/index.js | 11 ++ test/unit/mocks/use-cases/index.js | 3 + test/unit/use-cases/offer.use-case.unit.js | 71 +++++++++-- 11 files changed, 355 insertions(+), 11 deletions(-) create mode 100644 src/adapters/localdb/models/counter-offer.js create mode 100644 src/entities/counterOffer.js create mode 100644 test/unit/entities/counterOffer.entity.unit.js diff --git a/src/adapters/localdb/index.js b/src/adapters/localdb/index.js index 3551b8b..1cc4865 100644 --- a/src/adapters/localdb/index.js +++ b/src/adapters/localdb/index.js @@ -11,6 +11,7 @@ import Usage from './models/usage.js' import SmAccount from './models/smAccount.js' import DeletedChat from './models/deletedChat.js' import DeletedPost from './models/deletedPost.js' +import CounterOffer from './models/counter-offer.js' class LocalDB { constructor () { @@ -23,6 +24,7 @@ class LocalDB { this.SmAccount = SmAccount this.DeletedChat = DeletedChat this.DeletedPost = DeletedPost + this.CounterOffer = CounterOffer } } diff --git a/src/adapters/localdb/models/counter-offer.js b/src/adapters/localdb/models/counter-offer.js new file mode 100644 index 0000000..a8f8f3a --- /dev/null +++ b/src/adapters/localdb/models/counter-offer.js @@ -0,0 +1,11 @@ +import mongoose from 'mongoose' + +const CounterOffer = new mongoose.Schema({ + nostrEventId: { type: String, required: true }, // Nostr Event Id. + takerAddr: { type: String, default: null }, + takerNpub: { type: String, default: null }, + counterOfferAddr: { type: String, default: null }, + counterOfferUtxo: { type: String, default: null } +}) + +export default mongoose.model('counterOffer', CounterOffer) diff --git a/src/controllers/rest-api/offer/controller.js b/src/controllers/rest-api/offer/controller.js index 9810115..3e8d711 100644 --- a/src/controllers/rest-api/offer/controller.js +++ b/src/controllers/rest-api/offer/controller.js @@ -35,6 +35,7 @@ class OfferRESTControllerLib { this.takeOffer = this.takeOffer.bind(this) this.listOffersByAddress = this.listOffersByAddress.bind(this) this.syncOfferMutableData = this.syncOfferMutableData.bind(this) + this.listCounterOffersByAddress = this.listCounterOffersByAddress.bind(this) this.handleError = this.handleError.bind(this) } @@ -408,6 +409,52 @@ class OfferRESTControllerLib { } } + /** + * @api {get} /offer/list/counter-offer/:addr List all counter offers being made by a given address + * @apiPermission public + * @apiName listCounterOffersByAddress + * @apiGroup REST Offer + * + * @apiExample Example usage: + * curl -H "Content-Type: application/json" -X GET localhost:5000/offer/list/counter-offer/bitcoincash:qq54a3xyyptty63ztlcavzzh64gy4d247qs4ueaupe + * + * + + * @apiSuccess {String} offers.nostrEventId Nostr event id + * @apiSuccess {String} offers.takerNpub Nostr Npub. + * @apiSuccess {String} offers.takerAddr BCH Address. + * @apiSuccess {String} offers.counterOfferAddr BCH Address + * @apiSuccess {String} offers.counterOfferUtxo Utxo txid. + * + * + * @apiSuccessExample {json} Success-Response: + * HTTP/1.1 200 OK + * { + * "counterOffers": [{ + * "_id": "56bd1da600a526986cf65c80", + * "nostrEventId": "1234567890", + * "takerAddr": "bitcoincash:qq54a3xyyptty63ztlcavzzh64gy4d247qs4ueaupe", + * "takerNpub": "npub1nhc47z2tf42a4ps7plzcyax2j76gdausve763tqw3xuw7jjd7ceqppquly", + * "counterOfferAddr": "bitcoincash:qzltx40ldxr53edchprppj5lpg4q7vl20usx25ga44", + * "counterOfferUtxo": "71dab5b81cf06f3495aeebcc70fb351109e5e88bc85da67d0d144c0e5ca0bd6a", + * }] + * } + * + * @apiUse TokenError + */ + async listCounterOffersByAddress (ctx) { + try { + const addr = ctx.params.addr + + const counterOffers = await this.useCases.offer.listCounterOffersByAddress(addr) + + ctx.body = { counterOffers } + } catch (err) { + console.log('Error in listCounterOffersByAddress REST API handler: ', err) + this.handleError(ctx, err) + } + } + // DRY error handler handleError (ctx, err) { console.log('err', err.message) diff --git a/src/controllers/rest-api/offer/index.js b/src/controllers/rest-api/offer/index.js index 10fd4f3..8bf79c6 100644 --- a/src/controllers/rest-api/offer/index.js +++ b/src/controllers/rest-api/offer/index.js @@ -60,6 +60,7 @@ class OfferRouter { this.router.get('/list/nft/:page', this.offerRESTController.listNftOffers) this.router.get('/list/fungible/:page', this.offerRESTController.listFungibleOffers) this.router.get('/list/addr/:addr', this.offerRESTController.listOffersByAddress) + this.router.get('/list/counter-offer/:addr', this.offerRESTController.listCounterOffersByAddress) // Attach the Controller routes to the Koa app. app.use(this.router.routes()) diff --git a/src/entities/counterOffer.js b/src/entities/counterOffer.js new file mode 100644 index 0000000..901daf0 --- /dev/null +++ b/src/entities/counterOffer.js @@ -0,0 +1,53 @@ +/* + Counter Offer Entity +*/ + +class CounterOfferEntity { + validate (offerData = {}) { + console.log('counter offer data input validation ', offerData) + // Throw an error if input object does not have a data property + if (!offerData.data) { + throw new Error( + 'Input to counterOffer.validate() must be an object with a data property.' + ) + } + + const { + nostrEventId, + takerAddr, + takerNpub, + counterOfferAddr, + counterOfferUtxo + } = offerData.data + + // Input Validation + if (!nostrEventId || typeof nostrEventId !== 'string') { + throw new Error("Property 'nostrEventId' must be a string.") + } + // Next validations are optional, to allow old counter offers without the data. + if (takerAddr && typeof takerAddr !== 'string') { + throw new Error("Property 'takerAddr' must be a string.") + } + if (takerNpub && typeof takerNpub !== 'string') { + throw new Error("Property 'takerNpub' must be a string.") + } + if (counterOfferAddr && typeof counterOfferAddr !== 'string') { + throw new Error("Property 'counterOfferAddr' must be a string.") + } + if (counterOfferUtxo && typeof counterOfferUtxo !== 'string') { + throw new Error("Property 'counterOfferUtxo' must be a string.") + } + + const validatedCounterOfferData = { + nostrEventId, + takerAddr, + takerNpub, + counterOfferAddr, + counterOfferUtxo + } + + return validatedCounterOfferData + } +} + +export default CounterOfferEntity diff --git a/src/use-cases/offer/index.js b/src/use-cases/offer/index.js index e46a383..b9675c5 100644 --- a/src/use-cases/offer/index.js +++ b/src/use-cases/offer/index.js @@ -19,6 +19,7 @@ import RetryQueue from '@chris.troutner/retry-queue' // Local libraries import OfferEntity from '../../entities/offer.js' +import CounterOfferEntity from '../../entities/counterOffer.js' import config from '../../../config/index.js' const DEFAULT_ENTRIES_PER_PAGE = 20 @@ -45,7 +46,9 @@ class OfferUseCases { this.config = config this.axios = axios this.offerEntity = new OfferEntity() + this.counterOfferEntity = new CounterOfferEntity() this.OfferModel = this.adapters.localdb.Offer + this.CounterOfferModel = this.adapters.localdb.CounterOffer this.retryQueue = new RetryQueue({ retryPeriod: 1000, attempts: 3 }) // Bind 'this' object to functions @@ -66,6 +69,7 @@ class OfferUseCases { this.loadOffers = this.loadOffers.bind(this) this.listOffersByAddress = this.listOffersByAddress.bind(this) this.syncOfferMutableData = this.syncOfferMutableData.bind(this) + this.listCounterOffersByAddress = this.listCounterOffersByAddress.bind(this) // State this.seenOffers = [] @@ -595,10 +599,21 @@ class OfferUseCases { // and broadcasts the transaction to accept the Counter Offer. async acceptCounterOffer (offerData) { try { + console.log('offerData', offerData) // console.log(`acceptCounterOffer() offerData: ${JSON.stringify(offerData, null, 2)}`) // Quickly skip over offers that have already been processed. const eventId = offerData.data.nostrEventId + + // Create counter offer entity for nostr event id if it not exist + const existingCounterOffer = await this.CounterOfferModel.findOne(({ nostrEventId: eventId })) + if (!existingCounterOffer) { + const counterOEntity = this.counterOfferEntity.validate(offerData) + // Create new counter offfer model and save it. + const counterOffer = new this.CounterOfferModel(counterOEntity) + await counterOffer.save() + } + if (this.seenOffers.includes(eventId)) { // console.log(`Offer with event ID ${eventId} already processed. Skipping.`) return false @@ -1020,6 +1035,20 @@ class OfferUseCases { throw err } } + + async listCounterOffersByAddress (takerAddr) { + try { + if (!takerAddr || typeof takerAddr !== 'string') { + throw new Error('takerAddr must be a string') + } + + const counterOffers = await this.CounterOfferModel.find({ takerAddr }) + return counterOffers + } catch (err) { + console.error('Error in listCounterOffersByAddress(): ', err) + throw err + } + } } export default OfferUseCases diff --git a/test/unit/controllers/rest-api/offer/offer.rest.controller.unit.js b/test/unit/controllers/rest-api/offer/offer.rest.controller.unit.js index 96dcded..bcac0fa 100644 --- a/test/unit/controllers/rest-api/offer/offer.rest.controller.unit.js +++ b/test/unit/controllers/rest-api/offer/offer.rest.controller.unit.js @@ -251,6 +251,28 @@ describe('#Offer-REST-Router', () => { }) }) + describe('#listCounterOffersByAddress', () => { + it('should list counter offers by address', async () => { + ctx.params = { addr: 'testAddress' } + sandbox.stub(uut.useCases.offer, 'listCounterOffersByAddress').resolves([]) + await uut.listCounterOffersByAddress(ctx) + assert.isArray(ctx.body.counterOffers) + }) + it('should catch and throw an error', async () => { + try { + ctx.params = { addr: 'testAddress' } + sandbox + .stub(uut.useCases.offer, 'listCounterOffersByAddress') + .throws(new Error('test error')) + + await uut.listCounterOffersByAddress(ctx) + assert.fail('Unexpected code path') + } catch (err) { + assert.include(err.message, 'test error') + } + }) + }) + describe('#handleError', () => { it('should still throw error if there is no message', () => { try { diff --git a/test/unit/entities/counterOffer.entity.unit.js b/test/unit/entities/counterOffer.entity.unit.js new file mode 100644 index 0000000..893cc9e --- /dev/null +++ b/test/unit/entities/counterOffer.entity.unit.js @@ -0,0 +1,116 @@ +/* + Unit tests for the CounterOffer entity library. +*/ + +import { assert } from 'chai' +import sinon from 'sinon' + +import CounterOfferEntity from '../../../src/entities/counterOffer.js' + +let sandbox +let uut + +describe('#CounterOffer-Entity', () => { + before(async () => {}) + + beforeEach(() => { + uut = new CounterOfferEntity() + + sandbox = sinon.createSandbox() + }) + + afterEach(() => sandbox.restore()) + + describe('#validate', () => { + it('should throw an error if data is not provided', () => { + try { + uut.validate({ }) + } catch (err) { + assert.include(err.message, 'Input to counterOffer.validate() must be an object with a data property.') + } + }) + it('should throw an error if nostrEventId is not provided', () => { + try { + uut.validate({ data: {} }) + } catch (err) { + assert.include(err.message, "Property 'nostrEventId' must be a string") + } + }) + it('should throw an error if provided takerAddr is not string', () => { + try { + const inObj = { + nostrEventId: 'nostr event id', + takerAddr: 1234 + } + uut.validate({ data: inObj }) + } catch (err) { + assert.include(err.message, "Property 'takerAddr' must be a string") + } + }) + it('should throw an error if provided takerNpub is not string', () => { + try { + const inObj = { + nostrEventId: 'nostr event id', + takerAddr: 'taker address', + takerNpub: 1234 + } + uut.validate({ data: inObj }) + } catch (err) { + assert.include(err.message, "Property 'takerNpub' must be a string") + } + }) + it('should throw an error if provided counterOfferAddr is not string', () => { + try { + const inObj = { + nostrEventId: 'nostr event id', + takerAddr: 'taker address', + takerNpub: 'npub', + counterOfferAddr: 1234 + } + uut.validate({ data: inObj }) + } catch (err) { + assert.include(err.message, "Property 'counterOfferAddr' must be a string") + } + }) + it('should throw an error if provided counterOfferUtxo is not string', () => { + try { + const inObj = { + nostrEventId: 'nostr event id', + takerAddr: 'taker address', + takerNpub: 'npub', + counterOfferAddr: 'counter offer address', + counterOfferUtxo: 1234 + } + uut.validate({ data: inObj }) + } catch (err) { + assert.include(err.message, "Property 'counterOfferUtxo' must be a string") + } + }) + it('should return a counter offer object', () => { + const inObj = { + nostrEventId: 'nostr event id', + takerAddr: 'taker address', + takerNpub: 'npub', + counterOfferAddr: 'counter offer address', + counterOfferUtxo: 'utxo txid' + } + + const counterOfferEntity = uut.validate({ data: inObj }) + + assert.property(counterOfferEntity, 'nostrEventId') + assert.equal(counterOfferEntity.nostrEventId, inObj.nostrEventId) + + assert.property(counterOfferEntity, 'takerAddr') + assert.equal(counterOfferEntity.takerAddr, inObj.takerAddr) + + assert.property(counterOfferEntity, 'takerNpub') + assert.equal(counterOfferEntity.takerNpub, inObj.takerNpub) + + assert.property(counterOfferEntity, 'counterOfferAddr') + assert.equal(counterOfferEntity.counterOfferAddr, inObj.counterOfferAddr) + + assert.property(counterOfferEntity, 'counterOfferUtxo') + assert.equal(counterOfferEntity.counterOfferUtxo, inObj.counterOfferUtxo) + }) + }) +}) diff --git a/test/unit/mocks/adapters/index.js b/test/unit/mocks/adapters/index.js index 9469c86..a81d564 100644 --- a/test/unit/mocks/adapters/index.js +++ b/test/unit/mocks/adapters/index.js @@ -154,6 +154,17 @@ const localdb = { return {} } }, + CounterOffer: class CounterOffer { + constructor (obj) {} + + static findById () {} + static find () {} + static findOne () {} + static countDocuments(){} + async save () { + return {} + } + }, SmAccount: class SmAccount { constructor (obj) {} diff --git a/test/unit/mocks/use-cases/index.js b/test/unit/mocks/use-cases/index.js index 91a088e..1637d72 100644 --- a/test/unit/mocks/use-cases/index.js +++ b/test/unit/mocks/use-cases/index.js @@ -74,6 +74,9 @@ class Offer { async syncOfferMutableData(){ return {} } + async listCounterOffersByAddress() { + return [] + } } class Order { diff --git a/test/unit/use-cases/offer.use-case.unit.js b/test/unit/use-cases/offer.use-case.unit.js index ce9aaf2..5491a0a 100644 --- a/test/unit/use-cases/offer.use-case.unit.js +++ b/test/unit/use-cases/offer.use-case.unit.js @@ -835,6 +835,25 @@ describe('#offer-use-case', () => { }) describe('#acceptCounterOffer', () => { + it('should capture counter offer if does not exist', async () => { + const offerObj = mockData.offerMockData + sandbox.stub(uut.CounterOfferModel, 'findOne').resolves(null) + const spy = sandbox.stub(uut.CounterOfferModel.prototype, 'save') + // Mock dependencies + + await uut.acceptCounterOffer(offerObj) + assert.isTrue(spy.calledOnce) + }) + it('should not save counter offer if already exist', async () => { + const offerObj = mockData.offerMockData + sandbox.stub(uut.CounterOfferModel, 'findOne').resolves(offerObj.data) + const spy = sandbox.stub(uut.CounterOfferModel.prototype, 'save') + // Mock dependencies + + await uut.acceptCounterOffer(offerObj) + assert.isFalse(spy.calledOnce) + }) + it('should return false if order already processed', async () => { const offerObj = mockData.offerMockData uut.seenOffers.push(offerObj.data.nostrEventId) @@ -846,14 +865,18 @@ describe('#offer-use-case', () => { it('should return if order is not found!', async () => { // Mock dependencies + const offerObj = mockData.offerMockData + sandbox.stub(uut.orderUseCase, 'findOrderByUtxo').throws(new Error('test error')) - const result = await uut.acceptCounterOffer({ data: { /** .... */ } }) + const result = await uut.acceptCounterOffer(offerObj) assert.equal(result, 'N/A') }) it('should return N/A on axios error', async () => { // Mock dependencies + const offerObj = mockData.offerMockData + const mock = Object.assign({}, mockData.offerMockData.data) sandbox.stub(uut.orderUseCase, 'findOrderByUtxo').resolves(mock) // sandbox.stub(uut.adapters.wallet.bchWallet, 'utxoIsValid').throws(new Error('test error')) @@ -861,32 +884,38 @@ describe('#offer-use-case', () => { axiosErr.isAxiosError = true sandbox.stub(uut.retryQueue, 'addToQueue').throws(axiosErr) - const result = await uut.acceptCounterOffer({ data: { /** .... */ } }) + const result = await uut.acceptCounterOffer(offerObj) assert.equal(result, 'N/A') }) it('should return if utxo can not be validated', async () => { // Mock dependencies + const offerObj = mockData.offerMockData + const mock = Object.assign({}, mockData.offerMockData.data) sandbox.stub(uut.orderUseCase, 'findOrderByUtxo').resolves(mock) // sandbox.stub(uut.adapters.wallet.bchWallet, 'utxoIsValid').throws(new Error('test error')) sandbox.stub(uut.retryQueue, 'addToQueue').throws(new Error('test error')) - const result = await uut.acceptCounterOffer({ data: { /** .... */ } }) + const result = await uut.acceptCounterOffer(offerObj) assert.equal(result, 'N/A') }) it('should return if utxo is invalid', async () => { // Mock dependencies + const offerObj = mockData.offerMockData + const mock = Object.assign({}, mockData.offerMockData.data) sandbox.stub(uut.orderUseCase, 'findOrderByUtxo').resolves(mock) sandbox.stub(uut.adapters.wallet.bchWallet, 'utxoIsValid').resolves(false) - const result = await uut.acceptCounterOffer({ data: { /** .... */ } }) + const result = await uut.acceptCounterOffer(offerObj) assert.equal(result, 'N/A') }) it('should handle error if counter offer cant be calculated', async () => { try { + const offerObj = mockData.offerMockData + // Mock Data const mock = Object.assign({}, mockData.offerMockData.data) mock.rateInBaseUnit = null @@ -895,8 +924,7 @@ describe('#offer-use-case', () => { sandbox.stub(uut.orderUseCase, 'findOrderByEvent').resolves(mock) sandbox.stub(uut.orderUseCase, 'findOrderByUtxo').resolves(mock) - const result = await uut.acceptCounterOffer({ data: {} }) - console.log('result: ', result) + await uut.acceptCounterOffer(offerObj) assert.fail('unexpected code path') } catch (err) { assert.include(err.message, 'Could not calculate the amount of BCH offered in the Counter Offer') @@ -906,6 +934,7 @@ describe('#offer-use-case', () => { it('should handle error for wrong transaction output', async () => { try { // Mock Data + const mock = Object.assign({}, mockData.offerMockData.data) // Mock dependencies @@ -923,6 +952,7 @@ describe('#offer-use-case', () => { it('should skip transactions that do not have an output for the operator', async () => { // Mock data + const mock = Object.assign({}, mockData.offerMockData.data) mock.makerAddr = 'bitcoincash:qzy97glp47ut7tstm5g0tlrmkhk742795gkmyc7477' // Unknow Adress mock.rateInBaseUnit = 0 @@ -934,7 +964,7 @@ describe('#offer-use-case', () => { sandbox.stub(uut.adapters.wallet, 'deseralizeTx').resolves(mockData.deserealizeTxMockNoOperatorOut) - const result = await uut.acceptCounterOffer({ data: { /** .... */ } }) + const result = await uut.acceptCounterOffer({ data: mock }) assert.equal(result, 'N/A') }) it('should skip transactions if operator address does not match', async () => { @@ -952,7 +982,7 @@ describe('#offer-use-case', () => { sandbox.stub(uut.adapters.wallet, 'deseralizeTx').resolves(mockData.deserealizeTxMock) - await uut.acceptCounterOffer({ data: {} }) + await uut.acceptCounterOffer({ data: mock }) assert.fail('unexpected code path') } catch (error) { assert.include(error.message, 'The Counter Offer has an output address of') @@ -974,7 +1004,7 @@ describe('#offer-use-case', () => { sandbox.stub(uut.adapters.wallet, 'deseralizeTx').resolves(mockData.deserealizeTxMock) - await uut.acceptCounterOffer({ data: {} }) + await uut.acceptCounterOffer({ data: mock }) assert.fail('unexpected code path') } catch (err) { assert.include(err.message, 'The Counter Offer has an output address of') @@ -997,7 +1027,7 @@ describe('#offer-use-case', () => { sandbox.stub(uut.adapters.wallet, 'deseralizeTx').resolves(mockData.deserealizeTxMock) - const result = await uut.acceptCounterOffer({ data: {} }) + const result = await uut.acceptCounterOffer({ data: mock }) assert.equal(result, 'N/A') }) @@ -1017,7 +1047,7 @@ describe('#offer-use-case', () => { sandbox.stub(uut.adapters.wallet, 'deseralizeTx').resolves(mockData.deserealizeTxMock) // - const result = await uut.acceptCounterOffer({ data: {} }) + const result = await uut.acceptCounterOffer({ data: mock }) assert.isString(result) assert.notEqual('N/A') }) @@ -1130,4 +1160,23 @@ describe('#offer-use-case', () => { assert.isNumber(offer.lastUpdatedTokenData) }) }) + describe('#listCounterOffersByAddress', () => { + it('should throw an error if takerAddr is not provided!', async () => { + try { + await uut.listCounterOffersByAddress() + + assert.fail('unexpected code path') + } catch (err) { + assert.include(err.message, 'takerAddr must be a string') + } + }) + + it('should return counter offers by address', async () => { + // Mock dependencies + sandbox.stub(uut.CounterOfferModel, 'find').resolves([]) + + const result = await uut.listCounterOffersByAddress('bitcoincash:qzltx40ldxr53edchprppj5lpg4q7vl20usx25ga44') + assert.isArray(result) + }) + }) })