diff --git a/package.json b/package.json index 30a0ec8..5654314 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "@docusaurus/preset-classic": "^3.4.0", "@mdx-js/react": "^3.0.0", "clsx": "^2.0.0", + "echarts": "^5.5.1", + "echarts-for-react": "^3.0.2", "prism-react-renderer": "^2.3.0", "react": "^18.0.0", "react-dom": "^18.0.0", diff --git a/src/components/Charts/BatchBar/bar.tsx b/src/components/Charts/BatchBar/bar.tsx new file mode 100644 index 0000000..0e2a0af --- /dev/null +++ b/src/components/Charts/BatchBar/bar.tsx @@ -0,0 +1,33 @@ + +import * as echarts from 'echarts'; +import { useEffect, useState } from 'react'; +import ReactECharts from 'echarts-for-react'; + +export default function BatchBar({unbatchedVbytes, batchedVbytes, payjoinVbytes}: {unbatchedVbytes: number, batchedVbytes: number, payjoinVbytes: number}): JSX.Element { + const [option, setOption] = useState(undefined); + + useEffect(() => { + setOption({ + xAxis: { + type: 'category', + data: ['Unbatched', 'Batched', 'Payjoin'] + }, + yAxis: { + type: 'value' + }, + series: [ + { + data: [ + {value: unbatchedVbytes, itemStyle: {color: '#ffe751'}}, + {value: batchedVbytes, itemStyle: {color: '#81e86a'}}, + {value: payjoinVbytes, itemStyle: {color: '#ff6f6f'}} + ], + type: 'bar' + } + ] + }); + }, [unbatchedVbytes, batchedVbytes, payjoinVbytes]); + + return option && ; +} + \ No newline at end of file diff --git a/src/components/Charts/chart.tsx b/src/components/Charts/chart.tsx new file mode 100644 index 0000000..7cdac35 --- /dev/null +++ b/src/components/Charts/chart.tsx @@ -0,0 +1,63 @@ + +import React, { useRef, useEffect } from "react"; +import { init, getInstanceByDom } from "echarts"; +import type { CSSProperties } from "react"; +import type { EChartsOption, ECharts, SetOptionOpts } from "echarts"; + +export interface ReactEChartsProps { + option: EChartsOption; + style?: CSSProperties; + settings?: SetOptionOpts; + loading?: boolean; + theme?: "light" | "dark"; +} + +export function Chart({ + option, + style, + settings, + loading, + theme, +}: ReactEChartsProps): JSX.Element { + const chartRef = useRef(null); + + useEffect(() => { + // Initialize chart + let chart: ECharts | undefined; + if (chartRef.current !== null) { + chart = init(chartRef.current, theme); + } + + // Add chart resize listener + // ResizeObserver is leading to a bit janky UX + function resizeChart() { + chart?.resize(); + } + window.addEventListener("resize", resizeChart); + + // Return cleanup function + return () => { + chart?.dispose(); + window.removeEventListener("resize", resizeChart); + }; + }, [theme]); + + useEffect(() => { + // Update chart + if (chartRef.current !== null) { + const chart = getInstanceByDom(chartRef.current); + chart.setOption(option, settings); + } + }, [option, settings, theme]); // Whenever theme changes we need to add option and setting due to it being deleted in cleanup function + + useEffect(() => { + // Update chart + if (chartRef.current !== null) { + const chart = getInstanceByDom(chartRef.current); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + loading === true ? chart.showLoading() : chart.hideLoading(); + } + }, [loading, theme]); + + return
; +} \ No newline at end of file diff --git a/src/pages/savings-calculator.tsx b/src/pages/savings-calculator.tsx new file mode 100644 index 0000000..2cdd3c8 --- /dev/null +++ b/src/pages/savings-calculator.tsx @@ -0,0 +1,121 @@ +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; +import Layout from "@theme/Layout"; +import { getVbytesForEachTxType, ScriptType, } from "../utils/tx"; +import { useEffect, useState } from "react"; +import BatchBar from "../components/Charts/BatchBar/bar"; + +export default function SavingsCalculator(): JSX.Element { + const { siteConfig } = useDocusaurusContext(); + const [inputScript, setInputScript] = useState(ScriptType.P2WPKH); // we assume both inputs and outputs are of same script type + const [inputCount, setInputCount] = useState(1); + const [outputCount, setOutputCount] = useState(1); + const [recipientCount, setRecipientCount] = useState(1); + const [payjoinRecipientInputCount, setPayjoinRecipientInputCount] = useState(1); + const [depositorInputCount, setDepositorInputCount] = useState(1); + const [depositorOutputCount, setDepositorOutputCount] = useState(1); + const [isDisabled, setIsDisabled] = useState(true); + const [unbatchedVbytes, setUnbatchedVbytes] = useState(0); + const [batchedVbytes, setBatchedVbytes] = useState(0); + const [payjoinVbytes, setPayjoinVbytes] = useState(0); + + const scriptTypes = [ + { value: ScriptType.P2PKH, label: "P2PKH" }, + { value: ScriptType.P2WPKH, label: "P2WPKH" }, + { value: ScriptType.P2TR, label: "P2TR"} + ] + + function isInvalid() { + return [inputCount, outputCount, recipientCount].some((value) => isNaN(value) || value < 1); + } + + function handleSubmit() { + if (isInvalid()) { + alert("Please enter a valid number greater than 0."); + return; + } + const { vbytesUnbatched, vbytesBatched, vbytesPayjoined } = getVbytesForEachTxType(inputScript, inputCount, outputCount, recipientCount, payjoinRecipientInputCount, depositorInputCount, depositorOutputCount); + setUnbatchedVbytes(vbytesUnbatched); + setBatchedVbytes(vbytesBatched); + setPayjoinVbytes(vbytesPayjoined); + } + + useEffect(() => { + setIsDisabled(isInvalid()); + }, [inputScript, inputCount, outputCount, recipientCount]); + + return ( + +
+ Payjoin provides a unique opportunity for receiver-side savings. +
+
+
+
+
+ +
+ +
+
+
+ +
+ setInputCount(parseInt(e.target.value))} /> +
+
+
+ +
+ setOutputCount(parseInt(e.target.value))} /> +
+
+
+ +
+ setRecipientCount(parseInt(e.target.value))} /> +
+
+
+ +
+ setPayjoinRecipientInputCount(parseInt(e.target.value))} /> +
+
+
+ +
+ setDepositorInputCount(parseInt(e.target.value))} /> +
+
+
+ +
+ setDepositorOutputCount(parseInt(e.target.value))} /> +
+
+


+ {/* Transaction size in raw bytes:
*/} + Transaction size in virtual bytes without batching: {unbatchedVbytes}
+ Transaction size in virtual bytes with batching: {batchedVbytes}
+ Transaction size in virtual bytes with payjoin: {payjoinVbytes}
+ {/* Transaction size in weight units:

*/} + {/*

Which size should you use for calculating fee estimates?
+ Estimates should be in satoshis per virtual byte.

*/} +
+
+
+
+ +
+
+
+
+ ); +} diff --git a/src/utils/tx.ts b/src/utils/tx.ts new file mode 100644 index 0000000..2bf16ba --- /dev/null +++ b/src/utils/tx.ts @@ -0,0 +1,106 @@ +// From: https://bitcoinops.org/en/tools/calc-size/ +const P2PKH_IN_SIZE = 148; +const P2PKH_OUT_SIZE = 34; +const P2PKH_OVERHEAD = 10; +const P2WPKH_IN_SIZE = 68; +const P2WPKH_OUT_SIZE = 31; +const P2WPKH_OVERHEAD = 10.5; +const P2TR_IN_SIZE = 57.5; +const P2TR_OUT_SIZE = 43; +const P2TR_OVERHEAD = 10.5; + +export enum ScriptType { + P2PKH = "P2PKH", + P2WPKH = "P2WPKH", + P2TR = "P2TR", +} + +// See definitions here: https://gist.github.com/thebrandonlucas/fb4283bef3df51b88a85ae974488d81f +enum TxType { + Standard = "Standard", + Batch = "Batch", + Payjoin = "Payjoin", +} + +// Variables: +// b = base cost +// i = per input cost +// o = per output cost +// r = recipient count +// p = recipient input count (payjoin only) +// di = depositor input count (payjoin only) +// do = depositor output count (payjoin only) + +// total tx cost without batching: r(b + i) + 2ro +// total tx cost with batching: b + i + ro + o +// total tx cost with payjoin: b + p(i) + di(i) + ro + do(o) + o +const totalCost = (b: number, i: number, o: number, r: number, type: TxType, p?: number, di?: number, _do?: number) => { + switch (type) { + case TxType.Standard: + return r * (b + i) + 2 * r * o; + case TxType.Batch: + return b + i + r * o + o; + case TxType.Payjoin: + if (!p || !di || !_do) { + throw new Error("Payjoin requires recipient input count, depositor input count, and depositor output count"); + } + return b + p * i + di * i + r * o + _do * o + o; + } +} + + + +// TODO: payjoin recipient/cut-through formula + +function getBaseCost(inputScript: ScriptType) { + switch (inputScript) { + case ScriptType.P2PKH: + return P2PKH_OVERHEAD; + case ScriptType.P2WPKH: + return P2WPKH_OVERHEAD; + case ScriptType.P2TR: + return P2TR_OVERHEAD; + } +} + +function getPerInputCost(inputScript: ScriptType) { + switch (inputScript) { + case ScriptType.P2PKH: + return P2PKH_IN_SIZE; + case ScriptType.P2WPKH: + return P2WPKH_IN_SIZE; + case ScriptType.P2TR: + return P2TR_IN_SIZE; + } +} + +function getPerOutputCost(inputScript: ScriptType) { + switch (inputScript) { + case ScriptType.P2PKH: + return P2PKH_OUT_SIZE; + case ScriptType.P2WPKH: + return P2WPKH_OUT_SIZE; + case ScriptType.P2TR: + return P2TR_OUT_SIZE; + } +} + +function getVbytes(script: ScriptType, inputCount: number, outputCount: number, recipientCount: number, + type: TxType, payjoinRecipientInputCount?: number, depositorInputCount?: number, depositorOutputCount?: number) { + const perInputCost = getPerInputCost(script) * inputCount; + const perOutputCost = getPerOutputCost(script) * outputCount; + const baseCost = getBaseCost(script); + const vbytes = totalCost(baseCost, perInputCost, perOutputCost, recipientCount, type, payjoinRecipientInputCount, depositorInputCount, depositorOutputCount); + console.log({ baseCost, perInputCost, perOutputCost, recipientCount, type, vbytes }); + + return vbytes; +} + +export function getVbytesForEachTxType(script: ScriptType, inputCount: number, outputCount: number, recipientCount: number, payjoinRecipientInputCount: number, depositorInputCount: number, depositorOutputCount: number) { + const vbytesUnbatched = getVbytes(script, inputCount, outputCount, recipientCount, TxType.Standard); + const vbytesBatched = getVbytes(script, inputCount, outputCount, recipientCount, TxType.Batch); + const vbytesPayjoined = getVbytes(script, inputCount, outputCount, recipientCount, TxType.Payjoin, payjoinRecipientInputCount, depositorInputCount, depositorOutputCount); + + // console.log({vbytesBatched, vbytesUnbatched, vbytesPayjoined}) + return { vbytesBatched, vbytesUnbatched, vbytesPayjoined }; +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 8705bf3..1cfa4e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4632,6 +4632,22 @@ eastasianwidth@^0.2.0: resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== +echarts-for-react@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/echarts-for-react/-/echarts-for-react-3.0.2.tgz#ac5859157048a1066d4553e34b328abb24f2b7c1" + integrity sha512-DRwIiTzx8JfwPOVgGttDytBqdp5VzCSyMRIxubgU/g2n9y3VLUmF2FK7Icmg/sNVkv4+rktmrLN9w22U2yy3fA== + dependencies: + fast-deep-equal "^3.1.3" + size-sensor "^1.0.1" + +echarts@^5.5.1: + version "5.5.1" + resolved "https://registry.yarnpkg.com/echarts/-/echarts-5.5.1.tgz#8dc9c68d0c548934bedcb5f633db07ed1dd2101c" + integrity sha512-Fce8upazaAXUVUVsjgV6mBnGuqgO+JNDlcgF79Dksy4+wgGpQB2lmYoO4TSweFg/mZITdpGHomw/cNBJZj1icA== + dependencies: + tslib "2.3.0" + zrender "5.6.0" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -8644,6 +8660,11 @@ sitemap@^7.1.1: arg "^5.0.0" sax "^1.2.4" +size-sensor@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/size-sensor/-/size-sensor-1.0.2.tgz#b8f8da029683cf2b4e22f12bf8b8f0a1145e8471" + integrity sha512-2NCmWxY7A9pYKGXNBfteo4hy14gWu47rg5692peVMst6lQLPKrVjhY+UTEsPI5ceFRJSl3gVgMYaUi/hKuaiKw== + skin-tone@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/skin-tone/-/skin-tone-2.0.0.tgz#4e3933ab45c0d4f4f781745d64b9f4c208e41237" @@ -9072,6 +9093,11 @@ ts-interface-checker@^0.1.9: resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== +tslib@2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e" + integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg== + tslib@^2.0.3, tslib@^2.6.0: version "2.6.2" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" @@ -9592,6 +9618,13 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== +zrender@5.6.0: + version "5.6.0" + resolved "https://registry.yarnpkg.com/zrender/-/zrender-5.6.0.tgz#01325b0bb38332dd5e87a8dbee7336cafc0f4a5b" + integrity sha512-uzgraf4njmmHAbEUxMJ8Oxg+P3fT04O+9p7gY+wJRVxo8Ge+KmYv0WJev945EH4wFuc4OY2NLXz46FZrWS9xJg== + dependencies: + tslib "2.3.0" + zwitch@^2.0.0: version "2.0.4" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7"