Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Batching/Payjoin Savings Charts #73

Draft
wants to merge 8 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
33 changes: 33 additions & 0 deletions src/components/Charts/BatchBar/bar.tsx
Original file line number Diff line number Diff line change
@@ -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<echarts.EChartsOption | undefined>(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 && <ReactECharts option={option}/>;
}

63 changes: 63 additions & 0 deletions src/components/Charts/chart.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 <div ref={chartRef} style={{ width: "100%", height: "100px", ...style }} />;
}
121 changes: 121 additions & 0 deletions src/pages/savings-calculator.tsx
Original file line number Diff line number Diff line change
@@ -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>(ScriptType.P2WPKH); // we assume both inputs and outputs are of same script type
const [inputCount, setInputCount] = useState<number>(1);
const [outputCount, setOutputCount] = useState<number>(1);
const [recipientCount, setRecipientCount] = useState<number>(1);
const [payjoinRecipientInputCount, setPayjoinRecipientInputCount] = useState<number>(1);
const [depositorInputCount, setDepositorInputCount] = useState<number>(1);
const [depositorOutputCount, setDepositorOutputCount] = useState<number>(1);
const [isDisabled, setIsDisabled] = useState<boolean>(true);
const [unbatchedVbytes, setUnbatchedVbytes] = useState<number>(0);
const [batchedVbytes, setBatchedVbytes] = useState<number>(0);
const [payjoinVbytes, setPayjoinVbytes] = useState<number>(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 (
<Layout
title={siteConfig.title}
description="Payjoin Savings Calculator"
>
<div className="flex flex-col justify-center mx-24 items-center bg-red">
<span>Payjoin provides a unique opportunity for receiver-side savings.</span>
<div className="flex gap-12">
<form role="form" action="javascript:void(0);">
<div>
<div>
<div>
<label htmlFor="input_script">Input script type</label>
<div >
<select id="input_script" onChange={(e) => setInputScript(e.target.value as ScriptType)} value={inputScript}>
{scriptTypes.map((scriptType) => (
<option key={scriptType.value} value={scriptType.value}>{scriptType.label}</option>
))}
</select>
</div>
</div>
<div >
<label>Number of inputs</label>
<div>
<input type="number" min={1} value={inputCount} onChange={(e) => setInputCount(parseInt(e.target.value))} />
</div>
</div>
<div>
<label>Number of outputs</label>
<div>
<input type="number" min={1} value={outputCount} onChange={(e) => setOutputCount(parseInt(e.target.value))} />
</div>
</div>
<div>
<label>Number of recipients</label>
<div>
<input type="number" min={1} value={recipientCount} onChange={(e) => setRecipientCount(parseInt(e.target.value))} />
</div>
</div>
<div>
<label>Number of payjoin recipient inputs</label>
<div>
<input type="number" min={1} value={payjoinRecipientInputCount} onChange={(e) => setPayjoinRecipientInputCount(parseInt(e.target.value))} />
</div>
</div>
<div>
<label>Number of depositor inputs</label>
<div>
<input type="number" min={1} value={depositorInputCount} onChange={(e) => setDepositorInputCount(parseInt(e.target.value))} />
</div>
</div>
<div>
<label>Number of depositor outputs</label>
<div>
<input type="number" min={1} value={depositorOutputCount} onChange={(e) => setDepositorOutputCount(parseInt(e.target.value))} />
</div>
</div>
<button type="submit" disabled={isDisabled} onClick={handleSubmit}>Calculate</button><br/><br/><br/>
{/* Transaction size in raw bytes: <span id="txBytes"></span><br/> */}
Transaction size in virtual bytes without batching: <span>{unbatchedVbytes}</span><br/>
Transaction size in virtual bytes with batching: <span>{batchedVbytes}</span><br/>
Transaction size in virtual bytes with payjoin: <span>{payjoinVbytes}</span><br/>
{/* Transaction size in weight units: <span id="txWeight"></span><br/><br/> */}
{/* <p>Which size should you use for calculating fee estimates?<br/>
Estimates should be in <a href="https://medium.com/@murchandamus/psa-wrong-fee-rates-on-block-explorers-48390cbfcc74">satoshis per virtual byte.</a></p> */}
</div>
</div>
</form>
<div className="w-full h-full bg-red">
<BatchBar unbatchedVbytes={unbatchedVbytes} batchedVbytes={batchedVbytes} payjoinVbytes={payjoinVbytes} />
</div>
</div>
</div>
</Layout>
);
}
106 changes: 106 additions & 0 deletions src/utils/tx.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
33 changes: 33 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"

[email protected]:
version "1.1.1"
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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==

[email protected]:
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"
Expand Down Expand Up @@ -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==

[email protected]:
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"
Expand Down