diff --git a/config/env/common.js b/config/env/common.js index 9f55345..58f058c 100644 --- a/config/env/common.js +++ b/config/env/common.js @@ -191,5 +191,9 @@ export default { disableNewAccounts: process.env.DISABLE_NEW_ACCOUNTS ? true : false, // Admin password - adminPassword: process.env.ADMIN_PASSWORD + adminPassword: process.env.ADMIN_PASSWORD, + + // The Operator of BCH DEX can elect to receive a percentage of each sale. + operatorAddress: process.env.OPERATOR_ADDRESS ? process.env.OPERATOR_ADDRESS : 'bitcoincash:qqsrke9lh257tqen99dkyy2emh4uty0vky9y0z0lsr', + operatorPercentage: process.env.OPERATOR_PERCENTAGE ? parseFloat(process.env.OPERATOR_PERCENTAGE) : 2.0 } diff --git a/src/adapters/localdb/models/offer.js b/src/adapters/localdb/models/offer.js index 5b1a375..69358d9 100644 --- a/src/adapters/localdb/models/offer.js +++ b/src/adapters/localdb/models/offer.js @@ -35,8 +35,11 @@ const Offer = new mongoose.Schema({ lokadId: { type: String }, messageType: { type: Number }, messageClass: { type: Number }, - nostrEventId: { type: String } // Nostr Event Id. + nostrEventId: { type: String }, // Nostr Event Id. + // Operator fee and address + operatorAddress: { type: String }, + operatorPercentage: { type: Number } }) export default mongoose.model('offer', Offer) diff --git a/src/adapters/localdb/models/order.js b/src/adapters/localdb/models/order.js index fd0651f..0f38c1c 100644 --- a/src/adapters/localdb/models/order.js +++ b/src/adapters/localdb/models/order.js @@ -43,7 +43,11 @@ const Order = new mongoose.Schema({ // Additional properties found in createOrder dataType: { type: String, required: true }, - userId: { type: String, required: true } + userId: { type: String, required: true }, + + // Operator fee and address + operatorAddress: { type: String }, + operatorPercentage: { type: Number } }) export default mongoose.model('order', Order) diff --git a/src/entities/offer.js b/src/entities/offer.js index 8e2caed..55d7428 100644 --- a/src/entities/offer.js +++ b/src/entities/offer.js @@ -1,6 +1,6 @@ /* Offer Entity - An ffer is created when a new Signal is detected via the P2WDB webhook. + An offer is created when a new Signal is detected via the P2WDB webhook. It's destroyed when the UTXO described in the Signal has been detected as spent. */ @@ -33,7 +33,9 @@ class OfferEntity { makerAddr, ticker, tokenType, - nostrEventId + nostrEventId, + operatorAddress, + operatorPercentage } = offerData.data // Input Validation @@ -80,6 +82,14 @@ class OfferEntity { throw new Error("Property 'nostrEventId' must be a string.") } + // Add the operator address and percentage to the offer. + if (!operatorAddress || typeof operatorAddress !== 'string') { + throw new Error("Property 'operatorAddress' must be a string.") + } + if (!operatorPercentage || typeof operatorPercentage !== 'number') { + throw new Error("Property 'operatorPercentage' must be a number.") + } + // Convert the timestamp to a number. let timestamp = new Date(offerData.timestamp) timestamp = timestamp.getTime() diff --git a/src/use-cases/offer/index.js b/src/use-cases/offer/index.js index c8f4ce2..9f8d250 100644 --- a/src/use-cases/offer/index.js +++ b/src/use-cases/offer/index.js @@ -111,7 +111,10 @@ class OfferUseCases { const utxoStatus = await this.retryQueue.addToQueue(this.adapters.wallet.bchWallet.utxoIsValid, utxo) // console.log('utxoStatus: ', utxoStatus) // if (utxoStatus === null) return false - if (!utxoStatus) return false + if (!utxoStatus) { + console.log(`UTXO txid: ${offerObj.data.utxoTxid}, vout: ${offerObj.data.utxoVout} has been spent. Skipping.`) + return false + } // A new offer gets a status of 'posted' offerObj.data.offerStatus = 'posted' @@ -513,9 +516,8 @@ class OfferUseCases { // } } - // 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 diligence on the Counter Offer, then signs + // This function is called by loadOffers(). + // It does due diligence on the Counter Offer, then signs // and broadcasts the transaction to accept the Counter Offer. async acceptCounterOffer (offerData) { try { @@ -528,6 +530,8 @@ class OfferUseCases { return false } + console.log(`New counter offer detected: https://astral.psfoundation.info/${this.adapters.nostr.eventId2note(offerData.data.nostrEventId)}`) + // See if this instance of bch-dex is managing the Order associated with // the incoming Counter Offer. @@ -599,6 +603,16 @@ class OfferUseCases { throw new Error(`The Counter Offer has an output of ${satsOut}, which does not match the required ${satsToReceive} in the Offer.`) } + // Ensure the Counter Offer has an output for the Operator of bch-dex. + if (!txObj.vout[3]) { + console.log('The Counter Offer does not have an output for the Operator.') + + // Add order to list of seen orders, so that we don't spent time trying to validate it again. + this.seenOffers.push(eventId) + + return 'N/A' + } + // Ensure the 3rd output (vout=2) is going to the maker address specified // in the Offer. const addrInCounterOffer = txObj.vout[2].scriptPubKey.addresses[0] @@ -608,6 +622,31 @@ class OfferUseCases { throw new Error(`The Counter Offer has an output address of ${addrInCounterOffer}, which does not match the Maker address of ${makerAddr} in the Offer.`) } + // Ensure the 4th output (vout=3) is going to the operator address specified in the Offer. + const operatorAddr = orderData.operatorAddress + const hasCorrectOperatorAddr = operatorAddr === txObj.vout[3].scriptPubKey.addresses[0] + if (!hasCorrectOperatorAddr) { + throw new Error(`The Counter Offer has an output address of ${txObj.vout[3].scriptPubKey.addresses[0]}, which does not match the Operator address of ${operatorAddr} in the Offer.`) + } + + // Ensure the 4th output (vout=3) contains the required amount of BCH. + const operatorSatsToReceive = Math.ceil(orderData.numTokens * parseInt(orderData.rateInBaseUnit)) + if (isNaN(operatorSatsToReceive)) { + throw new Error('Could not calculate the amount of BCH offered in the Counter Offer') + } + const operatorSatsOut = this.adapters.wallet.bchWallet.bchjs.BitcoinCash.toSatoshi(txObj.vout[3].value) + let estimatedOperatorFee = Math.floor(txObj.vout[3].value * orderData.operatorPercentage / 100) + if (estimatedOperatorFee < 546) estimatedOperatorFee = 546 + console.log('operatorSatsOut: ', operatorSatsOut, 'estimatedOperatorFee: ', estimatedOperatorFee) + if (operatorSatsOut < estimatedOperatorFee) { + console.log(`Skipping: The Counter Offer has an output of ${operatorSatsOut}, which is less than the estimated operator fee of ${estimatedOperatorFee}.`) + + // Add order to list of seen orders, so that we don't spent time trying to validate it again. + this.seenOffers.push(eventId) + + return 'N/A' + } + // Get the User ID from the Order model. const userId = orderData.userId @@ -718,7 +757,8 @@ class OfferUseCases { await thisOffer.remove() } - // If the Offer is older than 7 days, delete it. + // TODO: Instead of deleting the offer, send the token back to the Makers wallet. + // If the Offer is older than a threshold, delete it. const nowMs = now.getTime() const eightWeeks = 1000 * 60 * 60 * 24 * 7 * 8 const eightWeeksAgo = nowMs - eightWeeks @@ -795,7 +835,7 @@ class OfferUseCases { if (offerObj.data.dataType === 'counter-offer') { // console.log('Counter offer detected: ', offerObj) // console.log('Counter offer detected: ', offerObj.data.nostrEventId) - console.log(`Counter offer detected: https://astral.psfoundation.info/${this.adapters.nostr.eventId2note(offerObj.data.nostrEventId)}`) + // console.log(`Counter offer detected: https://astral.psfoundation.info/${this.adapters.nostr.eventId2note(offerObj.data.nostrEventId)}`) await this.acceptCounterOffer(offerObj) } diff --git a/src/use-cases/order.js b/src/use-cases/order.js index ff31bf8..a75d0b1 100644 --- a/src/use-cases/order.js +++ b/src/use-cases/order.js @@ -44,6 +44,7 @@ class OrderLib { if (!entryObj.tokenId) throw new Error('entry does not contain required properties') + // Get the user data from the database, for the user creating the new Order. const user = await this.UserModel.findById(entryObj.userId) if (!user) throw new Error('user not found') @@ -81,13 +82,7 @@ class OrderLib { // await this.retryQueue.addToQueue(this.adapters.wallet.bchWallet.optimize, {}) await userWallet.optimize() - // Ensure sufficient tokens exist to create the order. - // await 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]) - // const tokenData = await this.retryQueue.addToQueue(this.adapters.wallet.bchWallet.getTxData, [entryObj.tokenId]) const tokenData = await userWallet.getTxData([entryObj.tokenId]) // console.log(`tokenData: ${JSON.stringify(tokenData, null, 2)}`) orderEntity.ticker = tokenData[0].tokenTicker @@ -107,15 +102,20 @@ class OrderLib { // await this.adapters.wallet.bchWallet.getUtxos() // await this.retryQueue.addToQueue(this.adapters.wallet.bchWallet.initialize, {}) await userWallet.initialize() + // Update the order with the new UTXO information. orderEntity.utxoTxid = utxoInfo.txid orderEntity.utxoVout = utxoInfo.vout orderEntity.tokenType = utxoInfo.tokenType - // Add P2WDB specific flag for signaling that this is a new offer. + // Add flag to signal that this is a new offer. orderEntity.dataType = 'offer' orderEntity.userId = user._id + // Add the operator address and percentage to the order. + orderEntity.operatorAddress = this.config.operatorAddress + orderEntity.operatorPercentage = this.config.operatorPercentage + // Post the new Order information to Nostr under the topic set in the // config file. const postObj = { diff --git a/test/unit/entities/offer.entity.unit.js b/test/unit/entities/offer.entity.unit.js index 090fcb8..dea0243 100644 --- a/test/unit/entities/offer.entity.unit.js +++ b/test/unit/entities/offer.entity.unit.js @@ -286,7 +286,9 @@ describe('#Offer-Entity', () => { makerAddr: 'bitcoincash:qzl0d3gcqeypv4cy7gh8rgdszxa9vvm2acv7fqtd00', ticker: 'TROUT', tokenType: 1, - nostrEventId: 'test' + nostrEventId: 'test', + operatorAddress: 'bitcoincash:qzl0d3gcqeypv4cy7gh8rgdszxa9vvm2acv7fqtd00', + operatorPercentage: 10 }, timestamp: '2021-09-20T17:54:26.395Z', localTimeStamp: '9/20/2021, 10:54:26 AM', diff --git a/test/unit/mocks/use-cases/offer-mock-data.js b/test/unit/mocks/use-cases/offer-mock-data.js index c2cc82f..071e82e 100644 --- a/test/unit/mocks/use-cases/offer-mock-data.js +++ b/test/unit/mocks/use-cases/offer-mock-data.js @@ -142,10 +142,37 @@ const offerMockData = { utxoVout: 0, makerAddr: 'address', tokenType: 1, - nostrEventId: 'test' + nostrEventId: 'test', + operatorAddress: 'bitcoincash:qzy97glp47ut7tstm5g0tlrmkhk742795gkmyc7478', + operatorPercentage: 10 } } +const deserealizeTxMockNoOperatorOut = { + //... + vout: [ + { + value: 0, + scriptPubKey: { + addresses: ['bitcoincash:qzy97glp47ut7tstm5g0tlrmkhk742795gkmyc7478'] + } + }, + { + value: 0, + scriptPubKey: { + addresses: ['bitcoincash:qzy97glp47ut7tstm5g0tlrmkhk742795gkmyc7478'] + } + }, + { + value: 0, + scriptPubKey: { + addresses: ['bitcoincash:qzy97glp47ut7tstm5g0tlrmkhk742795gkmyc7478'] + } + }, + //... + ] +} + const deserealizeTxMock = { //... vout: [ @@ -167,6 +194,12 @@ const deserealizeTxMock = { addresses: ['bitcoincash:qzy97glp47ut7tstm5g0tlrmkhk742795gkmyc7478'] } }, + { + value: 0, + scriptPubKey: { + addresses: ['bitcoincash:qzy97glp47ut7tstm5g0tlrmkhk742795gkmyc7478'] + } + }, //... ] } @@ -179,5 +212,6 @@ export default { fungibleOffer01, fungibleTokenData01, offerMockData, + deserealizeTxMockNoOperatorOut, deserealizeTxMock }; diff --git a/test/unit/use-cases/offer.use-case.unit.js b/test/unit/use-cases/offer.use-case.unit.js index 05fed70..a047efc 100644 --- a/test/unit/use-cases/offer.use-case.unit.js +++ b/test/unit/use-cases/offer.use-case.unit.js @@ -746,6 +746,24 @@ 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 + mock.numTokens = 0 + + // Mock dependencies + sandbox.stub(uut.orderUseCase, 'findOrderByEvent').resolves(mock) + sandbox.stub(uut.orderUseCase, 'findOrderByUtxo').resolves(mock) + sandbox.stub(uut.adapters.wallet.bchWallet.bchjs.BitcoinCash, 'toSatoshi').returns(0) + + sandbox.stub(uut.adapters.wallet, 'deseralizeTx').resolves(mockData.deserealizeTxMockNoOperatorOut) + + const result = await uut.acceptCounterOffer({ data: { /** .... */ } }) + assert.equal(result, 'N/A') + }) + it('should handle error for wrong transaction output address', async () => { try { // Mock data diff --git a/test/unit/use-cases/order.use-case.unit.js b/test/unit/use-cases/order.use-case.unit.js index c147bca..0fe3bab 100644 --- a/test/unit/use-cases/order.use-case.unit.js +++ b/test/unit/use-cases/order.use-case.unit.js @@ -185,6 +185,7 @@ describe('#order-use-case', () => { assert.property(result, 'eventId') assert.property(result, 'noteId') }) + it('should create an order with consumer-api', async () => { uut.config.useFullStackCash = false const entryObj = { @@ -214,6 +215,7 @@ describe('#order-use-case', () => { assert.property(result, 'eventId') assert.property(result, 'noteId') }) + it('should throw error if user is not found', async () => { try { const entryObj = {