Merge pull request #105 from Permissionless-Software-Foundation/operator-fee

Adding required Operator fee to SWaP TX
This commit is contained in:
Chris Troutner
2025-08-10 11:06:55 -07:00
committed by GitHub
10 changed files with 137 additions and 20 deletions
+5 -1
View File
@@ -191,5 +191,9 @@ export default {
disableNewAccounts: process.env.DISABLE_NEW_ACCOUNTS ? true : false, disableNewAccounts: process.env.DISABLE_NEW_ACCOUNTS ? true : false,
// Admin password // 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
} }
+4 -1
View File
@@ -35,8 +35,11 @@ const Offer = new mongoose.Schema({
lokadId: { type: String }, lokadId: { type: String },
messageType: { type: Number }, messageType: { type: Number },
messageClass: { 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) export default mongoose.model('offer', Offer)
+5 -1
View File
@@ -43,7 +43,11 @@ const Order = new mongoose.Schema({
// Additional properties found in createOrder // Additional properties found in createOrder
dataType: { type: String, required: true }, 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) export default mongoose.model('order', Order)
+12 -2
View File
@@ -1,6 +1,6 @@
/* /*
Offer Entity 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. It's destroyed when the UTXO described in the Signal has been detected as spent.
*/ */
@@ -33,7 +33,9 @@ class OfferEntity {
makerAddr, makerAddr,
ticker, ticker,
tokenType, tokenType,
nostrEventId nostrEventId,
operatorAddress,
operatorPercentage
} = offerData.data } = offerData.data
// Input Validation // Input Validation
@@ -80,6 +82,14 @@ class OfferEntity {
throw new Error("Property 'nostrEventId' must be a string.") 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. // Convert the timestamp to a number.
let timestamp = new Date(offerData.timestamp) let timestamp = new Date(offerData.timestamp)
timestamp = timestamp.getTime() timestamp = timestamp.getTime()
+46 -6
View File
@@ -111,7 +111,10 @@ class OfferUseCases {
const utxoStatus = await this.retryQueue.addToQueue(this.adapters.wallet.bchWallet.utxoIsValid, utxo) const utxoStatus = await this.retryQueue.addToQueue(this.adapters.wallet.bchWallet.utxoIsValid, utxo)
// console.log('utxoStatus: ', utxoStatus) // console.log('utxoStatus: ', utxoStatus)
// if (utxoStatus === null) return false // 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' // A new offer gets a status of 'posted'
offerObj.data.offerStatus = 'posted' offerObj.data.offerStatus = 'posted'
@@ -513,9 +516,8 @@ class OfferUseCases {
// } // }
} }
// This function is called by the P2WDB webhook REST API handler. When a // This function is called by loadOffers().
// Counter Offer is passed to bch-dex by the P2WDB, the data is then passed // It does due diligence on the Counter Offer, then signs
// to this function. It does due diligence on the Counter Offer, then signs
// and broadcasts the transaction to accept the Counter Offer. // and broadcasts the transaction to accept the Counter Offer.
async acceptCounterOffer (offerData) { async acceptCounterOffer (offerData) {
try { try {
@@ -528,6 +530,8 @@ class OfferUseCases {
return false 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 // See if this instance of bch-dex is managing the Order associated with
// the incoming Counter Offer. // 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.`) 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 // Ensure the 3rd output (vout=2) is going to the maker address specified
// in the Offer. // in the Offer.
const addrInCounterOffer = txObj.vout[2].scriptPubKey.addresses[0] 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.`) 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. // Get the User ID from the Order model.
const userId = orderData.userId const userId = orderData.userId
@@ -718,7 +757,8 @@ class OfferUseCases {
await thisOffer.remove() 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 nowMs = now.getTime()
const eightWeeks = 1000 * 60 * 60 * 24 * 7 * 8 const eightWeeks = 1000 * 60 * 60 * 24 * 7 * 8
const eightWeeksAgo = nowMs - eightWeeks const eightWeeksAgo = nowMs - eightWeeks
@@ -795,7 +835,7 @@ class OfferUseCases {
if (offerObj.data.dataType === 'counter-offer') { if (offerObj.data.dataType === 'counter-offer') {
// console.log('Counter offer detected: ', offerObj) // console.log('Counter offer detected: ', offerObj)
// console.log('Counter offer detected: ', offerObj.data.nostrEventId) // console.log('Counter offer detected: ', 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) await this.acceptCounterOffer(offerObj)
} }
+7 -7
View File
@@ -44,6 +44,7 @@ class OrderLib {
if (!entryObj.tokenId) throw new Error('entry does not contain required properties') 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) const user = await this.UserModel.findById(entryObj.userId)
if (!user) throw new Error('user not found') if (!user) throw new Error('user not found')
@@ -81,13 +82,7 @@ class OrderLib {
// await this.retryQueue.addToQueue(this.adapters.wallet.bchWallet.optimize, {}) // await this.retryQueue.addToQueue(this.adapters.wallet.bchWallet.optimize, {})
await userWallet.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. // 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]) const tokenData = await userWallet.getTxData([entryObj.tokenId])
// console.log(`tokenData: ${JSON.stringify(tokenData, null, 2)}`) // console.log(`tokenData: ${JSON.stringify(tokenData, null, 2)}`)
orderEntity.ticker = tokenData[0].tokenTicker orderEntity.ticker = tokenData[0].tokenTicker
@@ -107,15 +102,20 @@ class OrderLib {
// await this.adapters.wallet.bchWallet.getUtxos() // await this.adapters.wallet.bchWallet.getUtxos()
// await this.retryQueue.addToQueue(this.adapters.wallet.bchWallet.initialize, {}) // await this.retryQueue.addToQueue(this.adapters.wallet.bchWallet.initialize, {})
await userWallet.initialize() await userWallet.initialize()
// Update the order with the new UTXO information. // Update the order with the new UTXO information.
orderEntity.utxoTxid = utxoInfo.txid orderEntity.utxoTxid = utxoInfo.txid
orderEntity.utxoVout = utxoInfo.vout orderEntity.utxoVout = utxoInfo.vout
orderEntity.tokenType = utxoInfo.tokenType 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.dataType = 'offer'
orderEntity.userId = user._id 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 // Post the new Order information to Nostr under the topic set in the
// config file. // config file.
const postObj = { const postObj = {
+3 -1
View File
@@ -286,7 +286,9 @@ describe('#Offer-Entity', () => {
makerAddr: 'bitcoincash:qzl0d3gcqeypv4cy7gh8rgdszxa9vvm2acv7fqtd00', makerAddr: 'bitcoincash:qzl0d3gcqeypv4cy7gh8rgdszxa9vvm2acv7fqtd00',
ticker: 'TROUT', ticker: 'TROUT',
tokenType: 1, tokenType: 1,
nostrEventId: 'test' nostrEventId: 'test',
operatorAddress: 'bitcoincash:qzl0d3gcqeypv4cy7gh8rgdszxa9vvm2acv7fqtd00',
operatorPercentage: 10
}, },
timestamp: '2021-09-20T17:54:26.395Z', timestamp: '2021-09-20T17:54:26.395Z',
localTimeStamp: '9/20/2021, 10:54:26 AM', localTimeStamp: '9/20/2021, 10:54:26 AM',
+35 -1
View File
@@ -142,10 +142,37 @@ const offerMockData = {
utxoVout: 0, utxoVout: 0,
makerAddr: 'address', makerAddr: 'address',
tokenType: 1, 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 = { const deserealizeTxMock = {
//... //...
vout: [ vout: [
@@ -167,6 +194,12 @@ const deserealizeTxMock = {
addresses: ['bitcoincash:qzy97glp47ut7tstm5g0tlrmkhk742795gkmyc7478'] addresses: ['bitcoincash:qzy97glp47ut7tstm5g0tlrmkhk742795gkmyc7478']
} }
}, },
{
value: 0,
scriptPubKey: {
addresses: ['bitcoincash:qzy97glp47ut7tstm5g0tlrmkhk742795gkmyc7478']
}
},
//... //...
] ]
} }
@@ -179,5 +212,6 @@ export default {
fungibleOffer01, fungibleOffer01,
fungibleTokenData01, fungibleTokenData01,
offerMockData, offerMockData,
deserealizeTxMockNoOperatorOut,
deserealizeTxMock deserealizeTxMock
}; };
@@ -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 () => { it('should handle error for wrong transaction output address', async () => {
try { try {
// Mock data // Mock data
@@ -185,6 +185,7 @@ describe('#order-use-case', () => {
assert.property(result, 'eventId') assert.property(result, 'eventId')
assert.property(result, 'noteId') assert.property(result, 'noteId')
}) })
it('should create an order with consumer-api', async () => { it('should create an order with consumer-api', async () => {
uut.config.useFullStackCash = false uut.config.useFullStackCash = false
const entryObj = { const entryObj = {
@@ -214,6 +215,7 @@ describe('#order-use-case', () => {
assert.property(result, 'eventId') assert.property(result, 'eventId')
assert.property(result, 'noteId') assert.property(result, 'noteId')
}) })
it('should throw error if user is not found', async () => { it('should throw error if user is not found', async () => {
try { try {
const entryObj = { const entryObj = {