Compare commits

..
9 Commits
15 changed files with 273 additions and 70 deletions
+4 -3
View File
@@ -74,7 +74,7 @@ Writing data follows these steps:
- Tokens and BCH are held by a wallet which is under the controlled of `bch-dex`.
- The _Client_ submits data to the POST `/order` REST API endpoint to create a new Order.
- `bch-dex` will move the funds into a segregated UTXO, and will use that UTXO to create an Order. The Order data is written to the P2WDB. The Order data is also saved to the local MongoDB.
- After submitting the data to the P2WDB, `bch-dex` will receive a webhook call to its POST `/offer` endpoint by the P2WDB. This event will trigger the import of the new data into the apps local Mongo database, and generate a new Offer model.
- After submitting the data to the P2WDB, `bch-dex` will receive a webhook call to its POST `/p2wdb` endpoint by the P2WDB. This event will trigger the import of the new data into the apps local Mongo database, and generate a new Offer model.
- This webhook event is mirrored by every instance of `bch-dex` on the network. Each P2WDB peer on the network will independently validate the new database entry and create a new Offer model.
## Taking an Offer
@@ -82,8 +82,8 @@ Writing data follows these steps:
Users can browse the Offers tracked by a local `bch-dex` by using a *Client*. When they find an Offer they want to to take, they'll use some UI element that will send data to the POST `/offer/take` REST API endpoint. These series of steps happen:
- The `bch-dex` checks to see if the wallet it controls has enough BCH to take the other side of the Offer. If it does, the funds for the offer are moved to a segregated UTXO.
- The new UTXO is used to generate a *Counter Offer*, which contain a partially signed transaction saved as a hex string.
- The *Counter Offer* is submitted to the P2WDB. This triggers a webhook event in every running instance of `bch-dex` on the network.
- The new UTXO is used to generate a *Counter Offer*, which contains a partially signed transaction saved as a hex string.
- The *Counter Offer* is uploaded to the P2WDB. This triggers a webhook event in every running instance of `bch-dex` on the network.
- When the webhook is triggered, `bch-dex` will check to see if the *Counter Offer* matches an *Order* under its control. If a match is found, it will trigger an *Accept* event.
## Accepting a Counter Offer
@@ -92,6 +92,7 @@ This part of the process is automated and does not require input from the user.
- When a *Counter Offer* is received that matches an *Order* tracked by the local copy of `bch-dex`, it will trigger the *Acceptance* phase.
- In the *Acceptance* phase, the transaction will be checked to see if it matches the conditions in the original *Order*. If all checks pass, `bch-dex` will sign the transaction and broadcast it, completing the trade.
- A 'garbage collection' function that runs periodically will delete Orders and Offers in bch-dex that have had their UTXO spent, automatically cleaning up stale trade data that is no longer valid.
## Maintenance
+68 -38
View File
@@ -26,31 +26,31 @@ Order entities have the following properties:
- _utxoTxid_ - The TXID of the UTXO representing the token or BCH being offered for sale.
- _utxoVout_ - The vout of the UTXO representing the token or BCH being offered for sale.
- Trade Data:
- _buyOrSell_ - A string with a value `buy` or `sell` indicating which type of offer this is.
- _numTokens_ - The maximum number of tokens offered for sale.
- _rateInBaseUnit_ - The rate in terms of currency-unit-per-token. Ex: 1000 = 1000 sats per token
- For Bitcoin, the min currency is sats.
- For AVAX, the min currency is nano-Avax.
- for eCash, the min currency is bits.
- _minUnitsToExchange_ - The minimum order size accepted.
- _makerAddr_ - The address for the taker to send money to.
- _p2wdbTxid_ - The TXID proof-of-burn used to add the order to the P2WDB.
- _p2wdbHash_ - The CID used to identify the order entry in the P2WDB.
- Trade Data:
- _buyOrSell_ - A string with a value `buy` or `sell` indicating which type of offer this is.
- _numTokens_ - The maximum number of tokens offered for sale.
- _rateInBaseUnit_ - The rate in terms of currency-unit-per-token. Ex: 1000 = 1000 sats per token
- For Bitcoin, the min currency is sats.
- For AVAX, the min currency is nano-Avax.
- for eCash, the min currency is bits.
- _minUnitsToExchange_ - The minimum order size accepted.
- _makerAddr_ - The address for the taker to send money to.
- _p2wdbTxid_ - The TXID proof-of-burn used to add the order to the P2WDB.
- _p2wdbHash_ - The CID used to identify the order entry in the P2WDB.
- Authentication Data:
- _signature_ - A message signed by the address which created the order.
- _sigMsg_ - The clear-text message used to generate the signature.
- _offerBchAddr_ - The BCH address controlling the offer.
- _offerPubKey_ - The public key used to generate the BCH address, used for encryption.
- Authentication Data:
- _signature_ - A message signed by the address which created the order.
- _sigMsg_ - The clear-text message used to generate the signature.
- _offerBchAddr_ - The BCH address controlling the offer.
- _offerPubKey_ - The public key used to generate the BCH address, used for encryption.
- Wallet Data:
- _hdIndex_ - The HD index of the wallet used to generate the keypair to store the UTXO being offered for sale.
- Wallet Data:
- _hdIndex_ - The HD index of the wallet used to generate the keypair to store the UTXO being offered for sale.
- SWaP Protocol properties:
- _lokadId_ - Not used. Provided for future functionality.
- _messageType_ - Not used. Provided for future functionality.
- _messageClass_ - Not used. Provided for future functionality.
- SWaP Protocol properties:
- _lokadId_ - Not used. Provided for future functionality.
- _messageType_ - Not used. Provided for future functionality.
- _messageClass_ - Not used. Provided for future functionality.
### Offer
@@ -96,46 +96,76 @@ Offer entities have the following properties:
- _messageType_ - Not used. Provided for future functionality.
- _messageClass_ - Not used. Provided for future functionality.
When an Offer is uploaded to the P2WDB, the following properties are added to the data:
- Added properties to P2WDB data:
- *dataType* - Has a value of 'offer'. This is used to route the data correctly when the P2WDB webhook passes the data to `bch-dex`.
### Counter Offer
A Counter Offer is the other side of an Offer. It contains a partially signed transaction, created by the Taker. The Maker will review the Counter Offer before accepting and finalizing the trade.
A Counter Offer is the other side of the trade, a mirror image to an Offer. It contains a partially signed transaction, created by the Taker. The Maker will review the Counter Offer before accepting and finalizing the trade.
Counter Offers are not tracked via database models like Offers and Orders are. They are processed by `bch-dex` as soon as they are received. A garbage collection function will be called periodically, to sweep the segregated UTXOs used to generate a Counter Offer back into the root address of the wallet, if it is not accepted after a period of time.
To generate a Counter Offer, a segregated UTXO is created that matches the requirements in the Offer. A partially signed transaction is created that includes the Offer UTXO and the Counter Offer UTXO. The Taker signs the input consuming the Counter Offer UTXO. The partially signed transaction and other trade data is uploaded to P2WDB.
When the Counter Offer data is uploaded to the P2WDB, the data must have the following properties:
- *partialTxHex* - A hexidecimal representation of the partially-signed transaction, which includes the UTXO in the Offer and a second UTXO for that matches the conditions in the Offer.
- *dataType* - Must have a value of `counter-offer`
Details TBD.
## Use Cases
Use cases are verbs or actions that is done _to_ an Entity or _between_ Entities.
### Offer
- **`createOffer()`** - This method is triggered by a webhook from the P2WDB. It will take the data provided by the P2WDB and create a new Order entity in the local database.
### Order
- **`createOrder()`** - A macro command that leverages the other functions in this library to create a new Order and submit it to the P2WDB.
- **`ensureFunds()`** - Ensure that the wallet has enough BCH and tokens to complete the requested trade.
- **`moveTokens()`** - Move the tokens indicated in the order to a temporary holding address. This will generate the UTXO used in the webhook message. This function moves the funds and returns the UTXO information.
- **`createOrder()`** - A macro command that leverages `ensureFunds()` and `moveTokens()`, to create a new Order and submit it to the P2WDB.
- **`findOrderByHash()`** - Given a P2WDB CID 'hash', this function will return the corresponding Order model associated with that CID.
### Counter Offer
### Offer
TBD
- **`createOffer()`** - This method is triggered by a webhook from the P2WDB. It will take the data provided by the P2WDB and create a new Offer entity in the local database.
- **`listOffers()`** - Returns a list of all the active Offers tracked by `bch-dex`.
- **`takeOffer()`** - Generate a segregated UTXO and partially signed transaction, then upload the Counter Offer data to the P2WDB.
- **`ensureFunds()`** - Ensure the wallet has enough BCH and tokens to make a Counter Offer.
- **`findOfferByHash()`** - Given a P2WDB CID 'hash', this function will return the corresponding Offer model associated with that CID.
- **`acceptCounterOffer()`** - This function is triggered 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.
## Controllers
Controllers are inputs to the system. When a controller is activated, it causes the system to react in some way.
### Offers
- **POST /offer** - This POST REST API endpoint will be triggered by a webhook generated from P2WDB. This will notify the `bch-dex` that a new entry has been added to the P2WDB that matches the `appId` of `swap-<chain>`, where `<chain>` has a value of `avax`, `bch`, or `ecash`. It's a new entry that should be evaluated for inclusion in the `ipfs-swap-service` local database.
### Orders
- **POST /order** - This POST REST API endpoint can be triggered by the Client or a simple curl call. It passes in the data needed for `bch-dex` to generate and track a new Order, then submit the data to the P2WDB to generate an Offer that is tracked by all other instances of `bch-dex`.
- **POST /order** - This POST REST API endpoint can be triggered by the Client or a simple curl call. It passes in the data needed for `bch-dex` to generate and track a new Order, then submits the data to the P2WDB to generate an Offer that is tracked by all other instances of `bch-dex` on the network.
### Offers
- **GET /offer/list** - Returns an array of objects, where each object represents an active Offer.
- **POST /offer/take** - Given the P2WDB CID of an Offer, calling this endpoint will attempt to generate a Counter Offer and upload it to the P2WDB.
### P2WDB
- **POST /p2wdb** - This POST REST API endpoint will be called by a webhook generated from the P2WDB. This will notify the `bch-dex` that a new entry has been added to the P2WDB that matches the `appId` of `swap-<chain>`, where `<chain>` has a value of `avax`, `bch`, or `ecash`. It's a new entry that should be evaluated for inclusion in the `bch-dex` local database. Based on the `dataType` property, the data is routed to either create a new Offer or to process a new Counter Offer.
## Adapters
Adapters are output libraries so that the business logic doesn't need to know any specific information about the I/O. They are essentially the output of the application.
Adapters are outputs. These libraries exist so that the business logic doesn't need to know any specific information about the I/O. They handle the low-level mechanics of the various subsystems.
- **localdb** - An adapter for the local database (MongoDB).
- **ipfs** - An adapter for IPFS and the ipfs-coord library. It allows the app to use the JSON-RPC over IPFS.
- **wallet** - Handles low-level wallet tasks.
- **`moveTokens()`** - Move the tokens indicated in the order to a temporary holding address. This will generate the segregated UTXOs used in the trade. This function moves the funds and returns the UTXO information.
- **`moveBCH()`** - Same idea as `moveTokens()`, but creates a non-SLP segregated UTXO, used when generating a Counter Offer.
- **`openWallet()`** - Opens the JSON file containing the wallet data for the app.
- **`instanceWallet()`** - Given the wallet data returned by `openWallet()`, it generates an instance of the [minimal-slp-wallet](https://www.npmjs.com/package/minimal-slp-wallet) for generating UTXOs and interacting with the blockchain.
- **`incrementNextAddress()`** - Increments the index of the [HD wallet](https://github.com/bitcoinbook/bitcoinbook/blob/develop/ch05.asciidoc#hd_wallets) used to generate keypairs for storing segregated UTXOs.
- **`getKeyPair()`** - Generates a keypair from the HD wallet.
- **`generateSignature()`** - Generate a cryptographic signature.
- **`generatePartialTx()`** - Create a partial transaction as part of a Counter Offer.
- **`deseralizeTx()`** - Used only for debugging purposes. Expands a hex transaction into a JSON object.
- **`completeTx()`** - Complete the partially signed transaction by signing the first input, then broadcasting the transaction to the network.
+1
View File
@@ -6,6 +6,7 @@ const Offer = new mongoose.Schema({
tokenId: { type: String },
utxoTxid: { type: String },
utxoVout: { type: Number },
ticker: { type: String },
// Trade data
buyOrSell: { type: String },
+1
View File
@@ -13,6 +13,7 @@ const Order = new mongoose.Schema({
tokenId: { type: String },
utxoTxid: { type: String },
utxoVout: { type: Number },
ticker: { type: String },
// Trade data
buyOrSell: { type: String },
+3 -8
View File
@@ -6,18 +6,12 @@
// Public npm libraries.
// Load the Clean Architecture Adapters library
// Local libraries
const Adapters = require('../adapters')
// Load the JSON RPC Controller.
const JSONRPC = require('./json-rpc')
// Load the Clean Architecture Use Case libraries.
const UseCases = require('../use-cases')
// const useCases = new UseCases({ adapters })
// Load the REST API Controllers.
const RESTControllers = require('./rest-api')
const TimerControllers = require('./timer-controllers')
class Controllers {
constructor (localConfig = {}) {
@@ -33,6 +27,7 @@ class Controllers {
// this.attachRESTControllers(app)
// this.attachRPCControllers()
this.timerControllers = new TimerControllers({ adapters: this.adapters, useCases: this.useCases })
}
// Top-level function for this library.
+59
View File
@@ -0,0 +1,59 @@
/*
This Controller library is concerned with timer-based functions that are
kicked off periodicially.
*/
// Used to retain scope of 'this', when the scope is lost.
let _this
class TimerControllers {
constructor (localConfig = {}) {
// Dependency Injection.
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(
'Instance of Adapters library required when instantiating Timer Controller libraries.'
)
}
this.useCases = localConfig.useCases
if (!this.useCases) {
throw new Error(
'Instance of Use Cases library required when instantiating Timer Controller libraries.'
)
}
this.debugLevel = localConfig.debugLevel
_this = this
this.startTimers()
}
// Start all the time-based controllers.
startTimers () {
setInterval(this.gcOrders, 60000 * 5)
setInterval(this.gcOffers, 60000 * 5)
}
// Garbage Collect the Orders.
gcOrders () {
try {
_this.useCases.order.removeStaleOrders()
} catch (err) {
// Do not throw an error. This is a top-level function.
console.log('Error in timer-controllers.js/gcOrders(): ', err)
}
}
// Garbage Collect the Offers.
gcOffers () {
try {
_this.useCases.offer.removeStaleOffers()
} catch (err) {
// Do not throw an error. This is a top-level function.
console.log('Error in timer-controllers.js/gcOffers(): ', err)
}
}
}
module.exports = TimerControllers
+7 -2
View File
@@ -28,7 +28,8 @@ class OfferEntity {
utxoTxid,
utxoVout,
offerStatus,
makerAddr
makerAddr,
ticker
} = offerData.data
// Input Validation
@@ -65,6 +66,9 @@ class OfferEntity {
if (!makerAddr || typeof makerAddr !== 'string') {
throw new Error("Property 'makerAddr' must be a string.")
}
if (!ticker || typeof ticker !== 'string') {
throw new Error("Property 'ticker' must be a string.")
}
const validatedOfferData = {
messageType,
@@ -81,7 +85,8 @@ class OfferEntity {
txid: offerData.txid,
p2wdbHash: offerData.hash,
offerStatus: offerStatus || this.offerStatus[0],
makerAddr
makerAddr,
ticker
}
return validatedOfferData
+12 -2
View File
@@ -15,7 +15,9 @@ class Order {
buyOrSell,
rateInBaseUnit,
minUnitsToExchange,
numTokens
numTokens,
makerAddr,
ticker
} = data
// Input Validation
@@ -40,6 +42,12 @@ class Order {
if (!numTokens || typeof numTokens !== 'number') {
throw new Error("Property 'numTokens' must be a number.")
}
if (!makerAddr || typeof makerAddr !== 'string') {
throw new Error("Property 'makerAddr' must be a string.")
}
if (!ticker || typeof ticker !== 'string') {
throw new Error("Property 'ticker' must be a string.")
}
const offerData = {
messageType,
@@ -48,7 +56,9 @@ class Order {
buyOrSell,
rateInBaseUnit,
minUnitsToExchange,
numTokens
numTokens,
makerAddr,
ticker
}
return offerData
+48 -1
View File
@@ -29,7 +29,7 @@ class OfferUseCases {
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.'
'Instance of Offer Use Cases must be passed in when instantiating Offer Use Cases library.'
)
}
@@ -299,6 +299,53 @@ class OfferUseCases {
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.
utxoStatus = await this.adapters.bchjs.Blockchain.getTxOut(
thisOffer.utxoTxid,
thisOffer.utxoVout
)
// console.log('utxoStatus: ', utxoStatus)
} catch (err) {
// Handle corner case of bad-data in the Offer model.
if (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 {
throw err
}
}
// If the Offer UTXO is spent, delete the Offer model.
if (utxoStatus === null) {
console.log(`Spent UTXO detected. Deleting this Offer: ${JSON.stringify(thisOffer, null, 2)}`)
await thisOffer.remove()
}
}
} catch (err) {
console.error('Error in removeStaleOffers()')
throw err
}
}
}
module.exports = OfferUseCases
+56 -4
View File
@@ -29,6 +29,15 @@ class OrderLib {
try {
console.log('createOrder(entryObj): ', entryObj)
// Specify the address to send payment.
entryObj.makerAddr = this.adapters.wallet.bchWallet.walletInfo.cashAddress
console.log('entryObj.makerAddr: ', entryObj.makerAddr)
// Get Ticker for token ID.
const tokenData = await this.adapters.wallet.bchWallet.getTxData([entryObj.tokenId])
// console.log(`tokenData: ${JSON.stringify(tokenData, null, 2)}`)
entryObj.ticker = tokenData[0].tokenTicker
// Input Validation
const orderEntity = this.orderEntity.validate(entryObj)
console.log('orderEntity: ', orderEntity)
@@ -52,10 +61,6 @@ class OrderLib {
orderEntity.utxoTxid = utxoInfo.txid
orderEntity.utxoVout = utxoInfo.vout
// Specify the address to send payment.
orderEntity.makerAddr = this.adapters.wallet.bchWallet.walletInfo.cashAddress
console.log('orderEntity.makerAddr: ', orderEntity.makerAddr)
// Add P2WDB specific flag for signaling that this is a new offer.
orderEntity.dataType = 'offer'
@@ -158,6 +163,53 @@ class OrderLib {
throw err
}
}
// This function is called by the garbage collection timer controller. It
// checks the UTXO associated with each Order in the database. If the UTXO
// has been spent, the Order is deleted from the database.
async removeStaleOrders () {
try {
const now = new Date()
console.log(`Starting garbage collection for Orders at ${now.toLocaleString()}`)
// Get all Orders in the database.
const orders = await this.OrderModel.find({})
// console.log('orders: ', orders)
// Loop through each Order and ensure the UTXO is still valid.
for (let i = 0; i < orders.length; i++) {
const thisOrder = orders[i]
let utxoStatus = null
try {
// Get the status of the UTXO associate with this Order.
utxoStatus = await this.adapters.bchjs.Blockchain.getTxOut(
thisOrder.utxoTxid,
thisOrder.utxoVout
)
// console.log('utxoStatus: ', utxoStatus)
} catch (err) {
// Handle corner case of bad-data in the Order model.
if (err.message.includes('txid needs to be a proper transaction ID')) {
console.log(`Deleting Order with bad data: ${JSON.stringify(thisOrder, null, 2)}`)
await thisOrder.remove()
continue
} else {
throw err
}
}
// If the Order UTXO is spent, delete the Order model.
if (utxoStatus === null) {
console.log(`Spent UTXO detected. Deleting this Order: ${JSON.stringify(thisOrder, null, 2)}`)
await thisOrder.remove()
}
}
} catch (err) {
console.error('Error in removeStaleOrders()')
throw err
}
}
}
module.exports = OrderLib
+1 -1
View File
@@ -12,7 +12,7 @@ async function start () {
method: 'post',
url: `${LOCALHOST}/offer/take`,
data: {
offerCid: 'zdpuAwVTq7nF19VaDw6n49S2RvsVHNkYufZNP515Pqocyae9m'
offerCid: 'zdpuAkv76xbgqFEdFvcz28yNvtVjoZS7wsMo5wvuxWstycLRg'
}
}
+3 -1
View File
@@ -253,12 +253,14 @@ describe('#Offer-Entity', () => {
'241c06bf61384b8623477e419bf4779edbcc7e3bc862f0f179a9ed2967069b87',
utxoVout: 0,
offerStatus: 'posted',
makerAddr: 'bitcoincash:qzl0d3gcqeypv4cy7gh8rgdszxa9vvm2acv7fqtd00'
makerAddr: 'bitcoincash:qzl0d3gcqeypv4cy7gh8rgdszxa9vvm2acv7fqtd00',
ticker: 'TROUT'
},
timestamp: '2021-09-20T17:54:26.395Z',
localTimeStamp: '9/20/2021, 10:54:26 AM',
txid: '46f50f2a0cf44e3ed70dfb0618ef3ebfee57aabcf229b5d2d17c07322b54a8d7',
hash: 'zdpuB2X25AZCKo3wpr4sSbw44vqPWJRqcxWQRHZccK5BdtoGD'
}
const result = uut.validate(offerObj)
+5
View File
@@ -29,6 +29,11 @@ class MockBchWallet {
}
this.getUtxos = async () => {
}
this.getTxData = async () => {
return [{
tokenTicker: 'TROUT'
}]
}
// Environment variable is used by wallet-balance.unit.js to force an error.
if (process.env.NO_UTXO) {
+2 -1
View File
@@ -95,7 +95,8 @@ describe('#offer-use-case', () => {
utxoTxid:
'241c06bf61384b8623477e419bf4779edbcc7e3bc862f0f179a9ed2967069b87',
utxoVout: 0,
makerAddr: 'bitcoincash:qzl0d3gcqeypv4cy7gh8rgdszxa9vvm2acv7fqtd00'
makerAddr: 'bitcoincash:qzl0d3gcqeypv4cy7gh8rgdszxa9vvm2acv7fqtd00',
ticker: 'TROUT'
},
timestamp: '2021-09-20T17:54:26.395Z',
localTimeStamp: '9/20/2021, 10:54:26 AM',
+3 -9
View File
@@ -109,13 +109,12 @@ describe('#order-use-case', () => {
// Mock dependencies
// sandbox.stub(uut.adapters.wallet, 'burnPsf').resolves('fakeTxid')
// sandbox.stub(uut.adapters.wallet.bchWallet, 'getTxData').resolves({ tokenTicker: 'TROUT' })
sandbox.stub(uut.orderEntity, 'validate').returns(entryObj)
sandbox.stub(uut, 'ensureFunds').resolves()
sandbox.stub(uut.adapters.wallet.bchWallet.bchjs.Util, 'sleep').resolves()
sandbox.stub(uut.adapters.wallet, 'moveTokens').resolves({ txid: 'fakeTxid', vout: 0, hdIndex: 1 })
sandbox.stub(uut.adapters.wallet.bchWallet, 'getUtxos').resolves()
// sandbox
// .stub(uut.adapters.wallet, 'generateSignature')
// .resolves('fakeSignature')
sandbox.stub(uut.adapters.p2wdb, 'write').resolves('fakeHash')
const result = await uut.createOrder(entryObj)
@@ -126,16 +125,11 @@ describe('#order-use-case', () => {
it('should catch and throw an error', async () => {
try {
// Force an error
sandbox
.stub(uut.orderEntity, 'validate')
.throws(new Error('test error'))
await uut.createOrder()
assert.fail('Unexpected code path')
} catch (err) {
assert.include(err.message, 'test error')
assert.include(err.message, 'Cannot set')
}
})
})