diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f732dc0..82558d1f 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.7.8 - 2025-10-02](#178---2025-10-02) - [1.7.6 - 2025-09-09](#176---2025-09-08) - [1.7.5 - 2025-09-08](#175---2025-09-08) - [1.7.4 - 2025-09-08](#174---2025-09-08) @@ -157,6 +158,15 @@ All notable changes to this project will be documented in this file. The format ### Security +--- + +### [1.7.8] - 2025-10-02 + +### 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.7.7] - 2025-09-19 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) diff --git a/src/transaction/Transaction.ts b/src/transaction/Transaction.ts index 825f5ef2..24716ada 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 new file mode 100644 index 00000000..1bba80d2 --- /dev/null +++ b/src/transaction/fee-models/LivePolicy.ts @@ -0,0 +1,97 @@ +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 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 + 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) { + super(100) // Initialize with dummy value, will be overridden by fetchFeeRate + 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. + * + * @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. + * 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() + // Update the value property so parent's computeFee uses the live rate + this.value = rate + return super.computeFee(tx) + } +} 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..9b7ad2b6 --- /dev/null +++ b/src/transaction/fee-models/__tests/LivePolicy.test.ts @@ -0,0 +1,148 @@ +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(() => { + ;(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', () => { + 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() + + global.fetch = createSuccessfulFetchMock(5) + const mockTx = createMockTransaction() + + const fee1 = await instance1.computeFee(mockTx) + const fee2 = await instance2.computeFee(mockTx) + + expect(fee1).toBe(fee2) + 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) + const instance2 = LivePolicy.getInstance(20000) + + expect(instance1).toBe(instance2) + expect((instance1 as any).cacheValidityMs).toBe(10000) + }) + + it('should create instance with custom cache validity', () => { + const instance = new LivePolicy(30000) + expect((instance as any).cacheValidityMs).toBe(30000) + }) + + it('should handle HTTP error responses', async () => { + const instance = LivePolicy.getInstance() + global.fetch = createErrorFetchMock() + const mockTx = createMockTransaction() + + const fee = await instance.computeFee(mockTx) + + expect(fee).toBe(1) + expectDefaultFallback(consoleSpy) + }) + + it('should handle invalid API response format', async () => { + const instance = LivePolicy.getInstance() + global.fetch = createInvalidResponseMock() + const mockTx = createMockTransaction() + + const fee = await instance.computeFee(mockTx) + + expect(fee).toBe(1) + expectDefaultFallback(consoleSpy) + }) + + it('should use cached value when API fails after successful fetch', async () => { + const instance = LivePolicy.getInstance() + const mockTx = createMockTransaction() + + // First call - successful fetch + global.fetch = createSuccessfulFetchMock(10) + const fee1 = await instance.computeFee(mockTx) + expect(fee1).toBe(1) + + // 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) + expectCachedFallback(consoleSpy) + }) + + it('should handle network errors with no cached value', async () => { + const instance = LivePolicy.getInstance() + global.fetch = createNetworkErrorMock() + const mockTx = createMockTransaction() + + const fee = await instance.computeFee(mockTx) + + expect(fee).toBe(1) + expectDefaultFallback(consoleSpy) + }) +}) 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'