From 55d8e713647b0be35abc9c154076123c2fb6a4e8 Mon Sep 17 00:00:00 2001 From: Deggen Date: Mon, 25 Aug 2025 10:37:17 -0500 Subject: [PATCH 1/8] Add LivePolicy feeModel --- src/transaction/fee-models/LivePolicy.ts | 121 +++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 src/transaction/fee-models/LivePolicy.ts diff --git a/src/transaction/fee-models/LivePolicy.ts b/src/transaction/fee-models/LivePolicy.ts new file mode 100644 index 00000000..2d78ec94 --- /dev/null +++ b/src/transaction/fee-models/LivePolicy.ts @@ -0,0 +1,121 @@ +import FeeModel from '../FeeModel.js' +import Transaction from '../Transaction.js' + +/** + * Represents a live fee policy that fetches current rates from ARC GorillaPool. + */ +export default class LivePolicy implements FeeModel { + private static readonly ARC_POLICY_URL = 'https://arc.gorillapool.io/v1/policy' + private cachedRate: number | null = null + private cacheTimestamp: number = 0 + private readonly cacheValidityMs: number + + /** + * Constructs an instance of the live policy fee model. + * + * @param {number} cacheValidityMs - How long to cache the fee rate in milliseconds (default: 5 minutes) + */ + constructor(cacheValidityMs: number = 5 * 60 * 1000) { + this.cacheValidityMs = cacheValidityMs + } + + /** + * Fetches the current fee rate from ARC GorillaPool API. + * + * @returns The current satoshis per kilobyte rate + */ + private async fetchFeeRate(): Promise { + const now = Date.now() + + // Return cached rate if still valid + if (this.cachedRate !== null && (now - this.cacheTimestamp) < this.cacheValidityMs) { + return this.cachedRate + } + + try { + const response = await fetch(LivePolicy.ARC_POLICY_URL) + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const response_data = await response.json() + + if (!response_data.policy?.miningFee || typeof response_data.policy.miningFee.satoshis !== 'number' || typeof response_data.policy.miningFee.bytes !== 'number') { + throw new Error('Invalid policy response format') + } + + // Convert to satoshis per kilobyte + const rate = (response_data.policy.miningFee.satoshis / response_data.policy.miningFee.bytes) * 1000 + + // Cache the result + this.cachedRate = rate + this.cacheTimestamp = now + + return rate + } catch (error) { + // If we have a cached rate, use it as fallback + if (this.cachedRate !== null) { + console.warn('Failed to fetch live fee rate, using cached value:', error) + return this.cachedRate + } + + // Otherwise, use a reasonable default (100 sat/kb) + console.warn('Failed to fetch live fee rate, using default 100 sat/kb:', error) + return 100 + } + } + + /** + * Computes the fee for a given transaction using the current live rate. + * + * @param tx The transaction for which a fee is to be computed. + * @returns The fee in satoshis for the transaction. + */ + async computeFee(tx: Transaction): Promise { + const rate = await this.fetchFeeRate() + const getVarIntSize = (i: number): number => { + if (i > 2 ** 32) { + return 9 + } else if (i > 2 ** 16) { + return 5 + } else if (i > 253) { + return 3 + } else { + return 1 + } + } + // Compute the (potentially estimated) size of the transaction + let size = 4 // version + size += getVarIntSize(tx.inputs.length) // number of inputs + for (let i = 0; i < tx.inputs.length; i++) { + const input = tx.inputs[i] + size += 40 // txid, output index, sequence number + let scriptLength: number + if (typeof input.unlockingScript === 'object') { + scriptLength = input.unlockingScript.toBinary().length + } else if (typeof input.unlockingScriptTemplate === 'object') { + scriptLength = await input.unlockingScriptTemplate.estimateLength( + tx, + i + ) + } else { + throw new Error( + 'All inputs must have an unlocking script or an unlocking script template for sat/kb fee computation.' + ) + } + size += getVarIntSize(scriptLength) // unlocking script length + size += scriptLength // unlocking script + } + size += getVarIntSize(tx.outputs.length) // number of outputs + for (const out of tx.outputs) { + size += 8 // satoshis + const length = out.lockingScript.toBinary().length + size += getVarIntSize(length) // script length + size += length // script + } + size += 4 // lock time + // We'll use Math.ceil to ensure the miners get the extra satoshi. + const fee = Math.ceil((size / 1000) * rate) + return fee + } +} From 5f4bbdf15b90d663906c943d1582e59185fd5309 Mon Sep 17 00:00:00 2001 From: Deggen Date: Mon, 25 Aug 2025 10:38:40 -0500 Subject: [PATCH 2/8] 1.6.25 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 66caafa7..9a6e0506 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bsv/sdk", - "version": "1.6.23", + "version": "1.6.25", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bsv/sdk", - "version": "1.6.23", + "version": "1.6.25", "license": "SEE LICENSE IN LICENSE.txt", "devDependencies": { "@eslint/js": "^9.23.0", diff --git a/package.json b/package.json index 9fc6190d..f3b165ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bsv/sdk", - "version": "1.6.24", + "version": "1.6.25", "type": "module", "description": "BSV Blockchain Software Development Kit", "main": "dist/cjs/mod.js", From 3705f8b6212d036361c2571c64a0d908f7da6331 Mon Sep 17 00:00:00 2001 From: Deggen Date: Mon, 25 Aug 2025 10:39:17 -0500 Subject: [PATCH 3/8] changelog --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6b04839..54f7f6d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. The format ## Table of Contents - [Unreleased](#unreleased) +- [1.6.25 - 2025-08-25](#1625---2025-08-25) - [1.6.24 - 2025-08-19](#1620---2025-07-22) - [1.6.20 - 2025-07-22](#1620---2025-07-22) - [1.6.19 - 2025-07-21](#1619---2025-07-21) @@ -150,6 +151,14 @@ All notable changes to this project will be documented in this file. The format --- +### [1.6.25] - 2025-08-25 + +### Added + +- **LivePolicy fee model**: New dynamic fee model that fetches current rates from ARC GorillaPool API (`https://arc.gorillapool.io/v1/policy`) with intelligent caching and fallback mechanisms + +--- + ### [1.6.24] - 2025-08-19 ### Fixed From 99fa1754c17c123d80d02ccdaca0ae78bc5dc75f Mon Sep 17 00:00:00 2001 From: Deggen Date: Mon, 25 Aug 2025 10:47:55 -0500 Subject: [PATCH 4/8] use singleton approach --- src/transaction/Transaction.ts | 8 +-- src/transaction/fee-models/LivePolicy.ts | 14 +++++ .../fee-models/__tests/LivePolicy.test.ts | 59 +++++++++++++++++++ src/transaction/fee-models/index.ts | 1 + 4 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 src/transaction/fee-models/__tests/LivePolicy.test.ts diff --git a/src/transaction/Transaction.ts b/src/transaction/Transaction.ts index 5d9c8439..45e93814 100644 --- a/src/transaction/Transaction.ts +++ b/src/transaction/Transaction.ts @@ -6,7 +6,7 @@ import LockingScript from '../script/LockingScript.js' import { Reader, Writer, toHex, toArray } from '../primitives/utils.js' import { hash256 } from '../primitives/Hash.js' import FeeModel from './FeeModel.js' -import SatoshisPerKilobyte from './fee-models/SatoshisPerKilobyte.js' +import LivePolicy from './fee-models/LivePolicy.js' import { Broadcaster, BroadcastResponse, BroadcastFailure } from './Broadcaster.js' import MerklePath from './MerklePath.js' import Spend from '../script/Spend.js' @@ -411,7 +411,7 @@ export default class Transaction { /** * Computes fees prior to signing. - * If no fee model is provided, uses a SatoshisPerKilobyte fee model that pays 1 sat/kb. + * If no fee model is provided, uses a LivePolicy fee model that fetches current rates from ARC. * If fee is a number, the transaction uses that value as fee. * * @param modelOrFee - The initialized fee model to use or fixed fee for the transaction @@ -420,7 +420,7 @@ export default class Transaction { * */ async fee ( - modelOrFee: FeeModel | number = new SatoshisPerKilobyte(1), + modelOrFee: FeeModel | number = LivePolicy.getInstance(), changeDistribution: 'equal' | 'random' = 'equal' ): Promise { this.cachedHash = undefined @@ -774,7 +774,7 @@ export default class Transaction { * * @returns Whether the transaction is valid according to the rules of SPV. * - * @example tx.verify(new WhatsOnChain(), new SatoshisPerKilobyte(1)) + * @example tx.verify(new WhatsOnChain(), LivePolicy.getInstance()) */ async verify ( chainTracker: ChainTracker | 'scripts only' = defaultChainTracker(), diff --git a/src/transaction/fee-models/LivePolicy.ts b/src/transaction/fee-models/LivePolicy.ts index 2d78ec94..ac6f45ef 100644 --- a/src/transaction/fee-models/LivePolicy.ts +++ b/src/transaction/fee-models/LivePolicy.ts @@ -6,6 +6,7 @@ import Transaction from '../Transaction.js' */ export default class LivePolicy implements FeeModel { private static readonly ARC_POLICY_URL = 'https://arc.gorillapool.io/v1/policy' + private static instance: LivePolicy | null = null private cachedRate: number | null = null private cacheTimestamp: number = 0 private readonly cacheValidityMs: number @@ -19,6 +20,19 @@ export default class LivePolicy implements FeeModel { this.cacheValidityMs = cacheValidityMs } + /** + * Gets the singleton instance of LivePolicy to ensure cache sharing across the application. + * + * @param {number} cacheValidityMs - How long to cache the fee rate in milliseconds (default: 5 minutes) + * @returns The singleton LivePolicy instance + */ + static getInstance(cacheValidityMs: number = 5 * 60 * 1000): LivePolicy { + if (!LivePolicy.instance) { + LivePolicy.instance = new LivePolicy(cacheValidityMs) + } + return LivePolicy.instance + } + /** * Fetches the current fee rate from ARC GorillaPool API. * diff --git a/src/transaction/fee-models/__tests/LivePolicy.test.ts b/src/transaction/fee-models/__tests/LivePolicy.test.ts new file mode 100644 index 00000000..0123bce4 --- /dev/null +++ b/src/transaction/fee-models/__tests/LivePolicy.test.ts @@ -0,0 +1,59 @@ +import LivePolicy from '../LivePolicy.js' + +describe('LivePolicy', () => { + beforeEach(() => { + // Reset singleton instance before each test + ;(LivePolicy as any).instance = null + }) + + it('should return the same instance when getInstance is called multiple times', () => { + const instance1 = LivePolicy.getInstance() + const instance2 = LivePolicy.getInstance() + + expect(instance1).toBe(instance2) + expect(instance1).toBeInstanceOf(LivePolicy) + }) + + it('should share cache between singleton instances', async () => { + const instance1 = LivePolicy.getInstance() + const instance2 = LivePolicy.getInstance() + + // Mock fetch to return a specific rate + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + policy: { + miningFee: { + satoshis: 5, + bytes: 1000 + } + } + }) + }) + + // Create a mock transaction + const mockTx = { + inputs: [], + outputs: [] + } as any + + // First call should fetch from API + const fee1 = await instance1.computeFee(mockTx) + + // Second call should use cached value (no additional fetch) + const fee2 = await instance2.computeFee(mockTx) + + expect(fee1).toBe(fee2) + expect(fee1).toBe(1) // 1 because rate is 5 sat/1000 bytes * 1000 = 5 sat/kb, and minimum tx size gets 1 sat + expect(global.fetch).toHaveBeenCalledTimes(1) // Only called once due to caching + }) + + it('should allow different cache validity when creating singleton', () => { + const instance1 = LivePolicy.getInstance(10000) // 10 seconds + const instance2 = LivePolicy.getInstance(20000) // 20 seconds (should be ignored) + + expect(instance1).toBe(instance2) + // The first call determines the cache validity + expect((instance1 as any).cacheValidityMs).toBe(10000) + }) +}) diff --git a/src/transaction/fee-models/index.ts b/src/transaction/fee-models/index.ts index 3f94aed1..05e68419 100644 --- a/src/transaction/fee-models/index.ts +++ b/src/transaction/fee-models/index.ts @@ -1 +1,2 @@ export { default as SatoshisPerKilobyte } from './SatoshisPerKilobyte.js' +export { default as LivePolicy } from './LivePolicy.js' From 521e610cdbeacff4768d2d83d769b7cb5d76b5a0 Mon Sep 17 00:00:00 2001 From: Deggen Date: Mon, 25 Aug 2025 10:49:33 -0500 Subject: [PATCH 5/8] docs --- docs/reference/transaction.md | 128 +++++++++++++++++++--------------- 1 file changed, 73 insertions(+), 55 deletions(-) diff --git a/docs/reference/transaction.md b/docs/reference/transaction.md index 002ee487..f8958cd3 100644 --- a/docs/reference/transaction.md +++ b/docs/reference/transaction.md @@ -456,9 +456,9 @@ Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions]( | [BeefParty](#class-beefparty) | | [BeefTx](#class-beeftx) | | [FetchHttpClient](#class-fetchhttpclient) | +| [LivePolicy](#class-livepolicy) | | [MerklePath](#class-merklepath) | | [NodejsHttpClient](#class-nodejshttpclient) | -| [SatoshisPerKilobyte](#class-satoshisperkilobyte) | | [Transaction](#class-transaction) | | [WhatsOnChain](#class-whatsonchain) | @@ -1198,6 +1198,72 @@ See also: [Fetch](./transaction.md#type-fetch), [HttpClient](./transaction.md#in Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) +--- +### Class: LivePolicy + +Represents a live fee policy that fetches current rates from ARC GorillaPool. + +```ts +export default class LivePolicy implements FeeModel { + constructor(cacheValidityMs: number = 5 * 60 * 1000) + static getInstance(cacheValidityMs: number = 5 * 60 * 1000): LivePolicy + async computeFee(tx: Transaction): Promise +} +``` + +See also: [FeeModel](./transaction.md#interface-feemodel), [Transaction](./transaction.md#class-transaction) + +#### Constructor + +Constructs an instance of the live policy fee model. + +```ts +constructor(cacheValidityMs: number = 5 * 60 * 1000) +``` + +Argument Details + ++ **cacheValidityMs** + + How long to cache the fee rate in milliseconds (default: 5 minutes) + +#### Method computeFee + +Computes the fee for a given transaction using the current live rate. + +```ts +async computeFee(tx: Transaction): Promise +``` +See also: [Transaction](./transaction.md#class-transaction) + +Returns + +The fee in satoshis for the transaction. + +Argument Details + ++ **tx** + + The transaction for which a fee is to be computed. + +#### Method getInstance + +Gets the singleton instance of LivePolicy to ensure cache sharing across the application. + +```ts +static getInstance(cacheValidityMs: number = 5 * 60 * 1000): LivePolicy +``` +See also: [LivePolicy](./transaction.md#class-livepolicy) + +Returns + +The singleton LivePolicy instance + +Argument Details + ++ **cacheValidityMs** + + How long to cache the fee rate in milliseconds (default: 5 minutes) + +Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) + --- ### Class: MerklePath @@ -1420,54 +1486,6 @@ See also: [HttpClient](./transaction.md#interface-httpclient), [HttpClientReques Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) ---- -### Class: SatoshisPerKilobyte - -Represents the "satoshis per kilobyte" transaction fee model. - -```ts -export default class SatoshisPerKilobyte implements FeeModel { - value: number; - constructor(value: number) - async computeFee(tx: Transaction): Promise -} -``` - -See also: [FeeModel](./transaction.md#interface-feemodel), [Transaction](./transaction.md#class-transaction) - -#### Constructor - -Constructs an instance of the sat/kb fee model. - -```ts -constructor(value: number) -``` - -Argument Details - -+ **value** - + The number of satoshis per kilobyte to charge as a fee. - -#### Method computeFee - -Computes the fee for a given transaction. - -```ts -async computeFee(tx: Transaction): Promise -``` -See also: [Transaction](./transaction.md#class-transaction) - -Returns - -The fee in satoshis for the transaction, as a BigNumber. - -Argument Details - -+ **tx** - + The transaction for which a fee is to be computed. - -Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) - --- ### Class: Transaction @@ -1520,7 +1538,7 @@ export default class Transaction { addOutput(output: TransactionOutput): void addP2PKHOutput(address: number[] | string, satoshis?: number): void updateMetadata(metadata: Record): void - async fee(modelOrFee: FeeModel | number = new SatoshisPerKilobyte(1), changeDistribution: "equal" | "random" = "equal"): Promise + async fee(modelOrFee: FeeModel | number = LivePolicy.getInstance(), changeDistribution: "equal" | "random" = "equal"): Promise getFee(): number async sign(): Promise async broadcast(broadcaster: Broadcaster = defaultBroadcaster()): Promise @@ -1540,7 +1558,7 @@ export default class Transaction { } ``` -See also: [BroadcastFailure](./transaction.md#interface-broadcastfailure), [BroadcastResponse](./transaction.md#interface-broadcastresponse), [Broadcaster](./transaction.md#interface-broadcaster), [ChainTracker](./transaction.md#interface-chaintracker), [FeeModel](./transaction.md#interface-feemodel), [MerklePath](./transaction.md#class-merklepath), [Reader](./primitives.md#class-reader), [SatoshisPerKilobyte](./transaction.md#class-satoshisperkilobyte), [TransactionInput](./transaction.md#interface-transactioninput), [TransactionOutput](./transaction.md#interface-transactionoutput), [defaultBroadcaster](./transaction.md#function-defaultbroadcaster), [defaultChainTracker](./transaction.md#function-defaultchaintracker), [sign](./compat.md#variable-sign), [toHex](./primitives.md#variable-tohex), [verify](./compat.md#variable-verify) +See also: [BroadcastFailure](./transaction.md#interface-broadcastfailure), [BroadcastResponse](./transaction.md#interface-broadcastresponse), [Broadcaster](./transaction.md#interface-broadcaster), [ChainTracker](./transaction.md#interface-chaintracker), [FeeModel](./transaction.md#interface-feemodel), [LivePolicy](./transaction.md#class-livepolicy), [MerklePath](./transaction.md#class-merklepath), [Reader](./primitives.md#class-reader), [TransactionInput](./transaction.md#interface-transactioninput), [TransactionOutput](./transaction.md#interface-transactionoutput), [defaultBroadcaster](./transaction.md#function-defaultbroadcaster), [defaultChainTracker](./transaction.md#function-defaultchaintracker), [sign](./compat.md#variable-sign), [toHex](./primitives.md#variable-tohex), [verify](./compat.md#variable-verify) #### Method addInput @@ -1610,13 +1628,13 @@ Argument Details #### Method fee Computes fees prior to signing. -If no fee model is provided, uses a SatoshisPerKilobyte fee model that pays 1 sat/kb. +If no fee model is provided, uses a LivePolicy fee model that fetches current rates from ARC. If fee is a number, the transaction uses that value as fee. ```ts -async fee(modelOrFee: FeeModel | number = new SatoshisPerKilobyte(1), changeDistribution: "equal" | "random" = "equal"): Promise +async fee(modelOrFee: FeeModel | number = LivePolicy.getInstance(), changeDistribution: "equal" | "random" = "equal"): Promise ``` -See also: [FeeModel](./transaction.md#interface-feemodel), [SatoshisPerKilobyte](./transaction.md#class-satoshisperkilobyte) +See also: [FeeModel](./transaction.md#interface-feemodel), [LivePolicy](./transaction.md#class-livepolicy) Argument Details @@ -2037,7 +2055,7 @@ Argument Details Example ```ts -tx.verify(new WhatsOnChain(), new SatoshisPerKilobyte(1)) +tx.verify(new WhatsOnChain(), LivePolicy.getInstance()) ``` Links: [API](#api), [Interfaces](#interfaces), [Classes](#classes), [Functions](#functions), [Types](#types), [Enums](#enums), [Variables](#variables) From 0bfbe5b4c5e5b3ffc10ad59c3f78cb9c61f21779 Mon Sep 17 00:00:00 2001 From: Deggen Date: Mon, 25 Aug 2025 10:57:57 -0500 Subject: [PATCH 6/8] dedup code --- src/transaction/fee-models/LivePolicy.ts | 54 ++++-------------------- 1 file changed, 8 insertions(+), 46 deletions(-) diff --git a/src/transaction/fee-models/LivePolicy.ts b/src/transaction/fee-models/LivePolicy.ts index ac6f45ef..1bba80d2 100644 --- a/src/transaction/fee-models/LivePolicy.ts +++ b/src/transaction/fee-models/LivePolicy.ts @@ -1,10 +1,11 @@ -import FeeModel from '../FeeModel.js' +import SatoshisPerKilobyte from './SatoshisPerKilobyte.js' import Transaction from '../Transaction.js' /** * Represents a live fee policy that fetches current rates from ARC GorillaPool. + * Extends SatoshisPerKilobyte to reuse transaction size calculation logic. */ -export default class LivePolicy implements FeeModel { +export default class LivePolicy extends SatoshisPerKilobyte { private static readonly ARC_POLICY_URL = 'https://arc.gorillapool.io/v1/policy' private static instance: LivePolicy | null = null private cachedRate: number | null = null @@ -17,6 +18,7 @@ export default class LivePolicy implements FeeModel { * @param {number} cacheValidityMs - How long to cache the fee rate in milliseconds (default: 5 minutes) */ constructor(cacheValidityMs: number = 5 * 60 * 1000) { + super(100) // Initialize with dummy value, will be overridden by fetchFeeRate this.cacheValidityMs = cacheValidityMs } @@ -81,55 +83,15 @@ export default class LivePolicy implements FeeModel { /** * Computes the fee for a given transaction using the current live rate. + * Overrides the parent method to use dynamic rate fetching. * * @param tx The transaction for which a fee is to be computed. * @returns The fee in satoshis for the transaction. */ async computeFee(tx: Transaction): Promise { const rate = await this.fetchFeeRate() - const getVarIntSize = (i: number): number => { - if (i > 2 ** 32) { - return 9 - } else if (i > 2 ** 16) { - return 5 - } else if (i > 253) { - return 3 - } else { - return 1 - } - } - // Compute the (potentially estimated) size of the transaction - let size = 4 // version - size += getVarIntSize(tx.inputs.length) // number of inputs - for (let i = 0; i < tx.inputs.length; i++) { - const input = tx.inputs[i] - size += 40 // txid, output index, sequence number - let scriptLength: number - if (typeof input.unlockingScript === 'object') { - scriptLength = input.unlockingScript.toBinary().length - } else if (typeof input.unlockingScriptTemplate === 'object') { - scriptLength = await input.unlockingScriptTemplate.estimateLength( - tx, - i - ) - } else { - throw new Error( - 'All inputs must have an unlocking script or an unlocking script template for sat/kb fee computation.' - ) - } - size += getVarIntSize(scriptLength) // unlocking script length - size += scriptLength // unlocking script - } - size += getVarIntSize(tx.outputs.length) // number of outputs - for (const out of tx.outputs) { - size += 8 // satoshis - const length = out.lockingScript.toBinary().length - size += getVarIntSize(length) // script length - size += length // script - } - size += 4 // lock time - // We'll use Math.ceil to ensure the miners get the extra satoshi. - const fee = Math.ceil((size / 1000) * rate) - return fee + // Update the value property so parent's computeFee uses the live rate + this.value = rate + return super.computeFee(tx) } } From e906e9bb181914d9ccab875a39164da71601b381 Mon Sep 17 00:00:00 2001 From: Deggen Date: Mon, 25 Aug 2025 11:17:40 -0500 Subject: [PATCH 7/8] coverage --- .../fee-models/__tests/LivePolicy.test.ts | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/src/transaction/fee-models/__tests/LivePolicy.test.ts b/src/transaction/fee-models/__tests/LivePolicy.test.ts index 0123bce4..27df6fa4 100644 --- a/src/transaction/fee-models/__tests/LivePolicy.test.ts +++ b/src/transaction/fee-models/__tests/LivePolicy.test.ts @@ -4,6 +4,7 @@ describe('LivePolicy', () => { beforeEach(() => { // Reset singleton instance before each test ;(LivePolicy as any).instance = null + jest.clearAllMocks() }) it('should return the same instance when getInstance is called multiple times', () => { @@ -56,4 +57,131 @@ describe('LivePolicy', () => { // The first call determines the cache validity expect((instance1 as any).cacheValidityMs).toBe(10000) }) + + it('should create instance with custom cache validity', () => { + // Line 20: constructor with custom cacheValidityMs + const instance = new LivePolicy(30000) + expect((instance as any).cacheValidityMs).toBe(30000) + }) + + it('should handle HTTP error responses', async () => { + const instance = LivePolicy.getInstance() + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation() + + // Mock fetch to return HTTP error - Lines 53, 54 + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error' + }) + + const mockTx = { + inputs: [], + outputs: [] + } as any + + const fee = await instance.computeFee(mockTx) + + expect(fee).toBe(1) // Should use default 100 sat/kb, minimum tx size gets 1 sat + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to fetch live fee rate, using default 100 sat/kb:', + expect.any(Error) + ) + + consoleSpy.mockRestore() + }) + + it('should handle invalid API response format', async () => { + const instance = LivePolicy.getInstance() + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation() + + // Mock fetch to return invalid response format - Lines 59, 60 + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + // Missing policy.miningFee structure + invalid: 'response' + }) + }) + + const mockTx = { + inputs: [], + outputs: [] + } as any + + const fee = await instance.computeFee(mockTx) + + expect(fee).toBe(1) // Should use default 100 sat/kb, minimum tx size gets 1 sat + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to fetch live fee rate, using default 100 sat/kb:', + expect.any(Error) + ) + + consoleSpy.mockRestore() + }) + + it('should use cached value when API fails after successful fetch', async () => { + const instance = LivePolicy.getInstance() + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation() + + // First call - successful fetch + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + policy: { + miningFee: { + satoshis: 10, + bytes: 1000 + } + } + }) + }) + + const mockTx = { + inputs: [], + outputs: [] + } as any + + const fee1 = await instance.computeFee(mockTx) + expect(fee1).toBe(1) // 10 sat/kb rate + + // Expire the cache by manipulating the timestamp + ;(instance as any).cacheTimestamp = Date.now() - (6 * 60 * 1000) // 6 minutes ago + + // Second call - API fails, should use cached value - Lines 73, 74, 75 + global.fetch = jest.fn().mockRejectedValue(new Error('Network error')) + + const fee2 = await instance.computeFee(mockTx) + + expect(fee2).toBe(1) // Should use cached rate + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to fetch live fee rate, using cached value:', + expect.any(Error) + ) + + consoleSpy.mockRestore() + }) + + it('should handle network errors with no cached value', async () => { + const instance = LivePolicy.getInstance() + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation() + + // Mock fetch to throw network error - Lines 79, 80 + global.fetch = jest.fn().mockRejectedValue(new Error('Network error')) + + const mockTx = { + inputs: [], + outputs: [] + } as any + + const fee = await instance.computeFee(mockTx) + + expect(fee).toBe(1) // Should use default 100 sat/kb, minimum tx size gets 1 sat + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to fetch live fee rate, using default 100 sat/kb:', + expect.any(Error) + ) + + consoleSpy.mockRestore() + }) }) From 64e0d77cbf80a68d19bbd160888b90e20a035fd6 Mon Sep 17 00:00:00 2001 From: Deggen Date: Mon, 25 Aug 2025 11:23:32 -0500 Subject: [PATCH 8/8] deduplicate test code --- .../fee-models/__tests/LivePolicy.test.ts | 195 +++++++----------- 1 file changed, 78 insertions(+), 117 deletions(-) diff --git a/src/transaction/fee-models/__tests/LivePolicy.test.ts b/src/transaction/fee-models/__tests/LivePolicy.test.ts index 27df6fa4..9b7ad2b6 100644 --- a/src/transaction/fee-models/__tests/LivePolicy.test.ts +++ b/src/transaction/fee-models/__tests/LivePolicy.test.ts @@ -1,10 +1,61 @@ import LivePolicy from '../LivePolicy.js' describe('LivePolicy', () => { + let consoleSpy: jest.SpyInstance + + const createMockTransaction = () => ({ + inputs: [], + outputs: [] + } as any) + + const createSuccessfulFetchMock = (satoshis: number, bytes: number = 1000) => + jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + policy: { + miningFee: { satoshis, bytes } + } + }) + }) + + const createErrorFetchMock = (status = 500, statusText = 'Internal Server Error') => + jest.fn().mockResolvedValue({ + ok: false, + status, + statusText + }) + + const createInvalidResponseMock = () => + jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ invalid: 'response' }) + }) + + const createNetworkErrorMock = () => + jest.fn().mockRejectedValue(new Error('Network error')) + + const expectDefaultFallback = (consoleSpy: jest.SpyInstance) => { + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to fetch live fee rate, using default 100 sat/kb:', + expect.any(Error) + ) + } + + const expectCachedFallback = (consoleSpy: jest.SpyInstance) => { + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to fetch live fee rate, using cached value:', + expect.any(Error) + ) + } + beforeEach(() => { - // Reset singleton instance before each test ;(LivePolicy as any).instance = null jest.clearAllMocks() + consoleSpy = jest.spyOn(console, 'warn').mockImplementation() + }) + + afterEach(() => { + consoleSpy.mockRestore() }) it('should return the same instance when getInstance is called multiple times', () => { @@ -19,169 +70,79 @@ describe('LivePolicy', () => { const instance1 = LivePolicy.getInstance() const instance2 = LivePolicy.getInstance() - // Mock fetch to return a specific rate - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - policy: { - miningFee: { - satoshis: 5, - bytes: 1000 - } - } - }) - }) - - // Create a mock transaction - const mockTx = { - inputs: [], - outputs: [] - } as any + global.fetch = createSuccessfulFetchMock(5) + const mockTx = createMockTransaction() - // First call should fetch from API const fee1 = await instance1.computeFee(mockTx) - - // Second call should use cached value (no additional fetch) const fee2 = await instance2.computeFee(mockTx) expect(fee1).toBe(fee2) - expect(fee1).toBe(1) // 1 because rate is 5 sat/1000 bytes * 1000 = 5 sat/kb, and minimum tx size gets 1 sat - expect(global.fetch).toHaveBeenCalledTimes(1) // Only called once due to caching + expect(fee1).toBe(1) // 5 sat/kb rate, minimum tx size gets 1 sat + expect(global.fetch).toHaveBeenCalledTimes(1) }) it('should allow different cache validity when creating singleton', () => { - const instance1 = LivePolicy.getInstance(10000) // 10 seconds - const instance2 = LivePolicy.getInstance(20000) // 20 seconds (should be ignored) + const instance1 = LivePolicy.getInstance(10000) + const instance2 = LivePolicy.getInstance(20000) expect(instance1).toBe(instance2) - // The first call determines the cache validity expect((instance1 as any).cacheValidityMs).toBe(10000) }) it('should create instance with custom cache validity', () => { - // Line 20: constructor with custom cacheValidityMs const instance = new LivePolicy(30000) expect((instance as any).cacheValidityMs).toBe(30000) }) it('should handle HTTP error responses', async () => { const instance = LivePolicy.getInstance() - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation() - - // Mock fetch to return HTTP error - Lines 53, 54 - global.fetch = jest.fn().mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Internal Server Error' - }) - - const mockTx = { - inputs: [], - outputs: [] - } as any + global.fetch = createErrorFetchMock() + const mockTx = createMockTransaction() const fee = await instance.computeFee(mockTx) - expect(fee).toBe(1) // Should use default 100 sat/kb, minimum tx size gets 1 sat - expect(consoleSpy).toHaveBeenCalledWith( - 'Failed to fetch live fee rate, using default 100 sat/kb:', - expect.any(Error) - ) - - consoleSpy.mockRestore() + expect(fee).toBe(1) + expectDefaultFallback(consoleSpy) }) it('should handle invalid API response format', async () => { const instance = LivePolicy.getInstance() - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation() - - // Mock fetch to return invalid response format - Lines 59, 60 - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - // Missing policy.miningFee structure - invalid: 'response' - }) - }) - - const mockTx = { - inputs: [], - outputs: [] - } as any + global.fetch = createInvalidResponseMock() + const mockTx = createMockTransaction() const fee = await instance.computeFee(mockTx) - expect(fee).toBe(1) // Should use default 100 sat/kb, minimum tx size gets 1 sat - expect(consoleSpy).toHaveBeenCalledWith( - 'Failed to fetch live fee rate, using default 100 sat/kb:', - expect.any(Error) - ) - - consoleSpy.mockRestore() + expect(fee).toBe(1) + expectDefaultFallback(consoleSpy) }) it('should use cached value when API fails after successful fetch', async () => { const instance = LivePolicy.getInstance() - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation() + const mockTx = createMockTransaction() // First call - successful fetch - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - policy: { - miningFee: { - satoshis: 10, - bytes: 1000 - } - } - }) - }) - - const mockTx = { - inputs: [], - outputs: [] - } as any - + global.fetch = createSuccessfulFetchMock(10) const fee1 = await instance.computeFee(mockTx) - expect(fee1).toBe(1) // 10 sat/kb rate + expect(fee1).toBe(1) - // Expire the cache by manipulating the timestamp - ;(instance as any).cacheTimestamp = Date.now() - (6 * 60 * 1000) // 6 minutes ago - - // Second call - API fails, should use cached value - Lines 73, 74, 75 - global.fetch = jest.fn().mockRejectedValue(new Error('Network error')) + // Expire cache and simulate API failure + ;(instance as any).cacheTimestamp = Date.now() - (6 * 60 * 1000) + global.fetch = createNetworkErrorMock() const fee2 = await instance.computeFee(mockTx) - expect(fee2).toBe(1) // Should use cached rate - expect(consoleSpy).toHaveBeenCalledWith( - 'Failed to fetch live fee rate, using cached value:', - expect.any(Error) - ) - - consoleSpy.mockRestore() + expect(fee2).toBe(1) + expectCachedFallback(consoleSpy) }) it('should handle network errors with no cached value', async () => { const instance = LivePolicy.getInstance() - const consoleSpy = jest.spyOn(console, 'warn').mockImplementation() - - // Mock fetch to throw network error - Lines 79, 80 - global.fetch = jest.fn().mockRejectedValue(new Error('Network error')) - - const mockTx = { - inputs: [], - outputs: [] - } as any + global.fetch = createNetworkErrorMock() + const mockTx = createMockTransaction() const fee = await instance.computeFee(mockTx) - expect(fee).toBe(1) // Should use default 100 sat/kb, minimum tx size gets 1 sat - expect(consoleSpy).toHaveBeenCalledWith( - 'Failed to fetch live fee rate, using default 100 sat/kb:', - expect.any(Error) - ) - - consoleSpy.mockRestore() + expect(fee).toBe(1) + expectDefaultFallback(consoleSpy) }) })