From 7cf2bf48ebda2d8dc45c6a83068a5dc5ce028beb Mon Sep 17 00:00:00 2001 From: Harshil Jani Date: Sat, 31 Aug 2024 01:31:15 +0530 Subject: [PATCH] Caravan Health Package (beta) (#112) Signed-off-by: Harshil-Jani Co-authored-by: buck --- .changeset/eighty-planets-help.md | 14 + package-lock.json | 20 + .../caravan-bitcoin/src/addresses.test.ts | 53 ++- packages/caravan-bitcoin/src/addresses.ts | 29 ++ .../caravan-bitcoin/src/types/addresses.ts | 2 +- packages/caravan-clients/src/bitcoind.js | 14 + packages/caravan-clients/src/client.test.ts | 236 +++++++++++- packages/caravan-clients/src/client.ts | 143 ++++++- packages/caravan-clients/src/index.ts | 1 + packages/caravan-clients/src/types.ts | 45 +++ packages/caravan-health/.eslintrc.js | 11 + packages/caravan-health/.prettierrc | 4 + packages/caravan-health/README.md | 76 ++++ packages/caravan-health/jest.config.js | 5 + packages/caravan-health/package.json | 44 +++ packages/caravan-health/src/index.ts | 4 + packages/caravan-health/src/privacy.test.ts | 352 ++++++++++++++++++ packages/caravan-health/src/privacy.ts | 261 +++++++++++++ packages/caravan-health/src/spendType.ts | 77 ++++ packages/caravan-health/src/types.ts | 28 ++ packages/caravan-health/src/wallet.test.ts | 199 ++++++++++ packages/caravan-health/src/wallet.ts | 145 ++++++++ packages/caravan-health/src/waste.test.ts | 190 ++++++++++ packages/caravan-health/src/waste.ts | 234 ++++++++++++ packages/caravan-health/tsconfig.json | 4 + packages/caravan-health/tsup.config.js | 6 + 26 files changed, 2181 insertions(+), 16 deletions(-) create mode 100644 .changeset/eighty-planets-help.md create mode 100644 packages/caravan-clients/src/types.ts create mode 100644 packages/caravan-health/.eslintrc.js create mode 100644 packages/caravan-health/.prettierrc create mode 100644 packages/caravan-health/README.md create mode 100644 packages/caravan-health/jest.config.js create mode 100644 packages/caravan-health/package.json create mode 100644 packages/caravan-health/src/index.ts create mode 100644 packages/caravan-health/src/privacy.test.ts create mode 100644 packages/caravan-health/src/privacy.ts create mode 100644 packages/caravan-health/src/spendType.ts create mode 100644 packages/caravan-health/src/types.ts create mode 100644 packages/caravan-health/src/wallet.test.ts create mode 100644 packages/caravan-health/src/wallet.ts create mode 100644 packages/caravan-health/src/waste.test.ts create mode 100644 packages/caravan-health/src/waste.ts create mode 100644 packages/caravan-health/tsconfig.json create mode 100644 packages/caravan-health/tsup.config.js diff --git a/.changeset/eighty-planets-help.md b/.changeset/eighty-planets-help.md new file mode 100644 index 00000000..6a0ec073 --- /dev/null +++ b/.changeset/eighty-planets-help.md @@ -0,0 +1,14 @@ +--- +"@caravan/bitcoin": minor +"@caravan/clients": minor +--- + +@caravan/client +We are exposing a new method `getAddressTransactions` which will fetch all the transaction for a given address and format it as per needs. To facilitate the change, we had moved the interfaces in the new file `types.ts`. + +Another change was about getting the block fee-rate percentile history from mempool as a client. + +@caravan/bitcoin +The new function that has the capability to detect the address type (i.e P2SH, P2PKH, P2WSH or P2TR) was added. + +Overall, The changes were to support the new library within caravan called @caravan/health. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 12bcb2ec..9e2936d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2721,6 +2721,10 @@ "resolved": "packages/eslint-config", "link": true }, + "node_modules/@caravan/health": { + "resolved": "packages/caravan-health", + "link": true + }, "node_modules/@caravan/multisig": { "resolved": "packages/multisig", "link": true @@ -26007,6 +26011,22 @@ "webidl-conversions": "^4.0.2" } }, + "packages/caravan-health": { + "name": "@caravan/health", + "version": "1.0.0-beta", + "license": "MIT", + "dependencies": { + "@caravan/bitcoin": "*", + "@caravan/clients": "*" + }, + "devDependencies": { + "@caravan/eslint-config": "*", + "@caravan/typescript-config": "*" + }, + "engines": { + "node": ">=20" + } + }, "packages/caravan-psbt": { "name": "@caravan/psbt", "version": "1.4.2", diff --git a/packages/caravan-bitcoin/src/addresses.test.ts b/packages/caravan-bitcoin/src/addresses.test.ts index b79a8252..45dd8218 100644 --- a/packages/caravan-bitcoin/src/addresses.test.ts +++ b/packages/caravan-bitcoin/src/addresses.test.ts @@ -1,9 +1,10 @@ -import { validateAddress } from "./addresses"; +import { validateAddress, getAddressType } from "./addresses"; import * as multisig from "./multisig"; import { Network } from "./networks"; const P2PKH = "P2PKH"; const P2TR = "P2TR"; +const P2WSH = "P2WSH"; const ADDRESSES = {}; ADDRESSES[Network.MAINNET] = {}; @@ -104,4 +105,54 @@ describe("addresses", () => { }); }); }); + + describe("getAddressType", () => { + it("correctly identifies P2SH addresses", () => { + ADDRESSES[Network.MAINNET][P2PKH].forEach((address) => { + expect(getAddressType(address, Network.MAINNET)).toBe(P2PKH); + }); + ADDRESSES[Network.TESTNET][P2PKH].forEach((address) => { + expect(getAddressType(address, Network.TESTNET)).toBe("P2PKH"); + }); + ADDRESSES[Network.REGTEST][P2PKH].forEach((address) => { + expect(getAddressType(address, Network.REGTEST)).toBe(P2PKH); + }); + }); + + it("correctly identifies P2WSH addresses", () => { + ADDRESSES[Network.MAINNET][(multisig as any).P2WSH].forEach((address) => { + expect(getAddressType(address, Network.MAINNET)).toBe(P2WSH); + }); + ADDRESSES[Network.TESTNET][(multisig as any).P2WSH].forEach((address) => { + expect(getAddressType(address, Network.TESTNET)).toBe(P2WSH); + }); + ADDRESSES[Network.REGTEST][(multisig as any).P2WSH].forEach((address) => { + expect(getAddressType(address, Network.REGTEST)).toBe(P2WSH); + }); + }); + + it("correctly identifies P2TR addresses", () => { + ADDRESSES[Network.MAINNET][P2TR].forEach((address) => { + expect(getAddressType(address, Network.MAINNET)).toBe(P2TR); + }); + ADDRESSES[Network.TESTNET][P2TR].forEach((address) => { + expect(getAddressType(address, Network.TESTNET)).toBe(P2TR); + }); + ADDRESSES[Network.REGTEST][P2TR].forEach((address) => { + expect(getAddressType(address, Network.REGTEST)).toBe(P2TR); + }); + }); + + it("returns UNKNOWN for unrecognized addresses", () => { + expect(getAddressType("unknownaddress1", Network.MAINNET)).toBe( + "UNKNOWN", + ); + expect(getAddressType("unknownaddress2", Network.TESTNET)).toBe( + "UNKNOWN", + ); + expect(getAddressType("unknownaddress3", Network.REGTEST)).toBe( + "UNKNOWN", + ); + }); + }); }); diff --git a/packages/caravan-bitcoin/src/addresses.ts b/packages/caravan-bitcoin/src/addresses.ts index 557a083a..acb288dd 100644 --- a/packages/caravan-bitcoin/src/addresses.ts +++ b/packages/caravan-bitcoin/src/addresses.ts @@ -8,6 +8,7 @@ import { } from "bitcoin-address-validation"; import { Network } from "./networks"; +import { MultisigAddressType } from "./types"; const MAINNET_ADDRESS_MAGIC_BYTE_PATTERN = "^(bc1|[13])"; const TESTNET_ADDRESS_MAGIC_BYTE_PATTERN = "^(tb1|bcrt1|[mn2])"; @@ -60,3 +61,31 @@ export function validateAddress(address: string, network: Network) { return valid ? "" : "Address is invalid."; } + +export function getAddressType( + address: string, + network: Network, +): MultisigAddressType { + if (validateAddress(address, network) !== "") { + return "UNKNOWN"; + } + const bech32Regex = /^(bc1|tb1|bcrt1)/; + const p2pkhRegex = /^(1|m|n)/; + const p2shRegex = /^(3|2)/; + + if (address.match(bech32Regex)) { + if ( + address.startsWith("bc1p") || + address.startsWith("tb1p") || + address.startsWith("bcrt1p") + ) { + return "P2TR"; + } + return "P2WSH"; + } else if (address.match(p2pkhRegex)) { + return "P2PKH"; + } else if (address.match(p2shRegex)) { + return "P2SH"; + } + return "UNKNOWN"; +} diff --git a/packages/caravan-bitcoin/src/types/addresses.ts b/packages/caravan-bitcoin/src/types/addresses.ts index d030b457..ac71e7cb 100644 --- a/packages/caravan-bitcoin/src/types/addresses.ts +++ b/packages/caravan-bitcoin/src/types/addresses.ts @@ -2,4 +2,4 @@ // address type. // We should be able to replace this with use of the MULTISIG_ADDRESS_TYPES // enum when that file (./multisig.js) gets converted to typescript -export type MultisigAddressType = "P2SH" | "P2WSH" | "P2SH-P2WSH" | "P2TR"; +export type MultisigAddressType = "P2SH" | "P2WSH" | "P2SH-P2WSH" | "P2TR" | "P2PKH" | "UNKNOWN"; diff --git a/packages/caravan-clients/src/bitcoind.js b/packages/caravan-clients/src/bitcoind.js index 37f2beba..8c7cb063 100644 --- a/packages/caravan-clients/src/bitcoind.js +++ b/packages/caravan-clients/src/bitcoind.js @@ -143,3 +143,17 @@ export function bitcoindImportMulti({ url, auth, addresses, label, rescan }) { } return callBitcoind(...params); } + +export async function bitcoindRawTxData(txid){ + try{ + return await callBitcoind( + this.bitcoindParams.url, + this.bitcoindParams.auth, + "decoderawtransaction", + [txid], + ); + } + catch(e){ + return e; + } +} diff --git a/packages/caravan-clients/src/client.test.ts b/packages/caravan-clients/src/client.test.ts index 896a78ad..fcf4e60d 100644 --- a/packages/caravan-clients/src/client.test.ts +++ b/packages/caravan-clients/src/client.test.ts @@ -3,13 +3,12 @@ import { BlockchainClient, ClientType, ClientBase, - UTXO, BlockchainClientError, } from "./client"; import * as bitcoind from "./bitcoind"; import * as wallet from "./wallet"; import BigNumber from "bignumber.js"; - +import { UTXO } from "./types"; import axios from "axios"; jest.mock("axios"); @@ -839,7 +838,11 @@ describe("BlockchainClient", () => { const receive = "receive"; const change = "change"; - await blockchainClient.importDescriptors({ receive, change, rescan: true}); + await blockchainClient.importDescriptors({ + receive, + change, + rescan: true, + }); expect(mockImportDescriptors).toHaveBeenCalledWith({ receive, change, @@ -861,7 +864,11 @@ describe("BlockchainClient", () => { const receive = "receive"; const change = "change"; - await blockchainClient.importDescriptors({ receive, change, rescan: false}); + await blockchainClient.importDescriptors({ + receive, + change, + rescan: false, + }); expect(mockImportDescriptors).toHaveBeenCalledWith({ receive, change, @@ -896,4 +903,225 @@ describe("BlockchainClient", () => { }); }); }); + + describe("getAddressTransactions", () => { + it("should get the all the transactions for a given address in PRIVATE network MAINNET", async () => { + // Mock the response from the API + const mockResponseListTransaction = [ + { + address: + "bcrt1qymrajrm0wq5uvwlmj26lxkpchxge7rsf0qt3tfrvpcwcvxsmrp3qq60fu7", + parent_descs: [ + "wsh(sortedmulti(1,tpubDFnYXDztf7GxeGVpPsgYaqbfE6mCsvVzCGKhtafJU3pbF8r8cuGQgp81puJcjuBdsMhk1oUHdhNbsrPcn8SHjktJ45pzJNhAd1BY3jRdzvj/0/*,tpubDDwMB2bTZPY5Usnyqn7PN1cYmNWNghRxtY968LCA2DRr4HM93JqkLd5uEHXQb2rRLjHrkccguYRxyDkQi71mBuZ7XAfLH29918Gu9vKVmhy/0/*))#dw99d0sw", + ], + category: "receive", + amount: 15.0, + label: "", + vout: 0, + confirmations: 22, + blockhash: + "1ab9eed7ff3b824dfdee22560e8fc826f2bac0ca835c992b8659b1c834721ffa", + blockheight: 1181, + blockindex: 1, + blocktime: 1718291897, + txid: "c24617439089a088adb813b5c14238a9354db2f1f6a2224a36a8d7fe095b793d", + wtxid: + "341610613a8fcde8933322dc20f35f2635f37cc926c11001a446f604effb73a4", + walletconflicts: [], + time: 1718291888, + timereceived: 1718291888, + "bip125-replaceable": "no", + }, + ]; + const mockBitcoindListTransaction = jest.spyOn(bitcoind, "callBitcoind"); + mockBitcoindListTransaction.mockResolvedValue( + mockResponseListTransaction, + ); + + const mockBitcoindRawTxData = { + txid: "c24617439089a088adb813b5c14238a9354db2f1f6a2224a36a8d7fe095b793d", + hash: "341610613a8fcde8933322dc20f35f2635f37cc926c11001a446f604effb73a4", + version: 2, + size: 312, + vsize: 212, + weight: 846, + locktime: 1180, + vin: [ + { + txid: "c628cc1cde5ca9adf470c4837ac99d3745a72d9a57a6cffb40e22508627af554", + vout: 1, + scriptSig: { + asm: "", + hex: "", + }, + txinwitness: [ + "23e8ef69bd66165cb1bc41a4354ecc69ee0d92a1b98fcb528f93dd2ae54ea7033c0fdf24e1419705ace5d1bd3d2aba34cccfabde22ec08ed1728c97e6fb85a7b", + ], + sequence: 4294967293, + }, + { + txid: "0dfe7a6df3c7840df8a6f5f74160bb3545d60aa0924eb0a6574f29e3eddb4354", + vout: 0, + scriptSig: { + asm: "", + hex: "", + }, + txinwitness: [ + "b342d6f9d0e75900d7301e3ddf3c386f1f4103596e92bbd36df88d860d2a8631af177d69a47e619fbe1054690865fada18b3695d3160620d716090ae356ddf53", + ], + sequence: 4294967293, + }, + ], + vout: [ + { + value: 15.0, + n: 0, + scriptPubKey: { + asm: "0 26c7d90f6f7029c63bfb92b5f35838b9919f0e09781715a46c0e1d861a1b1862", + desc: "addr(bcrt1qymrajrm0wq5uvwlmj26lxkpchxge7rsf0qt3tfrvpcwcvxsmrp3qq60fu7)#szq6selt", + hex: "002026c7d90f6f7029c63bfb92b5f35838b9919f0e09781715a46c0e1d861a1b1862", + address: + "bcrt1qymrajrm0wq5uvwlmj26lxkpchxge7rsf0qt3tfrvpcwcvxsmrp3qq60fu7", + type: "witness_v0_scripthash", + }, + }, + { + value: 11.561891, + n: 1, + scriptPubKey: { + asm: "1 5a475ace36c3054538843e859a1485bf3dd438924ec4d2d81abf55feeabe5a56", + desc: "rawtr(5a475ace36c3054538843e859a1485bf3dd438924ec4d2d81abf55feeabe5a56)#am899zm3", + hex: "51205a475ace36c3054538843e859a1485bf3dd438924ec4d2d81abf55feeabe5a56", + address: + "bcrt1ptfr44n3kcvz52wyy86ze59y9hu7agwyjfmzd9kq6ha2la647tftq8dhx2a", + type: "witness_v1_taproot", + }, + }, + ], + }; + + const mockBitcoindGetAddressTransactions = jest.spyOn( + bitcoind, + "bitcoindRawTxData", + ); + mockBitcoindGetAddressTransactions.mockResolvedValue( + mockBitcoindRawTxData, + ); + + const blockchainClient = new BlockchainClient({ + type: ClientType.PRIVATE, + network: Network.MAINNET, + }); + + const address = + "bcrt1qymrajrm0wq5uvwlmj26lxkpchxge7rsf0qt3tfrvpcwcvxsmrp3qq60fu7"; + const transactions = + await blockchainClient.getAddressTransactions(address); + const expectedResponse = [ + { + txid: "c24617439089a088adb813b5c14238a9354db2f1f6a2224a36a8d7fe095b793d", + vin: [ + { + prevTxId: + "c628cc1cde5ca9adf470c4837ac99d3745a72d9a57a6cffb40e22508627af554", + vout: 1, + sequence: 4294967293, + }, + { + prevTxId: + "0dfe7a6df3c7840df8a6f5f74160bb3545d60aa0924eb0a6574f29e3eddb4354", + vout: 0, + sequence: 4294967293, + }, + ], + vout: [ + { + scriptPubkeyHex: + "002026c7d90f6f7029c63bfb92b5f35838b9919f0e09781715a46c0e1d861a1b1862", + scriptPubkeyAddress: + "bcrt1qymrajrm0wq5uvwlmj26lxkpchxge7rsf0qt3tfrvpcwcvxsmrp3qq60fu7", + value: 15, + }, + { + scriptPubkeyHex: + "51205a475ace36c3054538843e859a1485bf3dd438924ec4d2d81abf55feeabe5a56", + scriptPubkeyAddress: + "bcrt1ptfr44n3kcvz52wyy86ze59y9hu7agwyjfmzd9kq6ha2la647tftq8dhx2a", + value: 11.561891, + }, + ], + size: 312, + weight: 846, + fee: undefined, + isSend: false, + amount: 15, + block_time: 1718291897, + }, + ]; + + expect(transactions).toEqual(expectedResponse); + }); + }); + + describe("getBlockFeeRatePercentileHistory", () => { + it("should get the fee rate percentiles for a closest blocks' transactions (MEMPOOL client)", async () => { + // Mock the response from the API + const mockResponse = [ + { + avgHeight: 45, + timestamp: 1231605377, + avgFee_0: 0, + avgFee_10: 0, + avgFee_25: 0, + avgFee_50: 0, + avgFee_75: 0, + avgFee_90: 0, + avgFee_100: 0, + }, + ]; + const mockGet = jest.fn().mockResolvedValue(mockResponse); + // Create a new instance of BlockchainClient with a mock axios instance + const blockchainClient = new BlockchainClient({ + type: ClientType.MEMPOOL, + network: Network.MAINNET, + }); + blockchainClient.Get = mockGet; + + // Call the getTransactionHex method + const feeRateHistory = + await blockchainClient.getBlockFeeRatePercentileHistory(); + + // Verify the mock axios instance was called with the correct URL + expect(mockGet).toHaveBeenCalledWith(`/v1/mining/blocks/fee-rates/all`); + + // Verify the returned transaction hex + expect(feeRateHistory).toEqual(mockResponse); + }); + + it("should throw an error when using BLOCKSTREAM or PRIVATE client", async () => { + const mockError = new Error( + "Not supported for private clients and blockstream. Currently only supported for mempool", + ); + + // Create a new instance of BlockchainClient with a mock axios instance + const blockchainClient = new BlockchainClient({ + type: ClientType.PRIVATE, + network: Network.MAINNET, + }); + + let error; + try { + await blockchainClient.getBlockFeeRatePercentileHistory(); + } catch (err) { + error = err; + } + + // Verify the error message + expect(error).toEqual( + new Error( + `Failed to get feerate percentile block: ${mockError.message}`, + ), + ); + }); + }); }); diff --git a/packages/caravan-clients/src/client.ts b/packages/caravan-clients/src/client.ts index 1bc2ef98..95ac4a5d 100644 --- a/packages/caravan-clients/src/client.ts +++ b/packages/caravan-clients/src/client.ts @@ -10,6 +10,7 @@ import { bitcoindSendRawTransaction, isWalletAddressNotFoundError, callBitcoind, + bitcoindRawTxData, } from "./bitcoind"; import { bitcoindGetAddressStatus, @@ -18,6 +19,7 @@ import { bitcoindWalletInfo, } from "./wallet"; import BigNumber from "bignumber.js"; +import { FeeRatePercentile, Transaction, UTXO } from "./types"; export class BlockchainClientError extends Error { constructor(message) { @@ -26,21 +28,12 @@ export class BlockchainClientError extends Error { } } -export interface UTXO { - txid: string; - vout: number; - value: number; - status: { - confirmed: boolean; - block_time: number; - }; -} - export enum ClientType { PRIVATE = "private", BLOCKSTREAM = "blockstream", MEMPOOL = "mempool", } + const delay = () => { return new Promise((resolve) => setTimeout(resolve, 500)); }; @@ -172,6 +165,98 @@ export class BlockchainClient extends ClientBase { } } + public async getAddressTransactions(address: string): Promise { + try { + if (this.type === ClientType.PRIVATE) { + const data = await callBitcoind( + this.bitcoindParams.url, + this.bitcoindParams.auth, + "listtransactions", + [this.bitcoindParams.walletName], + ); + + const txs: Transaction[] = []; + for (const tx of data) { + if (tx.address === address) { + const rawTxData = await bitcoindRawTxData(tx.txid); + const transaction: Transaction = { + txid: tx.txid, + vin: [], + vout: [], + size: rawTxData.size, + weight: rawTxData.weight, + fee: tx.fee, + isSend: tx.category === "send" ? true : false, + amount: tx.amount, + block_time: tx.blocktime, + }; + for (const input of rawTxData.vin) { + transaction.vin.push({ + prevTxId: input.txid, + vout: input.vout, + sequence: input.sequence, + }); + } + for (const output of rawTxData.vout) { + transaction.vout.push({ + scriptPubkeyHex: output.scriptPubKey.hex, + scriptPubkeyAddress: output.scriptPubKey.address, + value: output.value, + }); + } + txs.push(transaction); + } + } + return txs; + } + + // For Mempool and Blockstream + const data = await this.Get(`/address/${address}/txs`); + const txs: Transaction[] = []; + for (const tx of data.txs) { + const transaction: Transaction = { + txid: tx.txid, + vin: [], + vout: [], + size: tx.size, + weight: tx.weight, + fee: tx.fee, + isSend: false, + amount: 0, + block_time: tx.status.block_time, + }; + + for (const input of tx.vin) { + if (input.prevout.scriptpubkey_address === address) { + transaction.isSend = true; + } + transaction.vin.push({ + prevTxId: input.txid, + vout: input.vout, + sequence: input.sequence, + }); + } + + let total_amount = 0; + for (const output of tx.vout) { + total_amount += output.value; + transaction.vout.push({ + scriptPubkeyHex: output.scriptpubkey, + scriptPubkeyAddress: output.scriptpubkey_address, + value: output.value, + }); + } + transaction.amount = total_amount; + txs.push(transaction); + } + return txs; + } catch (error: any) { + throw new Error( + `Failed to get transactions for address ${address}: ${error.message}`, + ); + } + } + public async broadcastTransaction(rawTx: string): Promise { try { if (this.type === ClientType.PRIVATE) { @@ -315,6 +400,44 @@ export class BlockchainClient extends ClientBase { } } + public async getBlockFeeRatePercentileHistory(): Promise< + FeeRatePercentile[] + > { + try { + if ( + this.type === ClientType.PRIVATE || + this.type === ClientType.BLOCKSTREAM + ) { + throw new Error( + "Not supported for private clients and blockstream. Currently only supported for mempool", + ); + } + + const data = await this.Get(`/v1/mining/blocks/fee-rates/all`); + + const feeRatePercentileBlocks: FeeRatePercentile[] = []; + for (const block of data) { + const feeRatePercentile: FeeRatePercentile = { + avgHeight: block?.avgHeight, + timestamp: block?.timestamp, + avgFee_0: block?.avgFee_0, + avgFee_10: block?.avgFee_10, + avgFee_25: block?.avgFee_25, + avgFee_50: block?.avgFee_50, + avgFee_75: block?.avgFee_75, + avgFee_90: block?.avgFee_90, + avgFee_100: block?.avgFee_100, + }; + feeRatePercentileBlocks.push(feeRatePercentile); + } + return feeRatePercentileBlocks; + } catch (error: any) { + throw new Error( + `Failed to get feerate percentile block: ${error.message}`, + ); + } + } + public async getTransactionHex(txid: string): Promise { try { if (this.type === ClientType.PRIVATE) { diff --git a/packages/caravan-clients/src/index.ts b/packages/caravan-clients/src/index.ts index a1f928fc..a89cd4be 100644 --- a/packages/caravan-clients/src/index.ts +++ b/packages/caravan-clients/src/index.ts @@ -1,2 +1,3 @@ export { bitcoindImportDescriptors } from "./wallet"; export { BlockchainClient, ClientType } from "./client"; +export type { UTXO, Transaction, FeeRatePercentile } from "./types"; diff --git a/packages/caravan-clients/src/types.ts b/packages/caravan-clients/src/types.ts new file mode 100644 index 00000000..a52f3142 --- /dev/null +++ b/packages/caravan-clients/src/types.ts @@ -0,0 +1,45 @@ +export interface UTXO { + txid: string; + vout: number; + value: number; + status: { + confirmed: boolean; + block_time: number; + }; +} + +export interface Transaction { + txid: string; + vin: Input[]; + vout: Output[]; + size: number; + weight: number; + fee: number; + isSend: boolean; + amount: number; + block_time: number; +} + +interface Input { + prevTxId: string; + vout: number; + sequence: number; +} + +interface Output { + scriptPubkeyHex: string; + scriptPubkeyAddress: string; + value: number; +} + +export interface FeeRatePercentile { + avgHeight: number; + timestamp: number; + avgFee_0: number; + avgFee_10: number; + avgFee_25: number; + avgFee_50: number; + avgFee_75: number; + avgFee_90: number; + avgFee_100: number; +} diff --git a/packages/caravan-health/.eslintrc.js b/packages/caravan-health/.eslintrc.js new file mode 100644 index 00000000..f93c6274 --- /dev/null +++ b/packages/caravan-health/.eslintrc.js @@ -0,0 +1,11 @@ +// .eslintrc.js +module.exports = { + root: true, + extends: [ + "@caravan/eslint-config/library.js" + ], + parser: "@typescript-eslint/parser", + parserOptions: { + project: true, + }, +}; diff --git a/packages/caravan-health/.prettierrc b/packages/caravan-health/.prettierrc new file mode 100644 index 00000000..222861c3 --- /dev/null +++ b/packages/caravan-health/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 2, + "useTabs": false +} diff --git a/packages/caravan-health/README.md b/packages/caravan-health/README.md new file mode 100644 index 00000000..603d9dc6 --- /dev/null +++ b/packages/caravan-health/README.md @@ -0,0 +1,76 @@ +# Caravan-Health + +The `@caravan/health` package is a toolkit for analyzing and scoring the privacy and fee spending efficiency of Bitcoin transactions and wallets. Wallet health is determined by various factors including financial privacy, transaction fees, and the avoidance of dust outputs. It provides metrics and algorithms to evaluate various aspects of transaction behavior, UTXO management, and fee strategies. + +# Defining Wallet Health Goals + +Different users have diverse needs and preferences which impact their wallet health goals. Some users prioritize financial privacy, others focus on minimizing transaction fees, and some want a healthy wallet without delving into the technical details of UTXOs and transactions. The `@caravan/health ` package aims to highlight metrics for wallet health and provide suggestions for improvement. + +# Features + +## Privacy Metrics + +The `PrivacyMetrics` class offers tools to assess the privacy of Bitcoin transactions and wallets: + +- **Topology Score:** Evaluates transaction privacy based on input/output structure. +- **Mean Transaction Topology Score:** Calculates the average privacy score across all wallet transactions. +- **Address Reuse Factor (ARF):** Measures the extent of address reuse within the wallet. +- **Address Type Factor (ATF):** Evaluates the diversity of address types used in transactions. +- **UTXO Spread Factor:** Assesses the dispersion of UTXO values to gauge traceability resistance. +- **UTXO Value Dispersion Factor:** Combines UTXO spread and mass factors for a comprehensive view. +- **Weighted Privacy Score:** Provides an overall privacy health score for the wallet. + +## Waste Metrics + +The `WasteMetrics` class focuses on transaction fee efficiency and UTXO management: + +- **Relative Fees Score (RFS):** Compares transaction fees to others in the same block. +- **Fees To Amount Ratio (FAR):** Evaluates the proportion of fees to transaction amounts. +- **calculateDustLimits:** Calculates the dust limits for UTXOs based on the current fee rate. A utxo is dust if it costs more to send based on the size of the input. +- **Spend Waste Amount (SWA):** Determines the cost of keeping or spending UTXOs in particular transaction at a given point of time. +- **Weighted Waste Score (WWS):** Combines various metrics for an overall efficiency score. + +# Dependencies + +This library depends on the `@caravan/clients` and `@caravan/bitcoin` package for type validations and preparing some of the required data for that type. + +# Usage + +To use the Caravan Health Library, you'll need to import the necessary classes and types + +```javascript +import { + PrivacyMetrics, + WasteMetrics, + AddressUtxos, + SpendType, +} from "@caravan/health"; +import { Transaction, FeeRatePercentile } from "@caravan/clients"; +import { Network, MultisigAddressType } from "@caravan/bitcoin"; + +const transactions : Transaction[] = []; +const utxos: AddressUtxos = {}; +const walletAddressType : MultisigAddressType = "P2SH"; +const network : Network = "mainnet"; +const feeRatePercentileHistory : FeeRatePercentile[] + +// Initialize classes for health analysis +const privacyMetrics = new PrivacyMetrics(transactions,utxos); +const wasteMetrics = new WasteMetrics(transactions,utxos); + +// For example use metric that calculates overall privacy score +const privacyScore = privacyMetrics.getWalletPrivacyScore( + walletAddressType, + network, +); + +// For example use metric that calculates overall waste score +const wasteScore = wasteMetrics.weightedWasteScore( + feeRatePercentileHistory, +); +``` + +# TODOs + +- [] Expand the test cases for privacy and waste metrics to cover every possible case. +- [] Add links to each algorithm and the corresponding explanation in final research document. diff --git a/packages/caravan-health/jest.config.js b/packages/caravan-health/jest.config.js new file mode 100644 index 00000000..0113a7bb --- /dev/null +++ b/packages/caravan-health/jest.config.js @@ -0,0 +1,5 @@ +// jest.config.js +module.exports = { + preset: "ts-jest", + testEnvironment: "jsdom" +}; diff --git a/packages/caravan-health/package.json b/packages/caravan-health/package.json new file mode 100644 index 00000000..bad7c39b --- /dev/null +++ b/packages/caravan-health/package.json @@ -0,0 +1,44 @@ +{ + "name": "@caravan/health", + "version": "1.0.0-beta", + "author": "Harshil Jani", + "description": "The core logic for analysing wallet health for privacy concerns and nature of spending fees.", + "private": true, + "engines": { + "node": ">=20" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "module": "./dist/index.mjs", + "files": [ + "./dist/index.js", + "./dist/index.mjs", + "./dist/index.d.ts" + ], + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "license": "MIT", + "scripts": { + "build": "tsup src/index.ts --format cjs,esm --dts", + "dev": "npm run build -- --watch", + "lint": "eslint src/", + "ci": "npm run lint && npm run test", + "format": "prettier --write \"**/*.{ts,tsx,md}\"", + "test": "jest src", + "test:watch": "jest --watch src", + "test:debug": "node --inspect-brk ../../node_modules/.bin/jest --runInBand" + }, + "dependencies": { + "@caravan/clients": "*", + "@caravan/bitcoin": "*" + }, + "devDependencies": { + "@caravan/eslint-config": "*", + "@caravan/typescript-config": "*" + } +} diff --git a/packages/caravan-health/src/index.ts b/packages/caravan-health/src/index.ts new file mode 100644 index 00000000..0148b0ce --- /dev/null +++ b/packages/caravan-health/src/index.ts @@ -0,0 +1,4 @@ +export * from "./privacy"; +export * from "./types"; +export * from "./waste"; +export * from "./wallet"; diff --git a/packages/caravan-health/src/privacy.test.ts b/packages/caravan-health/src/privacy.test.ts new file mode 100644 index 00000000..06229af4 --- /dev/null +++ b/packages/caravan-health/src/privacy.test.ts @@ -0,0 +1,352 @@ +import { PrivacyMetrics } from "./privacy"; +import { AddressUtxos, SpendType, Transaction, Network } from "./types"; +import { determineSpendType, getSpendTypeScore } from "./spendType"; + +const transactions: Transaction[] = [ + // transactions[0] is a perfect spend transaction + { + txid: "txid1", + vin: [ + { + prevTxId: "prevTxId1", + vout: 0, + sequence: 0, + }, + ], + vout: [ + { + scriptPubkeyHex: "scriptPubkeyHex1", + scriptPubkeyAddress: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + value: 0.1, + }, + ], + size: 0, + weight: 0, + fee: 0, + isSend: true, + amount: 0, + block_time: 0, + }, + // transactions[1] is a coin join transaction + { + txid: "txid2", + vin: [ + { + prevTxId: "prevTxId2", + vout: 0, + sequence: 0, + }, + { + prevTxId: "prevTxId2", + vout: 0, + sequence: 0, + }, + ], + vout: [ + { + scriptPubkeyHex: "scriptPubkeyHex2", + scriptPubkeyAddress: "scriptPubkeyAddress2", + value: 0.2, + }, + { + scriptPubkeyHex: "scriptPubkeyHex2", + scriptPubkeyAddress: + "bc1qng72v5ceptk07htel0wcv6k27fkg6tmmd8887jr2l2yz5a5lnawqqeceya", + value: 0.2, + }, + { + scriptPubkeyHex: "scriptPubkeyHex2", + scriptPubkeyAddress: + "bc1qng72v5ceptk07htel0wcv6k27fkg6tmmd8887jr2l2yz5a5lnawqqeceya", + value: 0.2, + }, + { + scriptPubkeyHex: "scriptPubkeyHex2", + scriptPubkeyAddress: "scriptPubkeyAddress2", + value: 0.2, + }, + ], + size: 0, + weight: 0, + fee: 0, + isSend: true, + amount: 0, + block_time: 0, + }, +]; + +const utxos: AddressUtxos = { + address1: [ + { + txid: "tx1", + vout: 0, + value: 0.1, + status: { + confirmed: true, + block_time: 1234, + }, + }, + { + txid: "tx2", + vout: 0, + value: 0.2, + status: { + confirmed: true, + block_time: 1234, + }, + }, + { + txid: "tx3", + vout: 0, + value: 0.3, + status: { + confirmed: true, + block_time: 1234, + }, + }, + { + txid: "tx4", + vout: 0, + value: 0.4, + status: { + confirmed: true, + block_time: 1234, + }, + }, + ], +}; + +describe("Privacy metric scoring", () => { + const privacyMetric = new PrivacyMetrics(transactions, utxos); + + describe("Determine Spend Type", () => { + it("Perfect Spend are transactions with 1 input and 1 output", () => { + const spendType: SpendType = determineSpendType(1, 1); + expect(spendType).toBe(SpendType.PerfectSpend); + }); + + it("Simple Spend are transactions with 1 input and 2 outputs", () => { + const spendType: SpendType = determineSpendType(1, 2); + expect(spendType).toBe(SpendType.SimpleSpend); + }); + + it("UTXO Fragmentation are transactions with 1 input and more than 2 outputs", () => { + const spendType: SpendType = determineSpendType(1, 3); + expect(spendType).toBe(SpendType.UTXOFragmentation); + + const spendType2: SpendType = determineSpendType(1, 4); + expect(spendType2).toBe(SpendType.UTXOFragmentation); + + const spendType3: SpendType = determineSpendType(1, 5); + expect(spendType3).toBe(SpendType.UTXOFragmentation); + }); + + it("Consolidation transactions have more than 1 inputs and 1 output", () => { + const spendType: SpendType = determineSpendType(2, 1); + expect(spendType).toBe(SpendType.Consolidation); + + const spendType2: SpendType = determineSpendType(3, 1); + expect(spendType2).toBe(SpendType.Consolidation); + + const spendType3: SpendType = determineSpendType(4, 1); + expect(spendType3).toBe(SpendType.Consolidation); + }); + + it("Mixing or CoinJoin transactions have more than 1 inputs and more than 1 outputs", () => { + const spendType: SpendType = determineSpendType(3, 3); + expect(spendType).toBe(SpendType.MixingOrCoinJoin); + + const spendType2: SpendType = determineSpendType(4, 3); + expect(spendType2).toBe(SpendType.MixingOrCoinJoin); + + const spendType3: SpendType = determineSpendType(4, 4); + expect(spendType3).toBe(SpendType.MixingOrCoinJoin); + }); + }); + + describe("Spend Type Score", () => { + /* + score = P(“An output cannot be a self-payment) * (1 - P(“involvement of any change output”)) + + Perfect Spend Transaction + - No. of Input = 1 + - No. of Output = 1 + + P("An output can be a self-payment") = 0.5 + P("An output cannot be a self-payment") = 0.5 + P(“involvement of any change output”) = 0 (when number of output is 1 it will be 0) + + score = 0.5 * (1 - 0) = 0.5 + */ + it("Perfect Spend has a raw score of 0.5 for external wallet payments", () => { + const score: number = getSpendTypeScore(1, 1); + expect(score).toBe(0.5); + }); + /* + score = P(“An output cannot be a self-payment) * (1 - P(“involvement of any change output”)) + + Simple Spend Transaction + - No. of Output = 2 + + P("An output can be a self-payment") = 0.33 + P("An output cannot be a self-payment") = 0.67 + + P1 (Party1) -> P2 (Party-2) , P3(Party-3) | No change involved + P1 (Party1) -> P1 (Party1), P1 (Party1) | No change involved + P2 (Party-2) -> P2 (Party-2) ,P1 (Party1) | Yes change is involved + P(“involvement of any change output”) = 0.33 + + + score = 0.67 * (1-0.33) = 0.4489 + */ + it("Simple Spend has a raw score of 0.44 for external wallet payments", () => { + const score: number = getSpendTypeScore(1, 2); + expect(score).toBeCloseTo(0.44); + }); + + /* + UTXO Fragmentation Transaction + ONE to MANY transaction + No. of Input = 1 + No. of Output = 3 or more (MANY) + + score = 0.67 - ( 1 / Number of Outputs ) + + Justification behind the number 0.67 : + We want that the privacy score should increase with higher numbers of outputs, + because in the case it is a self spend transaction then producing more outputs means + producing more UTXOs which has privacy benefits for the wallet. + + Now, We are using a multiplication factor of 1.5 for deniability in case of all the self spend transactions. + So the quantity [ X - ( 1 / No. of outputs) ] should be less than 1 + + [ X - ( 1 / No. of outputs) ] <= 1 + + Here the quantity ( 1 / No. of outputs) could be maximum when No. of outputs = 3 + [X - ⅓ ] <= 1 + [X - 0.33] <= 1 + X<=0.67 + + */ + it("UTXO Fragmentation has a raw score of 0.33 for external wallet payments", () => { + const score: number = getSpendTypeScore(1, 3); + expect(score).toBeCloseTo(0.33); + }); + + /* + Consolidation Transaction + MANY to ONE transaction + No. of Input = 2 or more (MANY) + No. of Output = 1 + + When the number of inputs is higher than the single output then the privacy score should decrease because + it increases the fingerprint or certainty for on-chain analysers to determine that the transaction was made as + a consolidation and with more inputs we tend to expose more UTXOs for a transaction. + + score = 1 / Number of Inputs + */ + it("Consolidation has raw score of ", () => { + const score: number = getSpendTypeScore(2, 1); + expect(score).toBeCloseTo(0.5); + + const score2: number = getSpendTypeScore(3, 1); + expect(score2).toBeCloseTo(0.33); + }); + + /* + Mixing or CoinJoin Transaction + MANY to MANY transaction + No. of Input >= No. of Outputs (MANY) + No. of Output > 2 or more (MANY) + + Justification : + Privacy score is directly proportional to higher number of outputs AND less number of inputs in case of coin join. + The explanation for this to happen is that if you try to consolidate + i.e lower number of output and high number of input, the privacy should be decreased and + in case of coin join where there are so many outputs against few inputs it should have increased + privacy since the probability of someone find out if the coin belongs to you or not is very small. + + score = 1/2 * (y2/x)/(1+y2/x) + */ + it("MixingOrCoinJoin has raw score of ", () => { + const score: number = getSpendTypeScore(2, 2); + expect(score).toBeCloseTo(0.44); + + const score2: number = getSpendTypeScore(2, 3); + expect(score2).toBeCloseTo(0.333); + + const score3: number = getSpendTypeScore(3, 2); + expect(score3).toBeCloseTo(0.44); + }); + }); + + describe("Transaction Topology Score", () => { + it("Calculates the transaction topology score based on the spend type", () => { + const score: number = privacyMetric.getTopologyScore(transactions[0]); + expect(score).toBe(0.75); + + const score2: number = privacyMetric.getTopologyScore(transactions[1]); + expect(score2).toBeCloseTo(0.416); + }); + }); + + describe("Mean Topology Score", () => { + it("Calculates the mean topology score for all transactions done by a wallet", () => { + const meanScore: number = privacyMetric.getMeanTopologyScore(); + expect(meanScore).toBeCloseTo(0.583); + }); + }); + + describe("Address Reuse Factor", () => { + it.todo( + "Make multiple transactions and UTXO objects to test the address reuse factor for half used and half reused addresses.", + ); + it("Calculates the amount being held by reused addresses with respect to the total amount", () => { + const addressReuseFactor: number = privacyMetric.addressReuseFactor(); + expect(addressReuseFactor).toBe(0); + }); + }); + + describe("Address Type Factor", () => { + it.todo("Test with different combination of address types and networks"); + it("Calculates the the address type distribution of the wallet transactions", () => { + const addressTypeFactor: number = privacyMetric.addressTypeFactor( + "P2SH", + Network.MAINNET, + ); + expect(addressTypeFactor).toBe(1); + }); + it("Calculates the the address type distribution of the wallet transactions", () => { + const addressTypeFactor: number = privacyMetric.addressTypeFactor( + "P2WSH", + Network.MAINNET, + ); + expect(addressTypeFactor).toBe(0.25); + }); + }); + + describe("UTXO Spread Factor", () => { + it("Calculates the standard deviation of UTXO values which helps in assessing the dispersion of UTXO values", () => { + const utxoSpreadFactor: number = privacyMetric.utxoSpreadFactor(); + expect(utxoSpreadFactor).toBeCloseTo(0.1); + }); + }); + + describe("UTXO Value Dispersion Factor", () => { + it("Combines UTXO Spread Factor and UTXO Mass Factor", () => { + const utxoValueDispersionFactor: number = + privacyMetric.utxoValueDispersionFactor(); + expect(utxoValueDispersionFactor).toBeCloseTo(0.015); + }); + }); + + describe("Overall Privacy Score", () => { + it("Calculates the overall privacy score for a wallet", () => { + const privacyScore: number = privacyMetric.getWalletPrivacyScore( + "P2SH", + Network.MAINNET, + ); + expect(privacyScore).toBeCloseTo(0.0015); + }); + }); +}); diff --git a/packages/caravan-health/src/privacy.ts b/packages/caravan-health/src/privacy.ts new file mode 100644 index 00000000..fee0959c --- /dev/null +++ b/packages/caravan-health/src/privacy.ts @@ -0,0 +1,261 @@ +import { SpendType, MultisigAddressType, Network, Transaction } from "./types"; +import { getAddressType } from "@caravan/bitcoin"; +import { WalletMetrics } from "./wallet"; +import { determineSpendType, getSpendTypeScore } from "./spendType"; + +// Deniability Factor is a normalizing quantity that increases the score by a certain factor in cases of self-payment. +// More about deniability : https://www.truthcoin.info/blog/deniability/ +const DENIABILITY_FACTOR = 1.5; + +export class PrivacyMetrics extends WalletMetrics { + /* + Name : Topology Score + + Definition : + The score is calculated based on the number of inputs and outputs which + influence the topology type of the transaction. + + Calculation : + We have 5 categories of transaction type each with their own impact on privacy score + - Perfect Spend (1 input, 1 output) + - Simple Spend (1 input, 2 outputs) + - UTXO Fragmentation (1 input, more than 2 standard outputs) + - Consolidation (more than 1 input, 1 output) + - CoinJoin or Mixing (more than 1 input, more than 1 output) + */ + getTopologyScore(transaction: Transaction): number { + const numberOfInputs: number = transaction.vin.length; + const numberOfOutputs: number = transaction.vout.length; + + const spendType: SpendType = determineSpendType( + numberOfInputs, + numberOfOutputs, + ); + const score: number = getSpendTypeScore( + numberOfInputs, + numberOfOutputs, + ); + + if (spendType === SpendType.Consolidation) { + return score; + } + for (const output of transaction.vout) { + const address = output.scriptPubkeyAddress; + const isResued = this.isReusedAddress(address); + if (isResued === true) { + return score; + } + } + return score * DENIABILITY_FACTOR; + } + + /* + Name : Mean Transaction Topology Privacy Score (MTPS) + + Definition : + The mean topology is evaluated for entire wallet history based on + the tx toplogy score for each transaction. It signifies how well the + transactions were performed to maintain privacy. + + Calculation : + The mean topology score is calculated by evaluating the topology score for each transaction. + + Expected Range : [0, 0.75] + -> Very Poor : [0, 0.15] + -> Poor : (0.15, 0.3] + -> Moderate : (0.3, 0.45] + -> Good : (0.45, 0.6] + -> Very Good : (0.6, 0.75) + */ + getMeanTopologyScore(): number { + let privacyScore = 0; + const transactions = this.transactions; + for (const tx of transactions) { + const topologyScore = this.getTopologyScore(tx); + privacyScore += topologyScore; + } + return privacyScore / transactions.length; + } + + /* + Name : Address Reuse Factor (ARF) + + Definition : + The address reuse factor evaluates the amount being held by reused addresses with respect + to the total amount. It signifies the privacy health of the wallet based on address reuse. + + Calculation : + The factor is calculated by summing the amount held by reused addresses and dividing it + by the total amount. + + Expected Range : [0,1] + -> Very Poor : (0.8, 1] + -> Poor : [0.6, 0.8) + -> Moderate : [0.4, 0.6) + -> Good : [0.2, 0.4) + -> Very Good : [0 ,0.2) + */ + addressReuseFactor(): number { + let reusedAmount: number = 0; + let totalAmount: number = 0; + const utxos = this.utxos; + for (const address in utxos) { + const addressUtxos = utxos[address]; + for (const utxo of addressUtxos) { + totalAmount += utxo.value; + const isReused = this.isReusedAddress(address); + if (isReused) { + reusedAmount += utxo.value; + } + } + } + return reusedAmount / totalAmount; + } + + /* + Name : Address Type Factor (ATF) + + Definition : + The address type factor evaluates the address type distribution of the wallet transactions. + It signifies the privacy health of the wallet based on the address types used. + + Calculation : + It is calculated as + ATF= 1/(same+1) + where "same" denotes the number of output address types matching the input address type. + A higher "same" value results in a lower ATF, indicating reduced privacy due to less variety in address types. + If all are same or all are different address type then there will be no change in the privacy score. + + Expected Range : (0,1] + -> Very Poor : (0, 0.1] + -> Poor : [0.1, 0.3) + -> Moderate : [0.3, 0.4) + -> Good : [0.4, 0.5) + -> Very Good : [0.5 ,1] + + */ + addressTypeFactor( + walletAddressType: MultisigAddressType, + network: Network, + ): number { + const addressCounts: Record = { + P2WSH: 0, + P2SH: 0, + P2PKH: 0, + P2TR: 0, + UNKNOWN: 0, + "P2SH-P2WSH": 0, + }; + const transactions = this.transactions; + transactions.forEach((tx) => { + tx.vout.forEach((output) => { + const addressType = getAddressType(output.scriptPubkeyAddress, network); + addressCounts[addressType]++; + }); + }); + + const totalAddresses = Object.values(addressCounts).reduce( + (a, b) => a + b, + 0, + ); + const walletTypeCount = addressCounts[walletAddressType]; + + if (walletTypeCount === 0 || totalAddresses === walletTypeCount) { + return 1; + } + return 1 / (walletTypeCount + 1); + } + + /* + Name : UTXO Spread Factor + + Definition : + The spread factor using standard deviation helps in assessing the dispersion of UTXO values. + In Bitcoin privacy, spreading UTXOs reduces traceability by making it harder for adversaries + to link transactions and deduce the ownership and spending patterns of users. + + Calculation : + The spread factor is calculated by evaluating the standard deviation of UTXO values. + It is calculated as the standard deviation divided by the sum of the standard deviation with 1. + + Expected Range : [0,1) + -> Very Poor : (0, 0.2] + -> Poor : [0.2, 0.4) + -> Moderate : [0.4, 0.6) + -> Good : [0.6, 0.8) + -> Very Good : [0.8 ,1] + */ + utxoSpreadFactor(): number { + const amounts: number[] = []; + const utxos = this.utxos; + for (const address in utxos) { + const addressUtxos = utxos[address]; + addressUtxos.forEach((utxo) => { + amounts.push(utxo.value); + }); + } + + const mean: number = + amounts.reduce((sum, amount) => sum + amount, 0) / amounts.length; + const variance: number = + amounts.reduce((sum, amount) => sum + Math.pow(amount - mean, 2), 0) / + amounts.length; + const stdDev: number = Math.sqrt(variance); + return stdDev / (stdDev + 1); + } + + /* + Name : UTXO Value Dispersion Factor + + Definition : + The UTXO value dispersion factor is a combination of UTXO Spread Factor and UTXO Mass Factor. + It signifies the combined effect of how much variance there is in the UTXO Set values and + the total number of UTXOs there are. + + Calculation : + The U.V.D.F is calculated as a combination of UTXO Spread Factor and UTXO Set Length Weight. + It is calculated as (USF + UMF) * 0.15 - 0.15. + + Expected Range : [-0.15,0.15] + -> Very Poor : [-0.15, -0.1] + -> Poor : (-0.1, -0.075] + -> Moderate : (-0.075, 0) + -> Good : (0, 0.075] + -> Very Good : (0.075, 0.15] + */ + utxoValueDispersionFactor(): number { + const UMF: number = this.utxoMassFactor(); + const USF: number = this.utxoSpreadFactor(); + return (USF + UMF) * 0.15 - 0.15; + } + + /* + Name : Weighted Privacy Score + + Definition : + The weighted privacy score is a combination of all the factors calculated above. + It signifies the overall privacy health of the wallet based on the address reuse, + address types and UTXO set fingerprints etc. + + Calculation : + The weighted privacy score is calculated by + WPS = (MTPS * (1 - 0.5 * ARF) + 0.1 * (1 - ARF)) * (1 - ATF) + 0.1 * UVDF + + + */ + getWalletPrivacyScore( + walletAddressType: MultisigAddressType, + network: Network, + ): number { + const meanTopologyScore = this.getMeanTopologyScore(); + const ARF = this.addressReuseFactor(); + const ATF = this.addressTypeFactor(walletAddressType, network); + const UVDF = this.utxoValueDispersionFactor(); + + const WPS: number = + (meanTopologyScore * (1 - 0.5 * ARF) + 0.1 * (1 - ARF)) * (1 - ATF) + + 0.1 * UVDF; + + return WPS; + } +} diff --git a/packages/caravan-health/src/spendType.ts b/packages/caravan-health/src/spendType.ts new file mode 100644 index 00000000..825a04ff --- /dev/null +++ b/packages/caravan-health/src/spendType.ts @@ -0,0 +1,77 @@ +import { SpendType } from "./types"; + +/* + Name : Spend Type Determination + + Definition : + The type of spend transaction is obtained based on the number of inputs and outputs which + influence the topology type of the transaction and has a role in determining the fingerprints + behind privacy for wallets. + + Calculation : + We have 5 categories of transaction type each with their own impact on privacy score + - Perfect Spend (1 input, 1 output) + - Simple Spend (output=2 irrespective of input it is Simple Spend) + - UTXO Fragmentation (1 input, more than 2 standard outputs) + - Consolidation (more than 1 input, 1 output) + - CoinJoin or Mixing (inputs more than equal to outputs, more than 2 output) + */ +export function determineSpendType(inputs: number, outputs: number): SpendType { + if (outputs == 1) { + if (inputs == 1) { + return SpendType.PerfectSpend; + } else { + return SpendType.Consolidation; + } + } else if (outputs == 2) { + return SpendType.SimpleSpend; + } else { + if (inputs < outputs) { + return SpendType.UTXOFragmentation; + } else { + return SpendType.MixingOrCoinJoin; + } + } +} + +/* + Name : Spend Type Score + Definition : + Statistical derivations are used to calculate the score based on the spend type of the transaction. + + Calculation : + - Perfect Spend : P(“An output cannot be a self-payment) * (1 - P(“involvement of any change output”)) + - Simple Spend : P(“An output cannot be a self-payment) * (1 - P(“involvement of any change output”)) + - UTXO Fragmentation : 2/3 - 1/number of outputs + - Consolidation : 1/number of inputs + - Mixing or CoinJoin : (2/3) * (number of outputs^2) / number of inputs * (1 + (number of outputs^2) / number of inputs) + + Expected Range : [0,0.85] + -> Very Poor : [0, 0.15] + -> Poor : (0.15, 0.3] + -> Moderate : (0.3, 0.45] + -> Good : (0.45, 0.6] + -> Very Good : (0.6, 0.85] + */ +export function getSpendTypeScore( + numberOfInputs: number, + numberOfOutputs: number, +): number { + const spendType = determineSpendType(numberOfInputs, numberOfOutputs); + switch (spendType) { + case SpendType.PerfectSpend: + return 1 / 2; + case SpendType.SimpleSpend: + return 4 / 9; + case SpendType.UTXOFragmentation: + return 2 / 3 - 1 / numberOfOutputs; + case SpendType.Consolidation: + return 1 / numberOfInputs; + case SpendType.MixingOrCoinJoin: { + const x = Math.pow(numberOfOutputs, 2) / numberOfInputs; + return ((1 / 2) * x) / (1 + x); + } + default: + throw new Error("Invalid spend type"); + } +} diff --git a/packages/caravan-health/src/types.ts b/packages/caravan-health/src/types.ts new file mode 100644 index 00000000..ebce49ab --- /dev/null +++ b/packages/caravan-health/src/types.ts @@ -0,0 +1,28 @@ +import { UTXO } from "@caravan/clients"; +export type { MultisigAddressType } from "@caravan/bitcoin"; +export type { Transaction, UTXO, FeeRatePercentile } from "@caravan/clients"; +export { Network } from "@caravan/bitcoin"; + +// Represents the Unspent Outputs of the address +export interface AddressUtxos { + [address: string]: UTXO[]; +} + +/* +The p_score is calculated by evaluating the likelihood of self-payments, the involvement of +change outputs and the type of transaction based on number of inputs and outputs. + +We have 5 categories of transaction type each with their own impact on privacy score +- Perfect Spend (1 input, 1 output) +- Simple Spend (1 input, 2 outputs) +- UTXO Fragmentation (1 input, more than 2 standard outputs) +- Consolidation (more than 1 input, 1 output) +- CoinJoin or Mixing (more than 1 input, more than 1 output) +*/ +export enum SpendType { + PerfectSpend = "PerfectSpend", + SimpleSpend = "SimpleSpend", + UTXOFragmentation = "UTXOFragmentation", + Consolidation = "Consolidation", + MixingOrCoinJoin = "MixingOrCoinJoin", +} diff --git a/packages/caravan-health/src/wallet.test.ts b/packages/caravan-health/src/wallet.test.ts new file mode 100644 index 00000000..0b61268e --- /dev/null +++ b/packages/caravan-health/src/wallet.test.ts @@ -0,0 +1,199 @@ +import { WalletMetrics } from "./wallet"; +import { AddressUtxos, FeeRatePercentile, Transaction } from "./types"; + +const transactions: Transaction[] = [ + // transactions[0] is a perfect spend transaction + { + txid: "txid1", + vin: [ + { + prevTxId: "prevTxId1", + vout: 0, + sequence: 0, + }, + ], + vout: [ + { + scriptPubkeyHex: "scriptPubkeyHex1", + scriptPubkeyAddress: "scriptPubkeyAddress1", + value: 0.1, + }, + ], + size: 1, + weight: 1, + fee: 1, + isSend: true, + amount: 1, + block_time: 1234, + }, + // transactions[1] is a coin join transaction + { + txid: "txid2", + vin: [ + { + prevTxId: "txid1", + vout: 0, + sequence: 0, + }, + { + prevTxId: "prevTxId2", + vout: 0, + sequence: 0, + }, + ], + vout: [ + { + scriptPubkeyHex: "scriptPubkeyHex2", + scriptPubkeyAddress: "scriptPubkeyAddress2", + value: 0.2, + }, + { + scriptPubkeyHex: "scriptPubkeyHex3", + scriptPubkeyAddress: "scriptPubkeyAddress1", + value: 0.2, + }, + ], + size: 0, + weight: 0, + fee: 0, + isSend: true, + amount: 0, + block_time: 0, + }, +]; + +const utxos: AddressUtxos = { + address1: [ + { + txid: "tx1", + vout: 0, + value: 0.1, + status: { + confirmed: true, + block_time: 1234, + }, + }, + { + txid: "tx2", + vout: 0, + value: 0.2, + status: { + confirmed: true, + block_time: 1234, + }, + }, + { + txid: "tx3", + vout: 0, + value: 0.3, + status: { + confirmed: true, + block_time: 1234, + }, + }, + { + txid: "tx4", + vout: 0, + value: 0.4, + status: { + confirmed: true, + block_time: 1234, + }, + }, + ], +}; + +describe("Wallet Metrics", () => { + const walletMetrics = new WalletMetrics(transactions, utxos); + describe("UTXO Mass Factor", () => { + it("should return 1 for UTXO set length = 4", () => { + expect(Object.values(walletMetrics.utxos)[0].length).toBe(4) + expect(walletMetrics.utxoMassFactor()).toBe(1); + }); + }); + + describe("Fee Rate For Transaction", () => { + it("should return 1 for fee = 1 and weight = 1", () => { + expect(walletMetrics.getFeeRateForTransaction(transactions[0])).toBe(1); + }); + }); + + describe("Fee Rate Percentile Score", () => { + it("should return 0.5 for 50th percentile", () => { + const feeRatePercentileHistory: FeeRatePercentile[] = [ + { + avgHeight: 1234, + timestamp: 1234, + avgFee_0: 0.001, + avgFee_10: 0.01, + avgFee_25: 0.1, + avgFee_50: 1, + avgFee_75: 1.1, + avgFee_90: 1.2, + avgFee_100: 1.3, + }, + ]; + expect( + walletMetrics.getFeeRatePercentileScore( + 1234, + 1, + feeRatePercentileHistory, + ), + ).toBe(0.5); + }); + }); + + describe("Closest Percentile", () => { + it("should return 50 for 0.5 at 1229 timestamp", () => { + const feeRatePercentileHistory: FeeRatePercentile[] = [ + { + avgHeight: 1234, + timestamp: 1234, + avgFee_0: 0.001, + avgFee_10: 0.01, + avgFee_25: 0.1, + avgFee_50: 1, + avgFee_75: 1.1, + avgFee_90: 1.2, + avgFee_100: 1.3, + }, + { + avgHeight: 1230, + timestamp: 1234, + avgFee_0: 0.002, + avgFee_10: 0.02, + avgFee_25: 0.2, + avgFee_50: 1, + avgFee_75: 1.2, + avgFee_90: 1.4, + avgFee_100: 1.8, + }, + ]; + expect( + walletMetrics.getClosestPercentile(1229, 0.5, feeRatePercentileHistory), + ).toBe(50); + }); + }); + + describe("Address Reuse Map", () => { + it("should return a map of all the used or unused addresses", () => { + const addressUsageMap = walletMetrics.constructAddressUsageMap(); + + const expectedMap = new Map(); + expectedMap.set("scriptPubkeyAddress1", 2); + expectedMap.set("scriptPubkeyAddress2", 1); + + expect(addressUsageMap).toEqual(expectedMap); + }); + }); + + describe("is Address Reused", () => { + it("should return true for reused address", () => { + expect(walletMetrics.isReusedAddress("scriptPubkeyAddress1")).toBe(true); + }); + + it("should return false for unused address", () => { + expect(walletMetrics.isReusedAddress("scriptPubkeyAddress2")).toBe(false); + }); + }); +}); diff --git a/packages/caravan-health/src/wallet.ts b/packages/caravan-health/src/wallet.ts new file mode 100644 index 00000000..f6df022d --- /dev/null +++ b/packages/caravan-health/src/wallet.ts @@ -0,0 +1,145 @@ +import { AddressUtxos, Transaction, FeeRatePercentile } from "./types"; + +export class WalletMetrics { + public addressUsageMap: Map = new Map(); + public transactions: Transaction[] = []; + public utxos: AddressUtxos = {}; + + constructor(transactions?: Transaction[], utxos?: AddressUtxos) { + if (transactions) { + this.transactions = transactions; + this.addressUsageMap = this.constructAddressUsageMap(); + } + if (utxos) { + this.utxos = utxos; + } + } + /* + Name : UTXO Mass Factor + + Calculation : + The mass factor is calculated based on the number of UTXOs in the set. + + Expected Range : [0,1] + - 0 for UTXO set length >= 50 + - 0.25 for UTXO set length >= 25 and <= 49 + - 0.5 for UTXO set length >= 15 and <= 24 + - 0.75 for UTXO set length >= 5 and <= 14 + - 1 for UTXO set length < 5 + */ + utxoMassFactor(): number { + let utxoSetLength = 0; + const utxos = this.utxos; + for (const address in utxos) { + const addressUtxos = utxos[address]; + utxoSetLength += addressUtxos.length; + } + let utxoMassFactor: number; + if (utxoSetLength >= 50) { + utxoMassFactor = 0; + } else if (utxoSetLength >= 25 && utxoSetLength <= 49) { + utxoMassFactor = 0.25; + } else if (utxoSetLength >= 15 && utxoSetLength <= 24) { + utxoMassFactor = 0.5; + } else if (utxoSetLength >= 5 && utxoSetLength <= 14) { + utxoMassFactor = 0.75; + } else { + utxoMassFactor = 1; + } + return utxoMassFactor; + } + + /* + Utility function that helps to obtain the fee rate of the transaction + */ + getFeeRateForTransaction(transaction: Transaction): number { + const fees: number = transaction.fee; + const weight: number = transaction.weight; + return fees / weight; + } + + /* + Utility function that helps to obtain the percentile of the fees paid by user in tx block + */ + getFeeRatePercentileScore( + timestamp: number, + feeRate: number, + feeRatePercentileHistory: FeeRatePercentile[], + ): number { + const percentile: number = this.getClosestPercentile( + timestamp, + feeRate, + feeRatePercentileHistory, + ); + return 1 - percentile / 100; + } + + /* + Utility function that helps to obtain the closest percentile of the fees paid by user in tx block + */ + getClosestPercentile( + timestamp: number, + feeRate: number, + feeRatePercentileHistory: FeeRatePercentile[], + ): number { + // Find the closest entry by timestamp + let closestBlock: FeeRatePercentile | null = null; + let closestDifference: number = Infinity; + + for (const block of feeRatePercentileHistory) { + const difference = Math.abs(block.timestamp - timestamp); + if (difference <= closestDifference) { + closestDifference = difference; + closestBlock = block; + } + } + if (!closestBlock) { + throw new Error("No fee rate data found"); + } + // Find the closest fee rate percentile + switch (true) { + case feeRate <= closestBlock.avgFee_0: + return 0; + case feeRate <= closestBlock.avgFee_10: + return 10; + case feeRate <= closestBlock.avgFee_25: + return 25; + case feeRate <= closestBlock.avgFee_50: + return 50; + case feeRate <= closestBlock.avgFee_75: + return 75; + case feeRate <= closestBlock.avgFee_90: + return 90; + case feeRate <= closestBlock.avgFee_100: + return 100; + default: + throw new Error("Invalid fee rate"); + } + } + + constructAddressUsageMap(): Map { + const addressUsageMap: Map = new Map(); + const transactions = this.transactions; + for (const tx of transactions) { + for (const output of tx.vout) { + const address = output.scriptPubkeyAddress; + if (addressUsageMap.has(address)) { + addressUsageMap.set(address, addressUsageMap.get(address)! + 1); + } else { + addressUsageMap.set(address, 1); + } + } + } + return addressUsageMap; + } + + /* + Utility function to check if the given address was used already in past transactions + */ + isReusedAddress(address: string): boolean { + return ( + this.addressUsageMap.has(address) && + this.addressUsageMap.get(address)! > 1 + ); + } +} diff --git a/packages/caravan-health/src/waste.test.ts b/packages/caravan-health/src/waste.test.ts new file mode 100644 index 00000000..a9a61b55 --- /dev/null +++ b/packages/caravan-health/src/waste.test.ts @@ -0,0 +1,190 @@ +import { AddressUtxos } from "./types"; +import { WasteMetrics } from "./waste"; + +const transactions = [ + { + vin: [], // List of inputs + vout: [], // List of outputs + txid: "tx1", // Transaction ID + size: 1, // Size of the transaction + weight: 1, // Weight of the transaction + fee: 1, // Fee paid in the transaction + isSend: true, // Transaction is a send transaction + amount: 10, // Amount spent in the transaction + block_time: 1234, // Blocktime of the block where the transactions were mined + }, + { + vin: [], + vout: [], + txid: "tx2", + size: 0, + weight: 1, + fee: 1, + isSend: false, + amount: 10, + block_time: 1234, + }, +]; + +const feeRatePercentileHistory = [ + { + avgHeight: 0, // Height of the block where the transactions were mined + timestamp: 1234, // Blocktime of the block where the transactions were mined + avgFee_0: 0.1, // Lowest fee rate in the block was 0.1 sat/vbyte + avgFee_10: 0.2, // 10th percentile fee rate in the block was 0.2 sat/vbyte + avgFee_25: 0.5, // 25th percentile fee rate in the block was 0.5 sat/vbyte + avgFee_50: 1, // Median fee rate in the block was 1 sat/vbyte + avgFee_75: 1.5, // 75th percentile fee rate in the block was 1.5 sat/vbyte + avgFee_90: 2, // 90th percentile fee rate in the block was 2 sat/vbyte + avgFee_100: 2.5, // Highest fee rate in the block was 2.5 sat/vbyte + }, +]; + +const utxos: AddressUtxos = { + address1: [ + { + txid: "tx1", + vout: 0, + value: 0.1, + status: { + confirmed: true, + block_time: 1234, + }, + }, + { + txid: "tx2", + vout: 0, + value: 0.2, + status: { + confirmed: true, + block_time: 1234, + }, + }, + { + txid: "tx3", + vout: 0, + value: 0.3, + status: { + confirmed: true, + block_time: 1234, + }, + }, + { + txid: "tx4", + vout: 0, + value: 0.4, + status: { + confirmed: true, + block_time: 1234, + }, + }, + ], +}; + +describe("Waste metric scoring", () => { + const wasteMetric = new WasteMetrics(transactions, utxos); + + describe("Relative Fees Score (R.F.S)", () => { + it("calculates fee score based on tx fee rate relative to percentile in the block where a set of send tx were mined", () => { + const score: number = wasteMetric.relativeFeesScore( + feeRatePercentileHistory, + ); + expect(score).toBe(0.5); + }); + }); + + describe("Fees to Amount Ratio (F.A.R)", () => { + it("Fees paid over total amount spent as ratio for a 'send' type transaction", () => { + const ratio: number = wasteMetric.feesToAmountRatio(); + expect(ratio).toBe(0.1); + }); + }); + + describe("Spend Waste Amount (S.W.A)", () => { + it("determines the cost of keeping or spending the UTXOs at given point of time", () => { + // Input UTXO Set : [0.1 BTC, 0.2 BTC, 0.3 BTC, 0.4 BTC] + // Weight : 30 vbytes + // Current Fee Rate : 10 sat/vbyte + // Input Amount Sum : 10000 sats + // Spend Amount : 8000 sats + // Estimated Long Term Fee Rate : 15 sat/vbyte + const weight = 30; // Estimated weight of the spending transaction + const feeRate = 10; // Current Fee-Rate + const inputAmountSum = 10000; // Sum of all inputs in the spending transaction + const spendAmount = 8000; // Amount spent in the transaction + const estimatedLongTermFeeRate = 15; // Estimated long term fee rate + + const wasteAmount = wasteMetric.spendWasteAmount( + weight, + feeRate, + inputAmountSum, + spendAmount, + estimatedLongTermFeeRate, + ); + expect(wasteAmount).toBe(1850); + // This number is positive this means that in future if we spend the UTXOs now, + // we will be saving 1850 sats in fees. This is because in future the fee rate + // is expected to increase from 10 sat/vbyte to 15 sat/vbyte. + }); + }); + + describe("Dust Limits", () => { + const config = { + requiredSignerCount: 2, // Provide the required property m + totalSignerCount: 3, // Provide the required property n + }; + it("calculates the lower and upper limit of the dust amount for P2SH script type and 1.5 risk multiplier", () => { + const uninitializedWasteMetric = new WasteMetrics(); + const { lowerLimit, upperLimit } = + uninitializedWasteMetric.calculateDustLimits(10, "P2SH", config, 1.5); + expect(lowerLimit).toBe(2480); + expect(upperLimit).toBe(3720); + }); + + it("calculates the lower and upper limit of the dust amount for P2WSH script type and 1.5 risk multiplier", () => { + const uninitializedWasteMetric = new WasteMetrics(); + const { lowerLimit, upperLimit } = + uninitializedWasteMetric.calculateDustLimits(10, "P2WSH", config, 1.5); + expect(lowerLimit).toBe(2580); + expect(upperLimit).toBe(3870); + }); + + it("calculates the lower and upper limit of the dust amount for P2PKH script type and 1.5 risk multiplier", () => { + const uninitializedWasteMetric = new WasteMetrics(); + const { lowerLimit, upperLimit } = + uninitializedWasteMetric.calculateDustLimits(10, "P2PKH", config, 1.5); + expect(lowerLimit).toBe(1315); + expect(upperLimit).toBe(1972.5); + }); + + it("calculates the lower and upper limit of the dust amount for P2TR script type and 1.5 risk multiplier", () => { + const uninitializedWasteMetric = new WasteMetrics(); + const { lowerLimit, upperLimit } = + uninitializedWasteMetric.calculateDustLimits(10, "P2TR", config, 1.5); + expect(lowerLimit).toBe(575); + expect(upperLimit).toBe(862.5); + }); + + it("calculates the lower and upper limit of the dust amount for P2SH-P2WSH script type and 1.5 risk multiplier", () => { + const uninitializedWasteMetric = new WasteMetrics(); + const { lowerLimit, upperLimit } = + uninitializedWasteMetric.calculateDustLimits( + 10, + "P2SH-P2WSH", + config, + 1.5, + ); + expect(lowerLimit).toBe(610); + expect(upperLimit).toBe(915); + }); + }); + + describe("Weighted Waste Score (W.W.S)", () => { + it("calculates the overall waste of the wallet based on the relative fees score, fees to amount ratio and the UTXO mass factor", () => { + const score: number = wasteMetric.weightedWasteScore( + feeRatePercentileHistory, + ); + expect(score).toBe(0.51); + }); + }); +}); diff --git a/packages/caravan-health/src/waste.ts b/packages/caravan-health/src/waste.ts new file mode 100644 index 00000000..667dc7d6 --- /dev/null +++ b/packages/caravan-health/src/waste.ts @@ -0,0 +1,234 @@ +import { FeeRatePercentile, Transaction, MultisigAddressType } from "./types"; +import { WalletMetrics } from "./wallet"; +import { getWitnessSize } from "@caravan/bitcoin"; + +export class WasteMetrics extends WalletMetrics { + /* + Name : + Relative Fees Score (R.F.S) + + Definition : + Comparision of the fees paid by the wallet transactions in a block relative to + the fees paid by other transactions in the same block on the same network. + + Calculation : + We take the percentile value of the fees paid by the user in the block of the transaction. + And then we obtain the mean percentile score for all the transaction done in a wallet. + + Expected Range : [0, 1] + -> Very Poor : [0, 0.2] + -> Poor : (0.2, 0.4] + -> Moderate : (0.4, 0.6] + -> Good : (0.6, 0.8] + -> Very Good : (0.8, 1] + */ + relativeFeesScore(feeRatePercentileHistory: FeeRatePercentile[]): number { + let sumRFS: number = 0; + let numberOfSendTx: number = 0; + const transactions = this.transactions; + for (const tx of transactions) { + if (tx.isSend === true) { + numberOfSendTx++; + const feeRate: number = this.getFeeRateForTransaction(tx); + const RFS: number = this.getFeeRatePercentileScore( + tx.block_time, + feeRate, + feeRatePercentileHistory, + ); + sumRFS += RFS; + } + } + return sumRFS / numberOfSendTx; + } + + /* + Name : + Fees To Amount Ratio (F.A.R) + + Definition : + Ratio of the fees paid by the wallet transactions to the amount spent in the transaction. + + In the future, we can make this more accurate by comparing fees to the fee market at the time the transaction was sent. This will indicate if transactions typically pay within or out of the range of the rest of the market. + + Calculation : + We can compare this ratio against the fiat charges for cross-border transactions. + Mastercard charges 0.6% cross-border fee for international transactions in US dollars, + but if the transaction is in any other currency the fee goes up to 1%. + Source : https://www.clearlypayments.com/blog/what-are-cross-border-fees-in-credit-card-payments/ + + Expected Range : [0, 1] + -> Very Poor : [1, 0.01] // More than 1% amount paid as fees. In ratio 1% is 0.01 and so on for other range + -> Poor : (0.01, 0.0075] + -> Moderate : (0.0075, 0.006] + -> Good : (0.006, 0.001] + -> Very Good : (0.001, 0) + */ + feesToAmountRatio(): number { + let sumFeesToAmountRatio: number = 0; + let numberOfSendTx: number = 0; + const transactions = this.transactions; + transactions.forEach((tx: Transaction) => { + if (tx.isSend === true) { + sumFeesToAmountRatio += tx.fee / tx.amount; + numberOfSendTx++; + } + }); + return sumFeesToAmountRatio / numberOfSendTx; + } + + /* + Name : + Spend Waste Amount (S.W.A) + + Definition : + A quantity that indicates whether it is economical to spend a particular output now in a given transaction + or wait to consolidate it later when fees could be low. + + Important Terms: + - Weight: + Transaction weight units + - Fee Rate: + The transaction's target fee rate (current fee-rate of the network) + - Estimated Long Term Fee Rate: + The long-term fee rate estimate which the wallet might need to pay + to redeem remaining UTXOs. + Reference : https://bitcoincore.reviews/17331#l-164 + It is the upper bound for spending the UTXO in the future. + - Change: + The cost of creating and spending a change output. It includes the fees paid + on this transaction's change output plus the fees that will need to be paid + to spend it later. + - Excess: + The amount by which we exceed our selection target when creating a changeless transaction, + mutually exclusive with cost of change. It is extra fees paid if we don't make a change output + and instead add the difference to the fees. + - Input Amount : + Sum of amount for each coin in input of the transaction + - Spend Amount : + Exact amount wanted to be spent in the transaction. + + Calculation : + spend waste amount = consolidation factor + cost of transaction + spend waste amount = weight (fee rate - estimatedLongTermFeeRate) + change + excess + + Observation : + Depending on the fee rate in the long term, the consolidation factor can either be positive or negative. + fee rate (current) < estimatedLongTermFeeRate (long-term fee rate) –-> Consolidate now (-ve) + fee rate (current) > estimatedLongTermFeeRate (long-term fee rate) –-> Wait for later when fee rate go low (+ve) + + */ + spendWasteAmount( + weight: number, // Estimated weight of the transaction + feeRate: number, // Current fee rate for the transaction + inputAmountSum: number, // Sum of amount for each coin in input of the transaction + spendAmount: number, // Exact amount wanted to be spent in the transaction + estimatedLongTermFeeRate: number, // Long term estimated fee-rate + ): number { + const costOfTx: number = Math.abs(spendAmount - inputAmountSum); + return weight * (feeRate - estimatedLongTermFeeRate) + costOfTx; + } + + /* + Name : calculateDustLimits + Definition : + Dust limits are the limits that help to determine the lower and upper limit of the UTXO + that can be spent economically. + The lower limit is below which the UTXO will actually behave as a dust output and the + upper limit is above which the UTXO will be safe and economical to spend. + + Calculation : + lowerLimit - Below which the UTXO will actually behave as a dust output. + upperLimit - Above which the UTXO will be safe and economical to spend. + config - It takes two parameters, requiredSignerCount and totalSignerCount + Eg : For a 2-of-3 Multisig wallet the config will be + config : {requiredSignerCount: 2, totalSignerCount: 3} + riskMultiplier - + The riskMultiplier is a factor that scales the lower limit of a UTXO to determine its + upper limit. Based on their risk tolerance and expected fee volatility, a higher + multiplier provides a greater buffer but may unnecessarily categorize some UTXOs as + safe that could otherwise be considered risky. The default value is set to 2 as a + balanced approach. It doubles the lower limit, providing a reasonable buffer for most + common fee scenarios without being overly conservative. + + + lowerLimit = input_size (vB) * feeRate (sats/vByte) + upperLimit = lowerLimit * riskMultiplier + + */ + calculateDustLimits( + feeRate: number, + scriptType: MultisigAddressType, + config: { + requiredSignerCount: number; + totalSignerCount: number; + }, + riskMultiplier: number = 2, + ): { lowerLimit: number; upperLimit: number } { + if (riskMultiplier <= 1) { + throw new Error("Risk Multiplier should be greater than 1"); + } + + let vsize: number; + if (scriptType === "P2SH") { + const signatureLength = 72 + 1; // approx including push byte + const keylength = 33 + 1; // push byte + vsize = + signatureLength * config.requiredSignerCount + + keylength * config.totalSignerCount; + } else if (scriptType === "P2WSH") { + let total = 0; + total += 1; // segwit marker + total += 1; // segwit flag + total += getWitnessSize( + config.requiredSignerCount, + config.totalSignerCount, + ); + vsize = total; + } else if (scriptType === "P2SH-P2WSH") { + const signatureLength = 72; + const keylength = 33; + const witnessSize = + signatureLength * config.requiredSignerCount + + keylength * config.totalSignerCount; + vsize = Math.ceil(0.25 * witnessSize); + } else if (scriptType === "P2TR") { + // Reference : https://bitcoin.stackexchange.com/questions/111395/what-is-the-weight-of-a-p2tr-input + // Optimistic key-path-spend input size + vsize = 57.5; + } else if (scriptType === "P2PKH") { + // Reference : https://medium.com/coinmonks/on-bitcoin-transaction-sizes-97e31bc9d816 + vsize = 131.5; + } else { + vsize = 546; // Worst Case + } + const lowerLimit: number = vsize * feeRate; + const upperLimit: number = lowerLimit * riskMultiplier; + return { lowerLimit, upperLimit }; + } + + /* + Name : + Weighted Waste Score (W.W.S) + + Definition : + A score that indicates the overall waste of the wallet based on the relative fees score, + fees to amount ratio and the UTXO mass factor. + + Calculation : + weighted waste score = 0.35 * RFS + 0.35 * FAR + 0.3 * UMF + + Expected Range : [0, 1] + -> Very Poor : [0, 0.2] + -> Poor : (0.2, 0.4] + -> Moderate : (0.4, 0.6] + -> Good : (0.6, 0.8] + -> Very Good : (0.8, 1] + */ + + weightedWasteScore(feeRatePercentileHistory: FeeRatePercentile[]): number { + const RFS = this.relativeFeesScore(feeRatePercentileHistory); + const FAR = this.feesToAmountRatio(); + const UMF = this.utxoMassFactor(); + return 0.35 * RFS + 0.35 * FAR + 0.3 * UMF; + } +} diff --git a/packages/caravan-health/tsconfig.json b/packages/caravan-health/tsconfig.json new file mode 100644 index 00000000..1f2d76fd --- /dev/null +++ b/packages/caravan-health/tsconfig.json @@ -0,0 +1,4 @@ +// tsconfig.json +{ + "extends": "@caravan/typescript-config/base.json" +} diff --git a/packages/caravan-health/tsup.config.js b/packages/caravan-health/tsup.config.js new file mode 100644 index 00000000..2aee42e6 --- /dev/null +++ b/packages/caravan-health/tsup.config.js @@ -0,0 +1,6 @@ +import { defineConfig } from 'tsup'; +import { polyfillNode } from "esbuild-plugin-polyfill-node"; + +export default defineConfig({ + esbuildPlugins: [polyfillNode()], +});