mirror of
https://github.com/Permissionless-Software-Foundation/bch-dex.git
synced 2026-09-21 16:52:00 -07:00
829 lines
30 KiB
JavaScript
829 lines
30 KiB
JavaScript
/*
|
|
Use Case library for Offers.
|
|
|
|
Offers are created by a webhook trigger from the P2WDB. Offers are a result of
|
|
new data in P2WDB. They differ from Offers, which are generated by a local
|
|
user.
|
|
|
|
An Offer is created to match a local Offer, but it's created indirectly, as
|
|
a response to the webhook from the P2WDB. In this way, Offers generated from
|
|
local Offers are no different than Offers generated by other peers.
|
|
|
|
A Counter Offer is created by calling the /take/:cid endpoint. It creates
|
|
a partially signed transaction.
|
|
*/
|
|
|
|
// Global npm libraries
|
|
import axios from 'axios'
|
|
import RetryQueue from '@chris.troutner/retry-queue'
|
|
|
|
// Local libraries
|
|
import OfferEntity from '../../entities/offer.js'
|
|
import config from '../../../config/index.js'
|
|
|
|
const DEFAULT_ENTRIES_PER_PAGE = 20
|
|
const NFT_ENTRIES_PER_PAGE = 6
|
|
const FUNGIBLE_ENTRIES_PER_PAGE = 20
|
|
|
|
class OfferUseCases {
|
|
constructor (localConfig = {}) {
|
|
// console.log('User localConfig: ', localConfig)
|
|
this.adapters = localConfig.adapters
|
|
if (!this.adapters) {
|
|
throw new Error(
|
|
'Instance of adapters must be passed in when instantiating Offer Use Cases library.'
|
|
)
|
|
}
|
|
this.orderUseCase = localConfig.order
|
|
if (!this.orderUseCase) {
|
|
throw new Error(
|
|
'Instance of Order Use Cases must be passed in when instantiating Offer Use Cases library.'
|
|
)
|
|
}
|
|
|
|
// Encapsulate dependencies
|
|
this.config = config
|
|
this.axios = axios
|
|
this.offerEntity = new OfferEntity()
|
|
this.OfferModel = this.adapters.localdb.Offer
|
|
this.retryQueue = new RetryQueue({ retryPeriod: 1000, attempts: 3 })
|
|
|
|
// Bind 'this' object to functions
|
|
this.createOffer = this.createOffer.bind(this)
|
|
this.detectNsfw = this.detectNsfw.bind(this)
|
|
this.categorizeToken = this.categorizeToken.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.ensureFunds = this.ensureFunds.bind(this)
|
|
this.findOfferByEvent = this.findOfferByEvent.bind(this)
|
|
this.findOfferByTxid = this.findOfferByTxid.bind(this)
|
|
this.acceptCounterOffer = this.acceptCounterOffer.bind(this)
|
|
this.removeDuplicateOffers = this.removeDuplicateOffers.bind(this)
|
|
this.removeStaleOffers = this.removeStaleOffers.bind(this)
|
|
this.flagOffer = this.flagOffer.bind(this)
|
|
this.loadOffers = this.loadOffers.bind(this)
|
|
this.listOffersByAddress = this.listOffersByAddress.bind(this)
|
|
|
|
// State
|
|
this.seenOffers = []
|
|
this.seenCounterOffers = []
|
|
}
|
|
|
|
// 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)
|
|
|
|
// Return if Offer already exists in database with the same utxo transaction id.
|
|
try {
|
|
await this.findOfferByTxid(offerObj.data.utxoTxid)
|
|
|
|
// console.log('Offer already found in local database.')
|
|
return false
|
|
} catch (err) { /* exit quietly */ }
|
|
|
|
// console.log('this.adapters.bchjs: ', this.adapters.bchjs)
|
|
|
|
// 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()')
|
|
// }
|
|
|
|
// Quickly skip over offers that have already been processed.
|
|
const eventId = offerObj.data.nostrEventId
|
|
if (this.seenOffers.includes(eventId)) {
|
|
// console.log(`Offer with event ID ${eventId} already processed. Skipping.`)
|
|
return false
|
|
}
|
|
this.seenOffers.push(eventId)
|
|
|
|
// Verify that UTXO in offer is unspent. If it is spent, then ignore the
|
|
// offer.
|
|
const utxo = {
|
|
tx_hash: offerObj.data.utxoTxid,
|
|
tx_pos: offerObj.data.utxoVout
|
|
}
|
|
// const utxoStatus = await this.adapters.wallet.bchWallet.utxoIsValid(utxo)
|
|
const utxoStatus = await this.retryQueue.addToQueue(this.adapters.wallet.bchWallet.utxoIsValid, utxo)
|
|
// console.log('utxoStatus: ', utxoStatus)
|
|
// if (utxoStatus === null) 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'
|
|
|
|
// Set timestamp
|
|
offerObj.timestamp = new Date().getTime()
|
|
|
|
const offerEntity = this.offerEntity.validate(offerObj)
|
|
// console.log('offerEntity: ', offerEntity)
|
|
|
|
console.log(`New Offer for token ID ${offerEntity.tokenId} detected from Nostr post ${eventId}`)
|
|
|
|
// Get data about the token.
|
|
const tokenId = offerEntity.tokenId
|
|
// const tokenData = await this.adapters.wallet.bchWallet.getTokenData(tokenId)
|
|
const tokenData = await this.retryQueue.addToQueue(this.adapters.wallet.bchWallet.getTokenData, tokenId)
|
|
// console.log(`tokenData: ${JSON.stringify(tokenData, null, 2)}`)
|
|
|
|
// Generate a 'display category' for the token. This will allow the
|
|
// front end UI to figure out how to display the token.
|
|
const displayCategory = this.categorizeToken(offerEntity, tokenData)
|
|
console.log('displayCategory: ', displayCategory)
|
|
offerEntity.displayCategory = displayCategory
|
|
|
|
// Detect if user set the NSFW flag.
|
|
// const nsfw = false
|
|
// nsfw = await this.retryQueue.addToQueue(this.detectNsfw, tokenData)
|
|
// offerEntity.nsfw = nsfw
|
|
|
|
// Add offer to the local database.
|
|
const offerModel = new this.OfferModel(offerEntity)
|
|
await offerModel.save()
|
|
|
|
return true
|
|
} catch (err) {
|
|
console.error('Error in createOffer()', err.message)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
// By default this function returns false, to indicate the NFT is safe for work.
|
|
// If the user who created the token sets the nsfw property in the mutable data,
|
|
// this function will return true.
|
|
async detectNsfw (tokenData) {
|
|
try {
|
|
let nsfw = false
|
|
|
|
const mutableCid = tokenData.mutableData
|
|
|
|
// If there is no mutable IPFS CID, then skip this token.
|
|
if (!mutableCid.includes('ipfs://')) return nsfw
|
|
|
|
// Revove the ipfs:// prefix.
|
|
const cid = mutableCid.substring(7)
|
|
|
|
// Retrieve the mutable data from Filecoin/IPFS.
|
|
// const url = `https://${cid}.ipfs.w3s.link/data.json`
|
|
let mutableData = {}
|
|
|
|
try {
|
|
// Try the conventional data URL.
|
|
const url = `${this.config.ipfsGateway}${cid}/data.json`
|
|
const result = await this.axios.get(url)
|
|
mutableData = result.data
|
|
// console.log(`mutableData: ${JSON.stringify(mutableData, null, 2)}`)
|
|
} catch (err) {
|
|
// Try the newer data URL.
|
|
const url = `${this.config.ipfsGateway}${cid}`
|
|
const result = await this.axios.get(url)
|
|
mutableData = result.data
|
|
// console.log(`mutableData: ${JSON.stringify(mutableData, null, 2)}`)
|
|
}
|
|
console.log('mutableData: ', mutableData)
|
|
|
|
// Logical tests
|
|
const hasNsfw = !!mutableData.nsfw
|
|
const nsfwSetTrue = mutableData.nsfw === true
|
|
const nsfwStringTrue = mutableData.nsfw === 'true'
|
|
const nsfwDetected = hasNsfw && (nsfwSetTrue || nsfwStringTrue)
|
|
// console.log(`hasNsfw: ${hasNsfw}, nsfwSetTrue: ${nsfwSetTrue}, nsfwStringTrue: ${nsfwStringTrue}, nsfwDetected: ${nsfwDetected}`)
|
|
|
|
if (nsfwDetected) {
|
|
console.log('NSFW flag set as true')
|
|
nsfw = true
|
|
}
|
|
|
|
return nsfw
|
|
} catch (err) {
|
|
console.error('Error in detectNsfw(): ', err)
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Categorize the token for display purposes. This will categorize a token
|
|
// into one of these categories:
|
|
// - nft
|
|
// - group
|
|
// - fungible
|
|
// - simple-nft
|
|
//
|
|
// The first three are easy to categorize. The simple-nft is a fungible token
|
|
// with a quantity of 1, decimals of 0, and no minting baton. Categorizing this
|
|
// type of token is the main reason why this function exists.
|
|
categorizeToken (offerData, tokenData) {
|
|
try {
|
|
// console.log(`categorizeToken(): ${JSON.stringify(offerData, null, 2)}`)
|
|
|
|
// const tokenId = offerData.tokenId
|
|
//
|
|
// const tokenData = await this.adapters.wallet.bchWallet.getTokenData(tokenId)
|
|
// console.log(`tokenData: ${JSON.stringify(tokenData, null, 2)}`)
|
|
|
|
if (tokenData.genesisData.type === 65) {
|
|
return 'nft'
|
|
}
|
|
|
|
// Create a set of checks to detect a simple NFT
|
|
const isType1 = tokenData.genesisData.type === 1
|
|
const hasNoMintingBaton = !tokenData.genesisData.mintBatonIsActive
|
|
const hasNoDecimals = !tokenData.genesisData.decimals
|
|
const hasQtyOfOne = parseInt(tokenData.genesisData.tokensInCirculationStr) === 1
|
|
|
|
if (isType1 && hasNoMintingBaton && hasNoDecimals && hasQtyOfOne) {
|
|
return 'simple-nft'
|
|
}
|
|
|
|
if (isType1) return 'fungible'
|
|
|
|
throw new Error(`Unknown token type: ${tokenData.genesisData.type}`)
|
|
} catch (err) {
|
|
console.error('Error in use-cases/offer/index.js categorizeToken(): ', err)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
async listOffers (page = 0) {
|
|
try {
|
|
const data = await this.OfferModel.find({})
|
|
// Sort entries so newest entries show first.
|
|
.sort('-timestamp')
|
|
// Skip to the start of the selected page.
|
|
.skip(page * DEFAULT_ENTRIES_PER_PAGE)
|
|
// Only return 20 results.
|
|
.limit(DEFAULT_ENTRIES_PER_PAGE)
|
|
|
|
return data
|
|
} catch (error) {
|
|
console.error('Error in use-cases/offer/listOffers()')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async listNftOffers (page = 0, nsfw = false) {
|
|
try {
|
|
const data = await this.OfferModel.find({
|
|
displayCategory: { $ne: 'fungible' },
|
|
nsfw
|
|
})
|
|
// Sort entries so newest entries show first.
|
|
.sort('-timestamp')
|
|
// Skip to the start of the selected page.
|
|
.skip(page * NFT_ENTRIES_PER_PAGE)
|
|
// Only return 20 results.
|
|
.limit(NFT_ENTRIES_PER_PAGE)
|
|
|
|
// console.log('listNftOffers() returning this data: ', data)
|
|
|
|
return data
|
|
} catch (error) {
|
|
console.error('Error in use-cases/offer/listNftOffers()')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async listFungibleOffers (page = 0) {
|
|
try {
|
|
const data = await this.OfferModel.find({ displayCategory: 'fungible' })
|
|
// Sort entries so newest entries show first.
|
|
.sort('-timestamp')
|
|
// Skip to the start of the selected page.
|
|
.skip(page * FUNGIBLE_ENTRIES_PER_PAGE)
|
|
// Only return 20 results.
|
|
.limit(FUNGIBLE_ENTRIES_PER_PAGE)
|
|
|
|
// console.log('listFungibleOffers() returning this data: ', data)
|
|
|
|
return data
|
|
} catch (error) {
|
|
console.error('Error in use-cases/offer/listFungibleOffers()')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// Generate phase 2 of 3 - take the other side of an Offer.
|
|
// Based on this example:
|
|
// https://github.com/Permission=less-Software-Foundation/bch-js-examples/blob/master/bch/applications/collaborate/sell-slp/e2e-exchange/step2-purchase-tx.js
|
|
//
|
|
// Dev Note: Right now this use cases takes all the tokens offered. It does not
|
|
// 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 (eventId) {
|
|
try {
|
|
if (!eventId || typeof eventId !== 'string') throw new Error('eventId must be a string')
|
|
|
|
// Get the Offer information
|
|
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'
|
|
if (!offerInfo.offerStatus || offerInfo.offerStatus !== 'posted') {
|
|
throw new Error('offer status is not "posted", so offer is dead and can not be countered.')
|
|
}
|
|
|
|
// Verify that UTXO for sale is unspent. Abort if it's been spent.
|
|
const utxo = {
|
|
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) {
|
|
console.log(`utxo txid: ${offerInfo.utxoTxid}, vout: ${offerInfo.utxoVout}`)
|
|
|
|
// TODO: Mark this Offer as 'dead'
|
|
|
|
throw new Error('UTXO does not exist. Aborting.')
|
|
}
|
|
|
|
// Ensure the app has enough funds to complete the trade.
|
|
await this.ensureFunds(offerInfo)
|
|
|
|
// Get UTXOs.
|
|
// const utxos = this.adapters.wallet.bchWallet.utxos.utxoStore
|
|
// console.log(`utxos: ${JSON.stringify(utxos, null, 2)}`)
|
|
|
|
// 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')
|
|
}
|
|
|
|
// Add sats to cover mining fees and dust for token UTXO
|
|
satsToMove += 1000
|
|
|
|
// Move funds to create a segrated UTXO for taking the offer
|
|
const utxoInfo = await this.adapters.wallet.moveBch(satsToMove)
|
|
utxoInfo.sats = satsToMove
|
|
console.log('utxoInfo: ', utxoInfo)
|
|
|
|
// Create a partially signed transaction.
|
|
// https://github.com/Permissionless-Software-Foundation/bch-js-examples/blob/master/bch/applications/collaborate/sell-slp/e2e-exchange/step2-purchase-tx.js#L59
|
|
const partialTxHex = await this.adapters.wallet.generatePartialTx(offerInfo, utxoInfo)
|
|
console.log('partialTxHex: ', partialTxHex)
|
|
// return partialTxHex
|
|
|
|
// Debug: Decode the transaction for manual QA
|
|
const txObj = await this.adapters.wallet.deseralizeTx(partialTxHex)
|
|
console.log(`partially-signed transaction: ${JSON.stringify(txObj, null, 2)}`)
|
|
|
|
// Create valid Offer object
|
|
const takenOfferInfo = Object.assign({}, offerInfo)
|
|
takenOfferInfo.partialTxHex = partialTxHex
|
|
delete takenOfferInfo.nostrEventId
|
|
delete takenOfferInfo._id
|
|
takenOfferInfo.offerHash = offerInfo.nostrEventId
|
|
|
|
// Add P2WDB specific flag for signaling that this is a new offer.
|
|
takenOfferInfo.dataType = 'counter-offer'
|
|
|
|
// 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 nostrData = {
|
|
// wif: this.adapters.wallet.bchWallet.walletInfo.privateKey,
|
|
data: takenOfferInfo
|
|
// appId: this.config.p2wdbAppId
|
|
}
|
|
const resultEventId = await this.adapters.nostr.post(JSON.stringify(nostrData))
|
|
|
|
const noteId = this.adapters.nostr.eventId2note(resultEventId)
|
|
|
|
// 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 { eventId: resultEventId, noteId }
|
|
|
|
// return 'fake-hash'
|
|
} catch (err) {
|
|
console.error('Error in use-cases/offer/takeOffer(): ', err)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
// Ensure that the wallet has enough BCH and tokens to complete the requested
|
|
// trade. Will return true if it does. Will throw an error if it doesn't.
|
|
async ensureFunds (offerEntity) {
|
|
try {
|
|
// console.log('this.adapters.wallet: ', this.adapters.wallet.bchWallet)
|
|
// console.log(`walletInfo: ${JSON.stringify(this.adapters.wallet.bchWallet.walletInfo, null, 2)}`)
|
|
|
|
await this.adapters.wallet.bchWallet.walletInfoPromise
|
|
// console.log(`utxos: ${JSON.stringify(this.adapters.wallet.bchWallet.utxos.utxoStore, null, 2)}`)
|
|
|
|
// Refresh the wallet UTXOs
|
|
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.')
|
|
|
|
if (offerEntity.buyOrSell.includes('sell')) {
|
|
// Sell Offer
|
|
|
|
// Calculate the sats needed
|
|
const satsNeeded = Math.ceil(offerEntity.numTokens * parseInt(offerEntity.rateInBaseUnit))
|
|
if (isNaN(satsNeeded)) {
|
|
throw new Error('Could not calculate sats needed!')
|
|
}
|
|
|
|
// Ensure the app wallet controlls enough BCH to pay for the tokens.
|
|
const balance = await this.adapters.wallet.bchWallet.getBalance()
|
|
console.log(`wallet balance: ${balance}, sats needed: ${satsNeeded}`)
|
|
const SATS_MARGIN = 5000
|
|
if (satsNeeded + SATS_MARGIN > balance) {
|
|
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.')
|
|
}
|
|
|
|
return true
|
|
} catch (err) {
|
|
console.error('Error in offer/index.js/ensureFunds()')
|
|
|
|
// Debugging
|
|
try {
|
|
console.error(`Error with this address: ${this.adapters.wallet.bchWallet.walletInfo.cashAddress}`)
|
|
} catch (err) { /* exit quietly */ }
|
|
|
|
throw err
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
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 })
|
|
|
|
// 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(): ', error.message)
|
|
// 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 diligence on the Counter Offer, then signs
|
|
// and broadcasts the transaction to accept the Counter Offer.
|
|
async acceptCounterOffer (offerData) {
|
|
try {
|
|
// console.log(`acceptCounterOffer() offerData: ${JSON.stringify(offerData, null, 2)}`)
|
|
|
|
// Quickly skip over offers that have already been processed.
|
|
const eventId = offerData.data.nostrEventId
|
|
if (this.seenOffers.includes(eventId)) {
|
|
// console.log(`Offer with event ID ${eventId} already processed. Skipping.`)
|
|
return false
|
|
}
|
|
|
|
// See if this instance of bch-dex is managing the Order associated with
|
|
// the incoming Counter Offer.
|
|
|
|
// Note : this should be handled by nostrEvent id or UtxoId?
|
|
// const orderHash = offerData.data.nostrEventId
|
|
let orderData = {}
|
|
try {
|
|
orderData = await this.orderUseCase.findOrderByUtxo(offerData)
|
|
console.log(`orderData: ${JSON.stringify(orderData, null, 2)}`)
|
|
} catch (err) {
|
|
console.log('Order matching this Counter Offer is not managed by this instance of bch-dex. Skipping.\n')
|
|
|
|
// 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'
|
|
}
|
|
|
|
// Verify that the UTXO for sale is valid.
|
|
let utxoStatus = null
|
|
try {
|
|
// Get the status of the UTXO associate with this Offer.
|
|
const utxo = {
|
|
tx_hash: offerData.data.utxoTxid,
|
|
tx_pos: offerData.data.utxoVout
|
|
}
|
|
// console.log(`Checking this UTXO: ${JSON.stringify(utxo, null, 2)}`)
|
|
|
|
// utxoStatus = await this.adapters.wallet.bchWallet.utxoIsValid(utxo)
|
|
utxoStatus = await this.retryQueue.addToQueue(this.adapters.wallet.bchWallet.utxoIsValid, utxo)
|
|
console.log('utxoStatus: ', utxoStatus)
|
|
} catch (err) {
|
|
console.log('acceptCounterOffer() err validating UTXO: ', err)
|
|
|
|
// Handle corner case of bad-data in the Offer model.
|
|
if (err.isAxiosError) {
|
|
console.log('Error trying to contact wallet service: ', err)
|
|
return 'N/A'
|
|
} else {
|
|
return 'N/A'
|
|
}
|
|
}
|
|
|
|
// If the Offer UTXO is spent, exit.
|
|
if (utxoStatus === false) {
|
|
// Add order to list of seen orders, so that we don't spent time trying to validate it again.
|
|
this.seenOffers.push(eventId)
|
|
|
|
console.log(`Aborting Counter Offer from Event ID ${eventId}`)
|
|
console.log(`https://astral.psfoundation.info/${this.adapters.nostr.eventId2note(eventId)}`)
|
|
|
|
return 'N/A'
|
|
}
|
|
|
|
// Deserialize the partially signed transaction.
|
|
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')
|
|
}
|
|
const satsOut = this.adapters.wallet.bchWallet.bchjs.BitcoinCash.toSatoshi(txObj.vout[2].value)
|
|
const hasRequiredAmount = satsOut === satsToReceive
|
|
if (!hasRequiredAmount) {
|
|
throw new Error(`The Counter Offer has an output of ${satsOut}, which does not match the required ${satsToReceive} in the Offer.`)
|
|
}
|
|
|
|
// Ensure the 3rd output (vout=2) is going to the maker address specified
|
|
// in the Offer.
|
|
const addrInCounterOffer = txObj.vout[2].scriptPubKey.addresses[0]
|
|
const makerAddr = orderData.makerAddr
|
|
const hasCorrectAddr = makerAddr === addrInCounterOffer
|
|
if (!hasCorrectAddr) {
|
|
throw new Error(`The Counter Offer has an output address of ${addrInCounterOffer}, which does not match the Maker address of ${makerAddr} in the Offer.`)
|
|
}
|
|
|
|
// Get the User ID from the Order model.
|
|
const userId = orderData.userId
|
|
|
|
// Get the User model from the database.
|
|
this.UserModel = this.adapters.localdb.Users
|
|
const user = await this.UserModel.findById(userId)
|
|
console.log('user: ', user)
|
|
|
|
// Get the HD index from the Order model.
|
|
const hdIndex = orderData.hdIndex
|
|
|
|
// Sign and broadcast the transaction.
|
|
const txid = await this.adapters.wallet.completeTx(txHex, hdIndex, user.mnemonic)
|
|
console.log('txid: ', txid)
|
|
|
|
return txid
|
|
} catch (err) {
|
|
console.error('Error in acceptCounterOffer(): ', err)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
// TODO: Write unit tests for this function, then add it to the Timer Controllers
|
|
// Looks for duplicate offers and removes the duplicate.
|
|
// Duplicates are checked for when an offer is created, and so should not
|
|
// exist. But emperical testing shows that they do. This function is called
|
|
// periodically by a timer, to clean up any duplicates that slipped through
|
|
// the cracks.
|
|
async removeDuplicateOffers () {
|
|
try {
|
|
const now = new Date()
|
|
console.log(`Starting removeDuplicateOffers() at ${now.toLocaleString()}`)
|
|
|
|
let duplicateFound = false
|
|
|
|
// Get all Offers in the database.
|
|
const offers = await this.OfferModel.find({})
|
|
// console.log('offers: ', offers)
|
|
|
|
const offerZcids = []
|
|
|
|
for (let i = 0; i < offers.length; i++) {
|
|
const thisOffer = offers[i]
|
|
|
|
if (offerZcids.includes(thisOffer.p2wdbHash)) {
|
|
await thisOffer.remove()
|
|
duplicateFound = true
|
|
continue
|
|
}
|
|
|
|
// Add the zcid to the array.
|
|
offerZcids.push(thisOffer.p2wdbHash)
|
|
}
|
|
|
|
return duplicateFound
|
|
} catch (err) {
|
|
console.error('Error in removeDuplicateOffers()')
|
|
throw err
|
|
}
|
|
}
|
|
|
|
// This function is called by the garbage collection timer controller. It
|
|
// checks the UTXO associated with each Offer in the database. If the UTXO
|
|
// has been spent, the Offer is deleted from the database.
|
|
async removeStaleOffers () {
|
|
try {
|
|
const now = new Date()
|
|
console.log(`Starting garbage collection for Offers at ${now.toLocaleString()}`)
|
|
|
|
// Get all Offers in the database.
|
|
const offers = await this.OfferModel.find({})
|
|
// console.log('offers: ', offers)
|
|
|
|
// Loop through each Offer and ensure the UTXO is still valid.
|
|
for (let i = 0; i < offers.length; i++) {
|
|
const thisOffer = offers[i]
|
|
|
|
let utxoStatus = null
|
|
try {
|
|
// Get the status of the UTXO associate with this Offer.
|
|
const utxo = {
|
|
tx_hash: thisOffer.utxoTxid,
|
|
tx_pos: thisOffer.utxoVout
|
|
}
|
|
// console.log(`Checking this UTXO: ${JSON.stringify(utxo, null, 2)}`)
|
|
|
|
// utxoStatus = await this.adapters.wallet.bchWallet.utxoIsValid(utxo)
|
|
utxoStatus = await this.retryQueue.addToQueue(this.adapters.wallet.bchWallet.utxoIsValid, utxo)
|
|
// console.log('utxoStatus: ', utxoStatus)
|
|
} catch (err) {
|
|
// Handle corner case of bad-data in the Offer model.
|
|
if (err.message && err.message.includes('txid needs to be a proper transaction ID')) {
|
|
console.log(`Deleting Offer with bad data: ${JSON.stringify(thisOffer, null, 2)}`)
|
|
await thisOffer.remove()
|
|
continue
|
|
} else if (err.isAxiosError) {
|
|
console.log('Error trying to contact wallet service: ', err)
|
|
continue
|
|
} else {
|
|
throw err
|
|
}
|
|
}
|
|
|
|
// If the Offer UTXO is spent, delete the Offer model.
|
|
if (utxoStatus === false) {
|
|
// console.log('utxoStatus: ', utxoStatus)
|
|
console.log(`Spent UTXO detected. Deleting this Offer: ${JSON.stringify(thisOffer, null, 2)}`)
|
|
await thisOffer.remove()
|
|
}
|
|
|
|
// If the Offer is older than 7 days, delete it.
|
|
const nowMs = now.getTime()
|
|
const eightWeeks = 1000 * 60 * 60 * 24 * 7 * 8
|
|
const eightWeeksAgo = nowMs - eightWeeks
|
|
if (thisOffer.timestamp < eightWeeksAgo) {
|
|
console.log('Offer older than 8 weeks. Deleting.')
|
|
await thisOffer.remove()
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Error in removeStaleOffers(): ', err)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
// Flag offers as NSFW.
|
|
async flagOffer (flagData = {}) {
|
|
try {
|
|
if (!flagData.data) throw new Error('"data" property is required')
|
|
|
|
console.log(`flagData: ${JSON.stringify(flagData, null, 2)}`)
|
|
|
|
const eventId = flagData.data.nostrEventId
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// Get offers data from nostr.
|
|
async loadOffers () {
|
|
try {
|
|
// Retrieve offers array.
|
|
const offers = await this.adapters.nostr.read()
|
|
// console.log('offers: ', offers)
|
|
|
|
for (let i = 0; i < offers.length; i++) {
|
|
try {
|
|
const offer = offers[i]
|
|
|
|
// offer data
|
|
const offerObj = JSON.parse(offer.content)
|
|
|
|
// Append the Nostr Event ID to the offer object
|
|
offerObj.data.nostrEventId = offer.eventId
|
|
// console.log('loadOffers() offerObj: ', offerObj)
|
|
|
|
if (offerObj.data.dataType === 'offer') {
|
|
// Try to create new offer
|
|
await this.createOffer(offerObj)
|
|
}
|
|
|
|
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)}`)
|
|
await this.acceptCounterOffer(offerObj)
|
|
}
|
|
|
|
// Ignore the post if it doesn't fit the above filters.
|
|
} catch (error) {
|
|
/* exit quietly */
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error in loadOffers(): ', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// List all offers being made by a given address.
|
|
async listOffersByAddress (addr) {
|
|
try {
|
|
const offers = await this.OfferModel.find({ makerAddr: addr })
|
|
return offers
|
|
} catch (err) {
|
|
console.error('Error in listOffersByAddress(): ', err)
|
|
throw err
|
|
}
|
|
}
|
|
}
|
|
|
|
export default OfferUseCases
|