From d079d8c7c4c1efcb39f05255b2d025605dbbd790 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 9 Oct 2020 10:04:27 -0700 Subject: proposals -> orders --- db/index.js | 12 +- db/models/orders.js | 111 +++++++ db/models/proposals.js | 138 -------- routes/swaps.js | 281 +++++++++++----- swaps.js | 876 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1198 insertions(+), 220 deletions(-) create mode 100644 db/models/orders.js delete mode 100644 db/models/proposals.js create mode 100644 swaps.js diff --git a/db/index.js b/db/index.js index 0772bfc7..2ee61643 100644 --- a/db/index.js +++ b/db/index.js @@ -13,10 +13,10 @@ require("./models/codes.js"); require("./models/invoices.js"); require("./models/keys.js"); require("./models/payments.js"); -require("./models/proposals.js"); +require("./models/orders.js"); require("./models/users.js"); -const { User, Account, Payment, Invoice, Key, Proposal } = db; +const { User, Account, Payment, Invoice, Key, Order } = db; User.hasMany(Account, { as: "accounts", @@ -73,19 +73,19 @@ Payment.belongsTo(User, { foreignKey: "user_id" }); -Proposal.belongsTo(User, { +Order.belongsTo(User, { as: "user", foreignKey: "user_id" }); -Proposal.belongsTo(Account, { +Order.belongsTo(Account, { as: "acc1", foreignKey: "a1_id" }); -Proposal.belongsTo(Account, { +Order.belongsTo(Account, { as: "acc2", foreignKey: "a2_id" }); -db.Proposal = Proposal; +db.Order = Order; diff --git a/db/models/orders.js b/db/models/orders.js new file mode 100644 index 00000000..e9c701a3 --- /dev/null +++ b/db/models/orders.js @@ -0,0 +1,111 @@ +const { DataTypes } = require("sequelize"); + +const attributes = { + id: { + type: DataTypes.INTEGER(11), + allowNull: false, + defaultValue: null, + primaryKey: true, + autoIncrement: true, + comment: null, + field: "id" + }, + a1_id: { + type: DataTypes.INTEGER(11), + allowNull: true, + defaultValue: null, + primaryKey: false, + autoIncrement: false, + comment: null, + field: "a1_id" + }, + a2_id: { + type: DataTypes.INTEGER(11), + allowNull: true, + defaultValue: null, + primaryKey: false, + autoIncrement: false, + comment: null, + field: "a2_id" + }, + user_id: { + type: DataTypes.INTEGER(11), + allowNull: true, + defaultValue: null, + primaryKey: false, + autoIncrement: false, + comment: null, + field: "user_id" + }, + completedAt: { + type: DataTypes.DATE, + allowNull: true, + defaultValue: null, + primaryKey: false, + autoIncrement: false, + comment: null, + field: "completedAt" + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: null, + primaryKey: false, + autoIncrement: false, + comment: null, + field: "createdAt" + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: null, + primaryKey: false, + autoIncrement: false, + comment: null, + field: "updatedAt" + }, + v1: { + type: DataTypes.DOUBLE, + allowNull: true, + defaultValue: null, + primaryKey: false, + autoIncrement: false, + comment: null, + field: "v1" + }, + v2: { + type: DataTypes.DOUBLE, + allowNull: true, + defaultValue: null, + primaryKey: false, + autoIncrement: false, + comment: null, + field: "v2" + }, + rate: { + type: DataTypes.DOUBLE, + allowNull: true, + defaultValue: null, + primaryKey: false, + autoIncrement: false, + comment: null, + field: "rate" + }, + accepted: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + primaryKey: false, + autoIncrement: false, + comment: null, + field: "accepted" + }, +}; + +const options = { + tableName: "orders", + comment: "", + indexes: [] +}; + +db.Order = db.define("orders_model", attributes, options); diff --git a/db/models/proposals.js b/db/models/proposals.js deleted file mode 100644 index 1f93ba06..00000000 --- a/db/models/proposals.js +++ /dev/null @@ -1,138 +0,0 @@ -const { DataTypes } = require("sequelize"); - -const attributes = { - id: { - type: DataTypes.INTEGER(11), - allowNull: false, - defaultValue: null, - primaryKey: true, - autoIncrement: true, - comment: null, - field: "id" - }, - a1_id: { - type: DataTypes.INTEGER(11), - allowNull: true, - defaultValue: null, - primaryKey: false, - autoIncrement: false, - comment: null, - field: "a1_id" - }, - a2_id: { - type: DataTypes.INTEGER(11), - allowNull: true, - defaultValue: null, - primaryKey: false, - autoIncrement: false, - comment: null, - field: "a2_id" - }, - user_id: { - type: DataTypes.INTEGER(11), - allowNull: true, - defaultValue: null, - primaryKey: false, - autoIncrement: false, - comment: null, - field: "user_id" - }, - completedAt: { - type: DataTypes.DATE, - allowNull: true, - defaultValue: null, - primaryKey: false, - autoIncrement: false, - comment: null, - field: "completedAt" - }, - createdAt: { - type: DataTypes.DATE, - allowNull: false, - defaultValue: null, - primaryKey: false, - autoIncrement: false, - comment: null, - field: "createdAt" - }, - updatedAt: { - type: DataTypes.DATE, - allowNull: false, - defaultValue: null, - primaryKey: false, - autoIncrement: false, - comment: null, - field: "updatedAt" - }, - fee: { - type: DataTypes.DOUBLE, - allowNull: true, - defaultValue: null, - primaryKey: false, - autoIncrement: false, - comment: null, - field: "fee" - }, - v1: { - type: DataTypes.DOUBLE, - allowNull: true, - defaultValue: null, - primaryKey: false, - autoIncrement: false, - comment: null, - field: "v1" - }, - v2: { - type: DataTypes.DOUBLE, - allowNull: true, - defaultValue: null, - primaryKey: false, - autoIncrement: false, - comment: null, - field: "v2" - }, - rate: { - type: DataTypes.DOUBLE, - allowNull: true, - defaultValue: null, - primaryKey: false, - autoIncrement: false, - comment: null, - field: "rate" - }, - text: { - type: DataTypes.TEXT, - allowNull: false, - defaultValue: false, - primaryKey: false, - autoIncrement: false, - comment: null, - field: "text" - }, - public: { - type: DataTypes.BOOLEAN, - allowNull: false, - defaultValue: false, - primaryKey: false, - autoIncrement: false, - comment: null, - field: "public" - }, - accepted: { - type: DataTypes.BOOLEAN, - allowNull: false, - defaultValue: false, - primaryKey: false, - autoIncrement: false, - comment: null, - field: "accepted" - }, -}; - -const options = { - tableName: "proposals", - comment: "", - indexes: [] -}; - -db.Proposal = db.define("proposals_model", attributes, options); diff --git a/routes/swaps.js b/routes/swaps.js index 991c4abc..8872face 100644 --- a/routes/swaps.js +++ b/routes/swaps.js @@ -6,6 +6,8 @@ const { spawn } = require("child_process"); const leveldb = level("leveldb"); const getAccount = require("../lib/account"); const { Op, col } = require("sequelize"); +const uuidv4 = require("uuid/v4"); + const shallow = a => { let b = {}; for (x in a) { @@ -62,6 +64,62 @@ const getInfo = filename => ); }); +app.post( + "/propose", + auth, + ah(async (req, res) => { + const { a1, v1, a2, v2 } = req.body; + + const text = await new Promise((resolve, reject) => { + l.info( + "running liquid-cli tool %s %s %s %s %s", + config.liquid.conf, + a1, + v1, + a2, + v2 + ); + const proc = cli( + "propose", + a1, + (v1 / SATS).toFixed(8), + a2, + (v2 / SATS).toFixed(8) + ); + + proc.stdout.on("data", data => { + resolve(data.toString()); + }); + + proc.stderr.on("error", err => { + l.error("order error", err.toString()); + reject(err.toString()); + }); + + proc.on("close", (code, signal) => { + let msg = (code && code.toString()) || (signal && signal.toString()); + reject( + new Error(`Liquid swap tool process closed unexpectedly: ${msg}`) + ); + }); + + proc.on("exit", (code, signal) => { + let msg = (code && code.toString()) || (signal && signal.toString()); + reject( + new Error(`Liquid swap tool process exited unexpectedly: ${msg}`) + ); + }); + + setTimeout( + () => reject(new Error("Liquid swap tool timed out"), proc), + timeout + ); + }); + + res.send(text); + }) +); + const swap = async (user, { a1, a2, v1, v2 }) => { let rate = v2 / v1; @@ -114,7 +172,7 @@ const swap = async (user, { a1, a2, v1, v2 }) => { emit(user.username, "payment", payment.get({ plain: true })); emit(user.username, "account", a1acc.get({ plain: true })); - const proposals = await db.Proposal.findAll( + const orders = await db.Order.findAll( { where: { "$acc1.asset$": a2, @@ -144,9 +202,9 @@ const swap = async (user, { a1, a2, v1, v2 }) => { { transaction } ); - for (let i = 0; i < proposals.length; i++) { + for (let i = 0; i < orders.length; i++) { if (!v1) break; - p = proposals[i]; + p = orders[i]; if (p.v2 > v1) { p.v2 -= v1; await p.save({ transaction }); @@ -228,7 +286,7 @@ const swap = async (user, { a1, a2, v1, v2 }) => { p.a2 = p.acc2.asset; p.rate = p.v2 / p.v1; - broadcast("proposal", shallow(p)); + broadcast("order", shallow(p)); v1 = 0; } else { v1 -= p.v2; @@ -284,13 +342,13 @@ const swap = async (user, { a1, a2, v1, v2 }) => { p.a2 = p.acc2.asset; p.rate = p.v2 / p.v1; - broadcast("proposal", shallow(p)); + broadcast("order", shallow(p)); } } - let proposal; + let order; if (v1) { - proposal = await db.Proposal.create( + order = await db.Order.create( { v1, v2, @@ -301,19 +359,19 @@ const swap = async (user, { a1, a2, v1, v2 }) => { { transaction } ); - proposal = proposal.get({ plain: true }); - proposal.rate = v2 / v1; - proposal.a1 = a1; - proposal.a2 = a2; + order = order.get({ plain: true }); + order.rate = v2 / v1; + order.a1 = a1; + order.a2 = a2; - broadcast("proposal", proposal); + broadcast("order", order); } }); }; const cancel = async (user, id) => { await db.transaction(async transaction => { - let proposal = await db.Proposal.findOne( + let order = await db.Order.findOne( { where: { id, @@ -328,7 +386,7 @@ const cancel = async (user, id) => { { transaction } ); - let { acc1: account, v1, v2 } = proposal; + let { acc1: account, v1, v2 } = order; await account.increment({ balance: v1 }, { transaction }); await account.reload({ transaction }); @@ -351,13 +409,13 @@ const cancel = async (user, id) => { emit(user.username, "account", account.get({ plain: true })); emit(user.username, "payment", payment.get({ plain: true })); - if (proposal.fee) { + if (order.fee) { const btc = await getAccount(config.liquid.btcasset, user, transaction); payment = await db.Payment.create( { hash: "Swap Fee Refund", - amount: proposal.fee, + amount: order.fee, account_id: btc.id, user_id: user.id, currency: user.currency, @@ -369,20 +427,20 @@ const cancel = async (user, id) => { { transaction } ); - await btc.increment({ balance: proposal.fee }, { transaction }); + await btc.increment({ balance: order.fee }, { transaction }); await btc.reload({ transaction }); emit(user.username, "account", btc.get({ plain: true })); emit(user.username, "payment", payment.get({ plain: true })); } - broadcast("removeProposal", id); - await proposal.destroy({ transaction }); + broadcast("removeOrder", id); + await order.destroy({ transaction }); }); }; app.delete( - "/proposal/:id", + "/order/:id", auth, ah(async (req, res) => { const { id } = req.params; @@ -413,21 +471,9 @@ app.post( optionalAuth, ah(async (req, res) => { try { - const { id, text } = req.body; - - let proposal = await db.Proposal.findOne({ - where: { - id - }, - include: { - model: db.User, - as: "user" - } - }); - - if (!proposal) throw new Error("Proposal not found"); - + const { text } = req.body; const { user } = req; + const id = uuidv4(); if (text) { const filename = `acceptance-${id}.txt`; @@ -445,42 +491,123 @@ app.post( const leg1 = info.legs.find(leg => !leg.incoming); const leg2 = info.legs.find(leg => leg.incoming); - await db.transaction(async transaction => { - const l2a2 = await getAccount(leg2.asset, proposal.user, transaction); + console.log(info, rate, leg2.amount / leg1.amount); + let orders = await db.Order.findAll({ + where: { + rate: { [Op.lte]: leg2.amount / leg1.amount }, + accepted: false, + "$acc1.asset$": leg1.asset, + "$acc2.asset$": leg2.asset + }, + attributes: { + include: [ + [col("acc1.asset"), "a1"], + [col("acc2.asset"), "a2"] + ] + }, + order: [["rate", "DESC"]], + include: [ + { + model: db.User, + as: "user", + }, + { + model: db.Account, + as: "acc1", + attributes: [] + }, + { + model: db.Account, + as: "acc2", + attributes: [] + } + ] + }); - let amount = Math.round(leg1.amount * SATS); - fee = Math.round(fee * SATS); + console.log(orders.map(p => ({ rate: p.rate, v1: p.v1, a1: p.a1 }))); - amount = Math.round(leg2.amount * SATS); + let total = orders.reduce((a, b) => a + b.v1, 0); + let remaining = Math.round(leg1.amount * SATS); + if (total < remaining) + throw new Error("Not enough coins for sale at that price"); - await db.Invoice.create({ - user_id: proposal.user_id, - text: u_address_p, - address: u_address_p, - unconfidential: u_address_p, - currency: proposal.user.currency, - memo: "Atomic Swap", - rate: app.get("rates")[proposal.user.currency], - amount, - tip: 0, - network: "liquid", - account_id: l2a2.id - }); + await db.transaction(async transaction => { + for (let i = 0; i < orders.length; i++) { + let order = orders[i]; + const account = await getAccount( + leg1.asset, + order.user, + transaction + ); - addresses[u_address_p] = proposal.user.username; + console.log("aid", account.id); + + let { v1: amount } = order; + if (remaining >= amount) { + console.log(remaining); + remaining -= amount; + + await db.Invoice.create({ + user_id: order.user_id, + text: u_address_p, + address: u_address_p, + unconfidential: u_address_p, + currency: order.user.currency, + memo: "Atomic Swap", + rate: app.get("rates")[order.user.currency], + amount, + tip: 0, + network: "liquid", + account_id: account.id + }); + + addresses[u_address_p] = order.user.username; + + order.accepted = true; + order.completedAt = new Date(); + await order.save({ transaction }); + + order = order.get({ plain: true }); + order.a1 = leg2.asset; + order.a2 = leg1.asset; + order.rate = order.v2 / order.v1; + + console.log("broadcasting", order.id); + + broadcast("order", shallow(order)); + } else { + await db.Invoice.create({ + user_id: order.user_id, + text: u_address_p, + address: u_address_p, + unconfidential: u_address_p, + currency: order.user.currency, + memo: "Atomic Swap", + rate: app.get("rates")[order.user.currency], + amount: remaining, + tip: 0, + network: "liquid", + account_id: account.id + }); + + addresses[u_address_p] = order.user.username; + + console.log("new math", order.v2, Math.round(order.v2 * remaining / amount), amount, remaining, order.v1 - remaining); + order.decrement({ v1: remaining, v2: Math.round(order.v2 * remaining / amount) }, { transaction }); + await order.save({ transaction }); + await order.reload({ transaction }); + + order = order.get({ plain: true }); + order.a1 = leg2.asset; + order.a2 = leg1.asset; + order.rate = order.v2 / order.v1; + + broadcast("order", shallow(order)); + } + } + console.log("finalizing", filename); await finalize(filename); - - proposal.accepted = true; - proposal.completedAt = new Date(); - await proposal.save({ transaction }); - - proposal = proposal.get({ plain: true }); - proposal.a1 = leg1.asset; - proposal.a2 = leg2.asset; - proposal.rate = proposal.v2 / proposal.v1; - - broadcast("proposal", shallow(proposal)); }); res.send(info); @@ -488,7 +615,7 @@ app.post( throw new Error("No acceptance provided"); } } catch (e) { - l.error(e.message); + l.error(e.message, e.stack); res.status(500).send(e.message); } }) @@ -651,11 +778,11 @@ const finalize = (filename = "finalized.txt", text) => { }; app.get( - "/proposals", + "/orders", optionalAuth, ah(async (req, res) => { try { - let proposals = await db.Proposal.findAll({ + let orders = await db.Order.findAll({ attributes: { include: [ [col("acc1.asset"), "a1"], @@ -677,7 +804,7 @@ app.get( }); res.send( - proposals.map(p => { + orders.map(p => { if (p.user_id !== req.user.id) p.user_id = null; return p; }) @@ -694,7 +821,7 @@ app.post( ah(async (req, res) => { try { const { id } = req.body; - await db.Proposal.update( + await db.Order.update( { public: true }, { where: { id, user_id: req.user.id } } ); @@ -720,7 +847,7 @@ setInterval(async () => { }); await db.transaction(async transaction => { - let p = await db.Proposal.findOne( + let p = await db.Order.findOne( { where: { user_id: user.id, @@ -750,7 +877,7 @@ setInterval(async () => { }; if (p) { - let bestBid = await db.Proposal.findOne( + let bestBid = await db.Order.findOne( { where: { "$acc1.asset$": lcad, @@ -775,7 +902,8 @@ setInterval(async () => { }, { transaction } ); - if (!bestBid || params.v2 / params.v1 > bestBid.rate) { + if (!bestBid || (params.v2 / params.v1 > bestBid.rate && p.acc2.balance >= params.v2)) { + p.v1 = params.v1; p.v2 = params.v2; await p.save(); p = p.get({ plain: true }); @@ -783,7 +911,7 @@ setInterval(async () => { p.a2 = params.a2; p.rate = p.v2 / p.v1; - broadcast("proposal", shallow(p)); + broadcast("order", shallow(p)); } } else { try { @@ -793,7 +921,7 @@ setInterval(async () => { } } - p = await db.Proposal.findOne( + p = await db.Order.findOne( { where: { user_id: user.id, @@ -822,15 +950,16 @@ setInterval(async () => { v2: amount * SATS }; - if (p) { + if (p && p.acc1.balance >= p.v1) { p.v1 = params.v1; + p.v2 = params.v2; await p.save(); p = p.get({ plain: true }); p.a1 = params.a1; p.a2 = params.a2; p.rate = p.v2 / p.v1; - broadcast("proposal", shallow(p)); + broadcast("order", shallow(p)); } else { try { await swap(user, params); diff --git a/swaps.js b/swaps.js new file mode 100644 index 00000000..89fc551a --- /dev/null +++ b/swaps.js @@ -0,0 +1,876 @@ +const axios = require("axios"); +const crypto = require("crypto"); +const level = require("level"); +const fs = require("fs"); +const { spawn } = require("child_process"); +const leveldb = level("leveldb"); +const getAccount = require("../lib/account"); +const { Op, col } = require("sequelize"); +const shallow = a => { + let b = {}; + for (x in a) { + if (a[x] instanceof Date || typeof a[x] !== "object") b[x] = a[x]; + } + return b; +}; + +const assets = { + b2e15d0d7a0c94e4e2ce0fe6e8691b9e451377f6e46e8045a86f7c4b5d4f0f23: "bitcoin", + "4eebe36eb0819e6daa5dd3c97733251ff4eb728c810d949365d6dacaad5ef6e8": "tether" +}; + +const swapsdir = "swaps/"; +if (!fs.existsSync(swapsdir)) { + fs.mkdirSync(swapsdir); +} + +const timeout = 20000; + +const cli = (...args) => { + const params = ["--regtest", "-c", config.liquid.conf, ...args]; + if (config.liquid.network !== "regtest") params.shift(); + l.info("spawning liquidswap-cli with params:", params); + return spawn("liquidswap-cli", params); +}; + +const getInfo = filename => + new Promise((resolve, reject) => { + const proc = cli("info", swapsdir + filename); + + proc.stdout.on("data", data => { + resolve(data.toString()); + }); + + proc.stderr.on("data", err => { + l.error("info error", err.toString()); + reject(err.toString()); + }); + + proc.on("close", (code, signal) => { + let msg = (code && code.toString()) || (signal && signal.toString()); + reject(new Error(`Liquid swap tool process closed unexpectedly: ${msg}`)); + }); + + proc.on("exit", (code, signal) => { + let msg = (code && code.toString()) || (signal && signal.toString()); + reject(new Error(`Liquid swap tool process exited unexpectedly: ${msg}`)); + }); + + setTimeout( + () => reject(new Error("Liquid swap tool timed out"), proc), + timeout + ); + }); + +const swap = async (user, { a1, a2, v1, v2 }) => { + let rate = v2 / v1; + + const b = await lq.getBalance(); + + Object.keys(b).map(asset => { + assets[asset] = asset; + }); + + assets[config.liquid.btcasset] = "bitcoin"; + assets["ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2"] = + "tether"; + assets["a0c358a0f6947864af3a06f3f6a2aeb304df7fd95c922f2f22d7412399ce7691"] = + "adamcoin"; + + if (!assets[a1]) throw new Error("unsupported asset"); + if (v1 > Math.round(b[assets[a1]] * SATS)) + throw new Error(`insufficient server funds, ${v1} ${b[assets[a1]]}`); + + await db.transaction(async transaction => { + let a1acc = await getAccount(a1, user, transaction); + let a2acc = await getAccount(a2, user, transaction); + + if (v1 > a1acc.balance) + throw new Error(`insufficient funds, ${v1} ${a1acc.balance}`); + + l.info( + `trade ${v1} ${a1.substr(0, 6)} for ${v2} ${a2.substr(0, 6)}`, + user.username + ); + + await a1acc.decrement({ balance: v1 }, { transaction }); + await a1acc.reload({ transaction }); + + let payment = await db.Payment.create( + { + hash: "Trade Funding", + amount: -v1, + account_id: a1acc.id, + user_id: user.id, + currency: user.currency, + rate: app.get("rates")[user.currency], + confirmed: true, + received: false, + network: "COINOS" + }, + { transaction } + ); + + emit(user.username, "payment", payment.get({ plain: true })); + emit(user.username, "account", a1acc.get({ plain: true })); + + const orders = await db.Order.findAll( + { + where: { + "$acc1.asset$": a2, + "$acc2.asset$": a1, + rate: { [Op.lte]: 1/rate }, + accepted: false + }, + order: [ + ["rate", "ASC"], + ["id", "ASC"] + ], + include: [ + { + model: db.User, + as: "user" + }, + { + model: db.Account, + as: "acc1" + }, + { + model: db.Account, + as: "acc2" + } + ] + }, + { transaction } + ); + + for (let i = 0; i < orders.length; i++) { + if (!v1) break; + p = orders[i]; + if (p.v2 > v1) { + p.v2 -= v1; + await p.save({ transaction }); + + let payment = await db.Payment.create( + { + hash: "Trade Fill", + amount: v2, + account_id: p.a1_id, + user_id: p.user_id, + currency: p.user.currency, + rate: app.get("rates")[p.user.currency], + confirmed: true, + received: true, + network: "COINOS" + }, + { transaction } + ); + + await p.acc1.increment({ balance: v2 }, { transaction }); + await p.acc1.reload({ transaction }); + + emit(p.user.username, "payment", payment.get({ plain: true })); + emit(p.user.username, "account", p.acc1.get({ plain: true })); + + payment = await db.Payment.create( + { + hash: "Trade Fill", + amount: v1, + account_id: a1acc.id, + user_id: user.id, + currency: user.currency, + rate: app.get("rates")[user.currency], + confirmed: true, + received: true, + network: "COINOS" + }, + { transaction } + ); + + a1acc.increment({ balance: v1 }, { transaction }); + await a1acc.reload({ transaction }); + + emit(user.username, "payment", payment.get({ plain: true })); + emit(user.username, "account", a1acc.get({ plain: true })); + + if (p.fee) { + const btc = await getAccount( + config.liquid.btcasset, + p.user, + transaction + ); + + payment = await db.Payment.create( + { + hash: "Swap Fee Refund", + amount: p.fee, + account_id: btc.id, + user_id: p.user_id, + currency: p.user.currency, + rate: app.get("rates")[p.user.currency], + confirmed: true, + received: true, + network: "COINOS" + }, + { transaction } + ); + + await btc.increment({ balance: p.fee }, { transaction }); + await btc.reload({ transaction }); + + emit(p.user.username, "account", btc.get({ plain: true })); + } + + emit(p.user.username, "payment", payment.get({ plain: true })); + + p = p.get({ plain: true }); + p.a1 = p.acc1.asset; + p.a2 = p.acc2.asset; + broadcast("order", shallow(p)); + v1 = 0; + } else { + v1 -= p.v2; + v2 = Math.round(v1 * rate); + + p.accepted = true; + p.completedAt = new Date(); + + await p.save({ transaction }); + + let payment = await db.Payment.create( + { + hash: "Trade Fill", + amount: p.v1, + account_id: a2acc.id, + user_id: user.id, + currency: user.currency, + rate: app.get("rates")[user.currency], + confirmed: true, + received: true, + network: "COINOS" + }, + { transaction } + ); + + await a2acc.increment({ balance: p.v1 }, { transaction }); + await a2acc.reload({ transaction }); + + emit(user.username, "payment", payment.get({ plain: true })); + emit(user.username, "account", a2acc.get({ plain: true })); + + payment = await db.Payment.create( + { + hash: "Trade Fill", + amount: p.v2, + account_id: p.a2_id, + user_id: p.user_id, + currency: p.user.currency, + rate: app.get("rates")[p.user.currency], + confirmed: true, + received: true, + network: "COINOS" + }, + { transaction } + ); + + emit(p.user.username, "payment", payment.get({ plain: true })); + await p.acc2.increment({ balance: p.v2 }, { transaction }); + await p.acc2.reload({ transaction }); + + p = p.get({ plain: true }); + p.a1 = p.acc1.asset; + p.a2 = p.acc2.asset; + broadcast("order", shallow(p)); + } + } + + let order; + if (v1) { + order = await db.Order.create( + { + v1, + v2, + user_id: user.id, + a1_id: a1acc.id, + a2_id: a2acc.id + }, + { transaction } + ); + + const text = await new Promise((resolve, reject) => { + l.info( + "running liquid-cli tool %s %s %s %s %s", + config.liquid.conf, + a1, + v1, + a2, + v2 + ); + const proc = cli( + "propose", + a1, + (v1 / SATS).toFixed(8), + a2, + (v2 / SATS).toFixed(8) + ); + + proc.stdout.on("data", data => { + resolve(data.toString()); + }); + + proc.stderr.on("error", err => { + l.error("order error", err.toString()); + reject(err.toString()); + }); + + proc.on("close", (code, signal) => { + let msg = (code && code.toString()) || (signal && signal.toString()); + reject( + new Error(`Liquid swap tool process closed unexpectedly: ${msg}`) + ); + }); + + proc.on("exit", (code, signal) => { + let msg = (code && code.toString()) || (signal && signal.toString()); + reject( + new Error(`Liquid swap tool process exited unexpectedly: ${msg}`) + ); + }); + + setTimeout( + () => reject(new Error("Liquid swap tool timed out"), proc), + timeout + ); + }); + + const filename = `order-${order.id}.txt`; + + fs.writeFileSync(swapsdir + filename, text); + let info = JSON.parse(await getInfo(filename)); + let [fee, rate, asset] = parse(info); + + const btc = await getAccount(config.liquid.btcasset, user, transaction); + fee = Math.round(fee * SATS); + await btc.decrement({ balance: fee }, { transaction }); + await btc.save({ transaction }); + emit(user.username, "account", btc.get({ plain: true })); + + let payment = await db.Payment.create( + { + hash: "Swap Fee Deposit", + amount: -fee, + account_id: btc.id, + user_id: user.id, + currency: user.currency, + rate: app.get("rates")[user.currency], + confirmed: true, + received: false, + network: "COINOS" + }, + { transaction } + ); + + emit(user.username, "payment", payment.get({ plain: true })); + + order.fee = fee; + order.text = text; + await order.save({ transaction }); + + order = order.get({ plain: true }); + order.rate = v2 / v1; + order.a1 = a1; + order.a2 = a2; + + broadcast("order", order); + } + }); +}; + +const cancel = async (user, id) => { + await db.transaction(async transaction => { + let order = await db.Order.findOne( + { + where: { + id, + user_id: user.id + }, + include: { + model: db.Account, + as: "acc1" + } + }, + + { transaction } + ); + + let { acc1: account, v1, v2 } = order; + await account.increment({ balance: v1 }, { transaction }); + await account.reload({ transaction }); + + let rate = v2 / v1; + let payment = await db.Payment.create( + { + hash: "Trade Cancelled", + amount: v1, + account_id: account.id, + user_id: user.id, + currency: user.currency, + rate: app.get("rates")[user.currency], + confirmed: true, + received: true, + network: "COINOS" + }, + { transaction } + ); + + emit(user.username, "account", account.get({ plain: true })); + emit(user.username, "payment", payment.get({ plain: true })); + + if (order.fee) { + const btc = await getAccount(config.liquid.btcasset, user, transaction); + + payment = await db.Payment.create( + { + hash: "Swap Fee Refund", + amount: order.fee, + account_id: btc.id, + user_id: user.id, + currency: user.currency, + rate: app.get("rates")[user.currency], + confirmed: true, + received: true, + network: "COINOS" + }, + { transaction } + ); + + await btc.increment({ balance: order.fee }, { transaction }); + await btc.reload({ transaction }); + + emit(user.username, "account", btc.get({ plain: true })); + emit(user.username, "payment", payment.get({ plain: true })); + } + + broadcast("removeOrder", id); + await order.destroy({ transaction }); + }); +}; + +app.delete( + "/order/:id", + auth, + ah(async (req, res) => { + const { id } = req.params; + const { user } = req; + + await cancel(user, id); + res.end(); + }) +); + +app.post( + "/orders", + auth, + ah(async (req, res) => { + const { user } = req; + try { + await swap(user, req.body); + res.end(); + } catch (e) { + l.error(req.user.username, e.message, e.stack); + res.status(500).send(e.message); + } + }) +); + +app.post( + "/accept", + optionalAuth, + ah(async (req, res) => { + try { + const { id, text } = req.body; + + let order = await db.Order.findOne({ + where: { + id + }, + include: { + model: db.User, + as: "user" + } + }); + + if (!order) throw new Error("Order not found"); + + const { user } = req; + + if (text) { + const filename = `acceptance-${id}.txt`; + fs.writeFileSync(swapsdir + filename, text); + + let info = JSON.parse(await getInfo(filename)); + let [fee, rate, asset] = parse(info); + let { tx, u_address_p, u_address_r } = JSON.parse( + Buffer.from(text, "base64").toString() + ); + const sha256 = crypto.createHash("sha256"); + sha256.update(tx); + const hash = sha256.digest("hex"); + + const leg1 = info.legs.find(leg => !leg.incoming); + const leg2 = info.legs.find(leg => leg.incoming); + + await db.transaction(async transaction => { + const l2a2 = await getAccount(leg2.asset, order.user, transaction); + + let amount = Math.round(leg1.amount * SATS); + fee = Math.round(fee * SATS); + + amount = Math.round(leg2.amount * SATS); + + await db.Invoice.create({ + user_id: order.user_id, + text: u_address_p, + address: u_address_p, + unconfidential: u_address_p, + currency: order.user.currency, + memo: "Atomic Swap", + rate: app.get("rates")[order.user.currency], + amount, + tip: 0, + network: "liquid", + account_id: l2a2.id + }); + + addresses[u_address_p] = order.user.username; + + await finalize(filename); + + order.accepted = true; + order.completedAt = new Date(); + await order.save({ transaction }); + + order = order.get({ plain: true }); + order.a1 = leg1.asset; + order.a2 = leg2.asset; + broadcast("order", shallow(order)); + }); + + res.send(info); + } else { + throw new Error("No acceptance provided"); + } + } catch (e) { + l.error(e.message); + res.status(500).send(e.message); + } + }) +); + +app.post( + "/acceptance", + ah(async (req, res) => { + const { acceptance: text } = req.body; + l.info("acceptance received"); + + fs.writeFile(swapsdir + "accepted.txt", text, async err => { + if (err) { + return res.status(500).send(err); + } + + try { + let info = JSON.parse( + await new Promise((resolve, reject) => { + const proc = cli("info", swapsdir + "accepted.txt"); + + proc.stdout.on("data", data => { + resolve(data.toString()); + }); + + proc.stderr.on("data", err => { + reject(err.toString()); + }); + + setTimeout( + () => reject(new Error("Liquid swap tool timed out"), proc), + timeout + ); + }) + ); + + const time = Math.floor(new Date()).toString(); + const [fee, rate, asset] = parse(info); + if (!asset) throw new Error("Unsupported asset"); + l.info("accepted", info, rate, asset); + + leveldb.put(time, JSON.stringify({ text, info, rate, asset })); + l.info({ text, info, rate, asset }); + + res.send({ info, rate }); + } catch (e) { + l.error(e); + res.status(500).send(e.message); + } + }); + }) +); + +checkQueue = async () => { + const ws = app.get("ws"); + const txs = []; + + leveldb + .createReadStream() + .on("data", async function(data) { + txs.push({ ...JSON.parse(data.value), key: data.key }); + }) + .on("end", async function(data) { + const rate = (a, b) => a.rate - b.rate; + const time = (a, b) => b.time - a.time; + + const pending = txs.filter(tx => !tx.id); + const completed = txs.filter(tx => tx.id).sort(time); + + const bitcoin = pending + .filter(tx => tx.asset === "bitcoin") + .sort(rate) + .reverse(); + const tether = pending + .filter(tx => tx.asset === "tether") + .sort(rate) + .reverse(); + + const strip = a => + a.map(({ id, key, info, rate, time }) => ({ + id, + key, + info, + rate, + time + })); + + ws && + ws.send( + JSON.stringify({ + completed: strip(completed.slice(0, 3)), + bitcoin: strip(bitcoin.slice(0, 3)), + tether: strip(tether.slice(0, 3)) + }) + ); + + let b, t, tx; + if (bitcoin[0]) b = bitcoin[0].rate - app.get("ask"); + if (tether[0]) t = tether[0].rate - 1 / app.get("bid"); + + if (b > 0 || t > 0) { + if ((b && !t) || b > t) tx = bitcoin[0]; + if ((t && !b) || t > b) tx = tether[0]; + } + + if (tx) { + try { + tx.id = JSON.parse(await finalize(tx.text)).txid; + tx.time = Date.now(); + delete tx.text; + leveldb.put(tx.key, JSON.stringify(tx)); + } catch (e) { + l.info(e); + if (e.includes("Unexpected fees")) leveldb.del(tx.key); + if (e.includes("unsigned inputs")) leveldb.del(tx.key); + if (e.includes("insufficient fee")) leveldb.del(tx.key); + } + } + }); +}; + +const parse = info => { + const fee = info.legs[0].fee; + return [ + fee, + (info.legs[1].amount / (info.legs[0].amount + fee)).toFixed(8), + assets[info.legs[0].asset] + ]; +}; + +const finalize = (filename = "finalized.txt", text) => { + if (text) fs.writeFileSync(swapsdir + filename, text); + + return new Promise((resolve, reject) => { + const proc = cli("finalize", swapsdir + filename, "-s"); + + proc.stdout.on("data", data => { + resolve(data.toString()); + }); + + proc.stderr.on("data", err => { + reject(new Error(err.toString())); + }); + + proc.on("close", (code, signal) => { + let msg = (code && code.toString()) || (signal && signal.toString()); + reject(new Error(`Liquid swap tool process closed unexpectedly: ${msg}`)); + }); + + proc.on("exit", (code, signal) => { + let msg = (code && code.toString()) || (signal && signal.toString()); + reject(new Error(`Liquid swap tool process exited unexpectedly: ${msg}`)); + }); + + setTimeout( + () => reject(new Error("Liquid swap tool timed out"), proc), + timeout + ); + }); +}; + +app.get( + "/orders", + optionalAuth, + ah(async (req, res) => { + try { + let orders = await db.Order.findAll({ + attributes: { + include: [ + [col("acc1.asset"), "a1"], + [col("acc2.asset"), "a2"] + ] + }, + include: [ + { + model: db.Account, + as: "acc1", + attributes: [] + }, + { + model: db.Account, + as: "acc2", + attributes: [] + } + ] + }); + + res.send( + orders.map(p => { + if (p.user_id !== req.user.id) p.user_id = null; + return p; + }) + ); + } catch (e) { + res.status(500).send(e.message); + } + }) +); + +app.post( + "/publish", + auth, + ah(async (req, res) => { + try { + const { id } = req.body; + await db.Order.update( + { public: true }, + { where: { id, user_id: req.user.id } } + ); + res.end(); + } catch (e) { + res.status(500).send(e.message); + } + }) +); + +setInterval(async () => { + if (!app.get("rates")) return; + const amount = 0.01; + const btc = "5ac9f65c0efcc4775e0baec4ec03abdde22473cd3cf33c0419ca290e0751b225"; + const lcad = "e749f7326d0ba155ec1d878e23458bc3f740a38bf91bbce955945ee10d6ab523"; + const { CAD, USD } = app.get("rates"); + const user = await db.User.findOne({ + where: { + username: "maker" + } + }); + + await db.transaction(async transaction => { + let p = await db.Order.findOne( + { + where: { + user_id: user.id, + "$acc1.asset$": btc, + accepted: false + }, + include: [ + { + model: db.Account, + as: "acc1" + } + ] + }, + { transaction } + ); + + let params = { + a1: btc, + a2: lcad, + v1: amount * SATS, + v2: Math.round(amount * SATS * (((app.get("ask") * CAD) / USD) * 1.01)) + }; + + if (p && p.acc1.balance > p.v1) { + p.v1 = params.v1; + p.v2 = params.v2; + await p.save(); + p = p.get({ plain: true }); + p.a1 = params.a1; + p.a2 = params.a2; + + broadcast("order", shallow(p)); + } else { + try { + await swap(user, params); + } catch(e) { + l.error("Failed to make ask", e.message); + } + } + + p = await db.Order.findOne( + { + where: { + user_id: user.id, + "$acc1.asset$": lcad, + accepted: false + }, + include: [ + { + model: db.Account, + as: "acc1" + } + ] + }, + { transaction } + ); + + params = { + a1: lcad, + a2: btc, + v1: Math.round(amount * SATS * ((app.get("bid") * CAD) / USD) * 0.99), + v2: amount * SATS + } + + if (p && p.acc1.balance > p.v1) { + p.v1 = params.v1; + p.v2 = params.v2; + await p.save(); + p = p.get({ plain: true }); + p.a1 = params.a1; + p.a2 = params.a2; + + broadcast("order", shallow(p)); + } else { + try { + await swap(user, params); + } catch(e) { + l.warn("Failed to make bid", e.message); + } + } + }); +}, 6000); -- cgit v1.2.3