From fb7256a4b9e6a1e056a63d4326e113f62d955ad9 Mon Sep 17 00:00:00 2001 From: Kodemon Date: Thu, 18 May 2023 16:30:05 +0900 Subject: [PATCH] feat(offer): add offer transaction fee --- package.json | 2 +- src/Entities/Offer.ts | 27 ++++++-- src/Libraries/Bitcoin.ts | 11 ++++ src/Orderbook/Resolver.ts | 22 ++++--- src/Orderbook/Utilities/PSBT.ts | 61 +++++++++++++++++++ .../{Utilities.ts => Utilities/index.ts} | 17 ++++-- src/Orderbook/Validator/Offer/Format/PSBT.ts | 28 +-------- src/Services/Lookup.ts | 10 ++- 8 files changed, 132 insertions(+), 46 deletions(-) create mode 100644 src/Orderbook/Utilities/PSBT.ts rename src/Orderbook/{Utilities.ts => Utilities/index.ts} (88%) diff --git a/package.json b/package.json index d867916..195ef19 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build": "tsc", "start": "DEBUG=sado-* ts-node-dev ./src/main.ts", "clean": "rm -rf dist", - "lint": "eslint --ext .ts src" + "lint": "eslint --fix --ext .ts src" }, "dependencies": { "@fastify/cors": "^8.2.1", diff --git a/src/Entities/Offer.ts b/src/Entities/Offer.ts index 2eb5537..549ac8a 100644 --- a/src/Entities/Offer.ts +++ b/src/Entities/Offer.ts @@ -5,9 +5,10 @@ import { Network } from "../Libraries/Network"; import { PriceList } from "../Libraries/PriceList"; import { getAddressVoutValue } from "../Libraries/Transaction"; import { IPFSLookupFailed } from "../Orderbook/Exceptions/GeneralExceptions"; -import { getAskingPrice } from "../Orderbook/Utilities"; +import { getAskingPrice, utils } from "../Orderbook/Utilities"; import { validator } from "../Orderbook/Validator"; import { ipfs } from "../Services/IPFS"; +import { Lookup } from "../Services/Lookup"; import { db } from "../Services/Mongo"; import { IPFSOffer, IPFSOrder } from "./IPFS"; import { Inscription, Ordinal, Transaction, Vout } from "./Transaction"; @@ -59,6 +60,11 @@ export class Offer { */ readonly time: OfferTime; + /** + * Estimated fee in satoshis for the offer transaction. + */ + readonly fee?: number; + /** * Vout containing the ordinals and inscription array. */ @@ -83,6 +89,7 @@ export class Offer { this.offer = document.offer; this.value = document.value; this.time = document.time; + this.fee = document.fee; this.vout = document.vout; this.proof = document.proof; this.rejection = document.rejection; @@ -94,7 +101,7 @@ export class Offer { |-------------------------------------------------------------------------------- */ - static async insert(tx: Transaction): Promise { + static async insert(tx: Transaction, lookup: Lookup): Promise { const offer = await ipfs.getOffer(tx.cid); if ("error" in offer) { await collection.insertOne(makeRejectedOffer(tx, new IPFSLookupFailed(tx.txid, offer.error, offer.data))); @@ -105,7 +112,8 @@ export class Offer { await collection.insertOne(makeRejectedOffer(tx, new IPFSLookupFailed(tx.txid, order.error, order.data), offer)); return; } - const result = await collection.insertOne(makePendingOffer(tx, order, offer)); + const fee = await getTransactioFee(offer.offer, lookup); + const result = await collection.insertOne(makePendingOffer(tx, order, offer, fee)); if (result.acknowledged === true) { return this.findById(result.insertedId); } @@ -198,6 +206,7 @@ export class Offer { }, ago: moment(this.tx.blocktime).fromNow(), // [TODO] Deprecate in favor of `time.ago`. value: new PriceList(this.value), + fee: new PriceList(this.fee), order: { ...this.order, price: new PriceList(getAskingPrice(this.order)), @@ -231,7 +240,7 @@ export class Offer { |-------------------------------------------------------------------------------- */ -function makePendingOffer(tx: Transaction, order: IPFSOrder, offer: IPFSOffer): OfferDocument { +function makePendingOffer(tx: Transaction, order: IPFSOrder, offer: IPFSOffer, fee?: number): OfferDocument { return { status: "pending", address: tx.from, @@ -243,6 +252,7 @@ function makePendingOffer(tx: Transaction, order: IPFSOrder, offer: IPFSOffer): block: tx.blocktime, offer: offer.ts, }, + fee, tx, }; } @@ -263,6 +273,14 @@ function makeRejectedOffer(tx: Transaction, rejection: any, offer?: IPFSOffer): }; } +async function getTransactioFee(offer: string, lookup: Lookup): Promise { + const psbt = utils.psbt.decode(offer); + if (psbt === undefined) { + return undefined; + } + return utils.psbt.getFee(psbt, lookup); +} + /* |-------------------------------------------------------------------------------- | Document @@ -278,6 +296,7 @@ type OfferDocument = { offer: IPFSOffer; value?: number; time: OfferTime; + fee?: number; vout?: Vout; proof?: string; rejection?: any; diff --git a/src/Libraries/Bitcoin.ts b/src/Libraries/Bitcoin.ts index 58335e6..56e81ec 100644 --- a/src/Libraries/Bitcoin.ts +++ b/src/Libraries/Bitcoin.ts @@ -5,6 +5,17 @@ import { dexPrices } from "../Services/DexPrices"; */ export const BTC_TO_SAT = 100_000_000; +/** + * Convert provided BTC to satoshis. + * + * @param btc - BTC to convert. + * + * @returns Satoshi value. + */ +export function btcToSat(btc: number): number { + return btc * BTC_TO_SAT; +} + /** * Convert provided satoshis to BTC. * diff --git a/src/Orderbook/Resolver.ts b/src/Orderbook/Resolver.ts index 014a9fc..fda3c52 100644 --- a/src/Orderbook/Resolver.ts +++ b/src/Orderbook/Resolver.ts @@ -18,17 +18,23 @@ export async function resolveOrderbookTransactions(address: string, network: Net // ### Add Transactions // For any non-processed transactions in the address, add them to the database. + const result: (Order | Offer)[] = []; + const nextTxs = await addOrderbookTransactions(sadoTxs, address, network); - const result = await Promise.all( - nextTxs.map((tx) => { - if (tx.type === "order") { - return Order.insert(tx); + for (const tx of nextTxs) { + if (tx.type === "order") { + const order = await Order.insert(tx); + if (order !== undefined) { + result.push(order); } - if (tx.type === "offer") { - return Offer.insert(tx); + } + if (tx.type === "offer") { + const offer = await Offer.insert(tx, lookup); + if (offer !== undefined) { + result.push(offer); } - }) - ); + } + } // ### Resolve Pending // Run through pending orders and offers, checking of changes and transitioning diff --git a/src/Orderbook/Utilities/PSBT.ts b/src/Orderbook/Utilities/PSBT.ts new file mode 100644 index 0000000..acf9292 --- /dev/null +++ b/src/Orderbook/Utilities/PSBT.ts @@ -0,0 +1,61 @@ +import * as btc from "bitcoinjs-lib"; + +import { btcToSat } from "../../Libraries/Bitcoin"; +import { Lookup } from "../../Services/Lookup"; + +export const psbt = { + decode, + getFee, +}; + +/** + * Attempt to retrieve a PSBT from the offer string. We try both hex and base64 + * formats as we don't know which one the user will provide. + * + * @param offer - Encoded offer transaction. + * + * @returns The PSBT or undefined if it could not be parsed. + */ +function decode(offer: string): btc.Psbt | undefined { + try { + return btc.Psbt.fromHex(offer); + } catch (err) { + // TODO: Add better check in case the error is not about failure to + // parse the hex. + // not a PSBT hex offer + } + try { + return btc.Psbt.fromBase64(offer); + } catch (err) { + // TODO: Add better check in case the error is not about failure to + // parse the base64. + // not a PSBT base64 offer + } +} + +/** + * Calculate the fee for given PSBT by looking up the input transactions and + * subtracting the output values. + * + * @param psbt - The PSBT to calculate the fee for. + * @param lookup - The lookup service to use to retrieve the input transactions. + * + * @returns The fee in satoshis. + */ +async function getFee(psbt: btc.Psbt, lookup: Lookup): Promise { + let inputSum = 0; + for (const input of psbt.txInputs) { + const hash = input.hash.reverse().toString("hex"); + const tx = await lookup.getTransaction(hash); + if (tx !== undefined) { + inputSum += btcToSat(tx.vout[input.index].value); + } + } + + let outputSum = 0; + for (const output of psbt.txOutputs) { + outputSum += output.value; + } + + return inputSum - outputSum; +} diff --git a/src/Orderbook/Utilities.ts b/src/Orderbook/Utilities/index.ts similarity index 88% rename from src/Orderbook/Utilities.ts rename to src/Orderbook/Utilities/index.ts index 739feee..62b14b7 100644 --- a/src/Orderbook/Utilities.ts +++ b/src/Orderbook/Utilities/index.ts @@ -1,9 +1,14 @@ -import { IPFSOffer, IPFSOrder } from "../Entities/IPFS"; -import { Transaction } from "../Entities/Transaction"; -import { BTC_TO_SAT } from "../Libraries/Bitcoin"; -import { PriceList } from "../Libraries/PriceList"; -import { parseLocation } from "../Libraries/Transaction"; -import { Lookup } from "../Services/Lookup"; +import { IPFSOffer, IPFSOrder } from "../../Entities/IPFS"; +import { Transaction } from "../../Entities/Transaction"; +import { BTC_TO_SAT } from "../../Libraries/Bitcoin"; +import { PriceList } from "../../Libraries/PriceList"; +import { parseLocation } from "../../Libraries/Transaction"; +import { Lookup } from "../../Services/Lookup"; +import { psbt } from "./PSBT"; + +export const utils = { + psbt, +}; /** * Get order item from a vout scriptPubKey utf8 string. diff --git a/src/Orderbook/Validator/Offer/Format/PSBT.ts b/src/Orderbook/Validator/Offer/Format/PSBT.ts index 116f515..eae7da6 100644 --- a/src/Orderbook/Validator/Offer/Format/PSBT.ts +++ b/src/Orderbook/Validator/Offer/Format/PSBT.ts @@ -3,6 +3,7 @@ import * as btc from "bitcoinjs-lib"; import { IPFSOffer, IPFSOrder } from "../../../../Entities/IPFS"; import { parseLocation } from "../../../../Libraries/Transaction"; import { OfferValidationFailed } from "../../../Exceptions/OfferException"; +import { utils } from "../../../Utilities"; /** * Attempt to validate the offer string as a PSBT. If the offer is not a PSBT @@ -16,7 +17,7 @@ import { OfferValidationFailed } from "../../../Exceptions/OfferException"; * @returns `true` if the offer is a valid PSBT, `false` otherwise. */ export function validatePSBT({ offer }: IPFSOffer, { location }: IPFSOrder): boolean { - const psbt = getPsbt(offer); + const psbt = utils.psbt.decode(offer); if (psbt === undefined) { return false; } @@ -24,31 +25,6 @@ export function validatePSBT({ offer }: IPFSOffer, { location }: IPFSOrder): boo return true; } -/** - * Attempt to retrieve a PSBT from the offer string. We try both hex and base64 - * formats as we don't know which one the user will provide. - * - * @param offer - Encoded offer transaction. - * - * @returns The PSBT or undefined if it could not be parsed. - */ -function getPsbt(offer: string): btc.Psbt | undefined { - try { - return btc.Psbt.fromHex(offer); - } catch (err) { - // TODO: Add better check in case the error is not about failure to - // parse the hex. - // not a PSBT hex offer - } - try { - return btc.Psbt.fromBase64(offer); - } catch (err) { - // TODO: Add better check in case the error is not about failure to - // parse the base64. - // not a PSBT base64 offer - } -} - // ### ORDER INPUTS function validateOrderInput(psbt: btc.Psbt, location: string): void { diff --git a/src/Services/Lookup.ts b/src/Services/Lookup.ts index 3c95c49..d733ebd 100644 --- a/src/Services/Lookup.ts +++ b/src/Services/Lookup.ts @@ -2,7 +2,14 @@ import debug from "debug"; import fetch, { RequestInit } from "node-fetch"; import { config } from "../Config"; -import { getTransaction, Inscription, Ordinal, ScriptPubKey, Transaction } from "../Entities/Transaction"; +import { + addTransaction, + getTransaction, + Inscription, + Ordinal, + ScriptPubKey, + Transaction, +} from "../Entities/Transaction"; import { DEFAULT_NETWORK, Network } from "../Libraries/Network"; const log = debug("sado-lookup"); @@ -60,6 +67,7 @@ export class Lookup { } const tx = (await getTransaction(txid, this.network)) ?? (await get("/transaction", { txid }, this.network)); if (tx !== undefined) { + await addTransaction(tx, this.network); this.transactions.set(txid, tx); } return tx;