feat(offer): Trigger createOffer() from Nostr topic

This commit is contained in:
Daniel Gonzalez
2024-12-02 22:19:54 -04:00
parent 5eeafd7ead
commit 0a8bb52241
7 changed files with 330 additions and 122 deletions
+2 -1
View File
@@ -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.
})
+22 -3
View File
@@ -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
}
}
}
+70 -21
View File
@@ -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 the POST /offer REST API controller, which is
// triggered by a P2WDB webhook. ( DEPRECATED)>
// 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
+65 -22
View File
@@ -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)
})
})
})
+12
View File
@@ -41,12 +41,24 @@ class Offer {
async createOffer() {
return {}
}
async removeStaleOffers(){
}
async removeDuplicateOffers(){
}
async loadOffers(){
}
}
class Order {
async createOrder() {
return {}
}
async removeStaleOrders(){
}
}
class UseCasesMock {
+20 -1
View File
@@ -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
};
+139 -74
View File
@@ -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)
})
})
})