diff --git a/Sources/Starknet/Helpers/Tip.swift b/Sources/Starknet/Helpers/Tip.swift new file mode 100644 index 00000000..49b559f2 --- /dev/null +++ b/Sources/Starknet/Helpers/Tip.swift @@ -0,0 +1,89 @@ +import BigInt +import Foundation + +/// Estimates the transaction tip by taking the median of all V3 transaction tips in the latest block. +/// +/// - Parameter provider: The provider used to interact with Starknet. +/// - Returns: The estimated median tip. +/// - Throws: An error if the RPC call fails or no transactions are found. +public func estimateTip(provider: StarknetProviderProtocol) async throws -> UInt64AsHex { + try await estimateTip(provider: provider, blockId: .tag(.latest)) +} + +/// Estimates the transaction tip by taking the median of all V3 transaction tips in the latest block. +/// +/// - Parameters: +/// - provider: The provider used to interact with Starknet. +/// - blockHash: The block hash to estimate tip for. +/// - Returns: The estimated median tip. +/// - Throws: An error if the RPC call fails or no transactions are found. +public func estimateTip(provider: StarknetProviderProtocol, blockHash: Felt) async throws -> UInt64AsHex { + try await estimateTip(provider: provider, blockId: .hash(blockHash)) +} + +/// Estimates the transaction tip by taking the median of all V3 transaction tips in the latest block. +/// +/// - Parameters: +/// - provider: The provider used to interact with Starknet. +/// - blockNumber: The block number to estimate tip for. +/// - Returns: The estimated median tip. +/// - Throws: An error if the RPC call fails or no transactions are found. +public func estimateTip(provider: StarknetProviderProtocol, blockNumber: Int) async throws -> UInt64AsHex { + try await estimateTip(provider: provider, blockId: .number(blockNumber)) +} + +/// Estimates the transaction tip by taking the median of all V3 transaction tips in the latest block. +/// +/// - Parameters: +/// - provider: The provider used to interact with Starknet. +/// - blockTag: The block tag to estimate tip for. +/// - Returns: The estimated median tip. +/// - Throws: An error if the RPC call fails or no transactions are found. +public func estimateTip(provider: StarknetProviderProtocol, blockTag: StarknetBlockId.BlockTag) async throws -> UInt64AsHex { + try await estimateTip(provider: provider, blockId: .tag(blockTag)) +} + +/// Estimates the transaction tip by taking the median of all V3 transaction tips in the specified block. +/// +/// - Parameters: +/// - provider: The provider used to interact with Starknet. +/// - blockId: The block identifier to estimate the tip for (hash, number, or tag). +/// - Returns: The estimated median tip. +/// - Throws: An error if the RPC call fails or no transactions are found. +private func estimateTip(provider: StarknetProviderProtocol, blockId: StarknetBlockId) async throws -> UInt64AsHex { + let request = RequestBuilder.getBlockWithTxs(blockId) + let blockWithTxs = try await provider.send(request: request) + + let transactions: [TransactionWrapper] = switch blockWithTxs { + case let .processed(block): block.transactions + case let .preConfirmed(block): block.transactions + } + + let tips = transactions.compactMap { transactionWrapper in + switch transactionWrapper { + case let .invokeV3(invokeV3): invokeV3.tip.value + case let .deployAccountV3(deployAccountV3): deployAccountV3.tip.value + case let .declareV3(declareV3): declareV3.tip.value + default: nil + } + } + + if tips.isEmpty { + return UInt64AsHex.zero + } + + let sortedTips = tips.sorted() + let count = sortedTips.count + + let median = if count % 2 == 1 { + sortedTips[count / 2] + } else { + (sortedTips[count / 2 - 1] + sortedTips[count / 2]) / 2 + } + + if let median = median.toUInt64AsHex() { + return median + } else { + fatalError("Failed to convert BigUInt to UInt64AsHex") + } +} diff --git a/Sources/Starknet/Network/MockURLProtocol.swift b/Sources/Starknet/Network/MockURLProtocol.swift new file mode 100644 index 00000000..1c49c06f --- /dev/null +++ b/Sources/Starknet/Network/MockURLProtocol.swift @@ -0,0 +1,47 @@ +import Foundation + +final class MockURLProtocol: URLProtocol { + static var mockResponse: (statusCode: Int, body: Data)? + + override class func canInit(with _: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let mock = MockURLProtocol.mockResponse else { + fatalError("Mock response not set") + } + + let response = HTTPURLResponse( + url: request.url!, + statusCode: mock.statusCode, + httpVersion: nil, + headerFields: nil + )! + + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: mock.body) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +func makeMockedURLSession( + statusCode: Int = 200, + data: Data +) -> URLSession { + MockURLProtocol.mockResponse = ( + statusCode: statusCode, + body: data + ) + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + + return URLSession(configuration: config) +} diff --git a/Tests/StarknetTests/Helpers/TipTests.swift b/Tests/StarknetTests/Helpers/TipTests.swift new file mode 100644 index 00000000..12ecdbab --- /dev/null +++ b/Tests/StarknetTests/Helpers/TipTests.swift @@ -0,0 +1,493 @@ +import XCTest + +@testable import Starknet + +final class TipTests: XCTestCase { + var provider: StarknetProviderProtocol! + + func testEstimateTipForBlockWithoutTxs() async throws { + let json = """ + { + "id": 0, + "jsonrpc": "2.0", + "result": { + "block_hash": "0x1", + "block_number": 111, + "l1_da_mode": "BLOB", + "l1_data_gas_price": { + "price_in_fri": "0x1", + "price_in_wei": "0x1" + }, + "l1_gas_price": { + "price_in_fri": "0x1", + "price_in_wei": "0x1" + }, + "l2_gas_price": { + "price_in_fri": "0x1", + "price_in_wei": "0x1" + }, + "new_root": "0x1", + "parent_hash": "0x1", + "sequencer_address": "0x1", + "starknet_version": "0.14.0", + "status": "ACCEPTED_ON_L2", + "timestamp": 123, + "transactions": [] + } + } + """.data(using: .utf8)! + + let session = makeMockedURLSession(data: json) + let provider = StarknetProvider(url: "https://node.url", urlSession: session) + + let tip = try await estimateTip(provider: provider!) + XCTAssertEqual(tip, UInt64AsHex.zero) + } + + func testEstimateTipForBlockWithOddTxsNumber() async throws { + // 3 transactions, tips: 100, 200, 300 + let json = """ + { + "id": 0, + "jsonrpc": "2.0", + "result": { + "block_hash": "0x1", + "block_number": 111, + "l1_da_mode": "BLOB", + "l1_data_gas_price": { + "price_in_fri": "0x1", + "price_in_wei": "0x1" + }, + "l1_gas_price": { + "price_in_fri": "0x1", + "price_in_wei": "0x1" + }, + "l2_gas_price": { + "price_in_fri": "0x1", + "price_in_wei": "0x1" + }, + "new_root": "0x1", + "parent_hash": "0x1", + "sequencer_address": "0x1", + "starknet_version": "0.14.0", + "status": "ACCEPTED_ON_L2", + "timestamp": 123, + "transactions": [ + { + "account_deployment_data": [], + "calldata": [], + "fee_data_availability_mode": "L1", + "nonce": "0x1", + "nonce_data_availability_mode": "L1", + "paymaster_data": [], + "resource_bounds": { + "l1_data_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + }, + "l1_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + }, + "l2_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + } + }, + "sender_address": "0x1", + "signature": [ + "0x1", + "0x1" + ], + "tip": "0x64", + "transaction_hash": "0x1", + "type": "INVOKE", + "version": "0x3" + }, + { + "account_deployment_data": [], + "calldata": [], + "fee_data_availability_mode": "L1", + "nonce": "0x1", + "nonce_data_availability_mode": "L1", + "paymaster_data": [], + "resource_bounds": { + "l1_data_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + }, + "l1_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + }, + "l2_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + } + }, + "sender_address": "0x1", + "signature": [ + "0x1", + "0x1" + ], + "tip": "0xc8", + "transaction_hash": "0x1", + "type": "INVOKE", + "version": "0x3" + }, + { + "account_deployment_data": [], + "calldata": [], + "fee_data_availability_mode": "L1", + "nonce": "0x1", + "nonce_data_availability_mode": "L1", + "paymaster_data": [], + "resource_bounds": { + "l1_data_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + }, + "l1_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + }, + "l2_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + } + }, + "sender_address": "0x1", + "signature": [ + "0x1", + "0x1" + ], + "tip": "0x12c", + "transaction_hash": "0x1", + "type": "INVOKE", + "version": "0x3" + } + ] + } + } + """.data(using: .utf8)! + + let session = makeMockedURLSession(data: json) + let provider = StarknetProvider(url: "https://node.url", urlSession: session) + + let tip = try await estimateTip(provider: provider!) + XCTAssertEqual(tip, UInt64AsHex(200)) + } + + func testEstimateTipForBlockWithEvenTxsNumber() async throws { + // 4 transactions, tips: 100, 200, 300, 400 + let json = """ + { + "id": 0, + "jsonrpc": "2.0", + "result": { + "block_hash": "0x1", + "block_number": 111, + "l1_da_mode": "BLOB", + "l1_data_gas_price": { + "price_in_fri": "0x1", + "price_in_wei": "0x1" + }, + "l1_gas_price": { + "price_in_fri": "0x1", + "price_in_wei": "0x1" + }, + "l2_gas_price": { + "price_in_fri": "0x1", + "price_in_wei": "0x1" + }, + "new_root": "0x1", + "parent_hash": "0x1", + "sequencer_address": "0x1", + "starknet_version": "0.14.0", + "status": "ACCEPTED_ON_L2", + "timestamp": 123, + "transactions": [ + { + "account_deployment_data": [], + "calldata": [], + "fee_data_availability_mode": "L1", + "nonce": "0x1", + "nonce_data_availability_mode": "L1", + "paymaster_data": [], + "resource_bounds": { + "l1_data_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + }, + "l1_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + }, + "l2_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + } + }, + "sender_address": "0x1", + "signature": [ + "0x1", + "0x1" + ], + "tip": "0x64", + "transaction_hash": "0x1", + "type": "INVOKE", + "version": "0x3" + }, + { + "account_deployment_data": [], + "calldata": [], + "fee_data_availability_mode": "L1", + "nonce": "0x1", + "nonce_data_availability_mode": "L1", + "paymaster_data": [], + "resource_bounds": { + "l1_data_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + }, + "l1_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + }, + "l2_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + } + }, + "sender_address": "0x1", + "signature": [ + "0x1", + "0x1" + ], + "tip": "0xc8", + "transaction_hash": "0x1", + "type": "INVOKE", + "version": "0x3" + }, + { + "account_deployment_data": [], + "calldata": [], + "fee_data_availability_mode": "L1", + "nonce": "0x1", + "nonce_data_availability_mode": "L1", + "paymaster_data": [], + "resource_bounds": { + "l1_data_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + }, + "l1_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + }, + "l2_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + } + }, + "sender_address": "0x1", + "signature": [ + "0x1", + "0x1" + ], + "tip": "0x12c", + "transaction_hash": "0x1", + "type": "INVOKE", + "version": "0x3" + }, + { + "account_deployment_data": [], + "calldata": [], + "fee_data_availability_mode": "L1", + "nonce": "0x1", + "nonce_data_availability_mode": "L1", + "paymaster_data": [], + "resource_bounds": { + "l1_data_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + }, + "l1_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + }, + "l2_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + } + }, + "sender_address": "0x1", + "signature": [ + "0x1", + "0x1" + ], + "tip": "0x190", + "transaction_hash": "0x1", + "type": "INVOKE", + "version": "0x3" + } + ] + } + } + """.data(using: .utf8)! + + let session = makeMockedURLSession(data: json) + let provider = StarknetProvider(url: "https://node.url", urlSession: session) + + let tip = try await estimateTip(provider: provider!) + XCTAssertEqual(tip, UInt64AsHex(250)) + } + + func testEstimateTipForBlockWithOldTxs() async throws { + // 3 transactions v3, tips: 100, 200, 300 + // 1 transaction v1 + let json = """ + { + "id": 0, + "jsonrpc": "2.0", + "result": { + "block_hash": "0x1", + "block_number": 111, + "l1_da_mode": "BLOB", + "l1_data_gas_price": { + "price_in_fri": "0x1", + "price_in_wei": "0x1" + }, + "l1_gas_price": { + "price_in_fri": "0x1", + "price_in_wei": "0x1" + }, + "l2_gas_price": { + "price_in_fri": "0x1", + "price_in_wei": "0x1" + }, + "new_root": "0x1", + "parent_hash": "0x1", + "sequencer_address": "0x1", + "starknet_version": "0.14.0", + "status": "ACCEPTED_ON_L2", + "timestamp": 123, + "transactions": [ + { + "calldata": [], + "nonce": "0x1", + "max_fee": "0x123", + "sender_address": "0x1", + "signature": [ + "0x1", + "0x1" + ], + "transaction_hash": "0x1", + "type": "INVOKE", + "version": "0x1" + }, + { + "account_deployment_data": [], + "calldata": [], + "fee_data_availability_mode": "L1", + "nonce": "0x1", + "nonce_data_availability_mode": "L1", + "paymaster_data": [], + "resource_bounds": { + "l1_data_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + }, + "l1_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + }, + "l2_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + } + }, + "sender_address": "0x1", + "signature": [ + "0x1", + "0x1" + ], + "tip": "0x64", + "transaction_hash": "0x1", + "type": "INVOKE", + "version": "0x3" + }, + { + "account_deployment_data": [], + "calldata": [], + "fee_data_availability_mode": "L1", + "nonce": "0x1", + "nonce_data_availability_mode": "L1", + "paymaster_data": [], + "resource_bounds": { + "l1_data_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + }, + "l1_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + }, + "l2_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + } + }, + "sender_address": "0x1", + "signature": [ + "0x1", + "0x1" + ], + "tip": "0xc8", + "transaction_hash": "0x1", + "type": "INVOKE", + "version": "0x3" + }, + { + "account_deployment_data": [], + "calldata": [], + "fee_data_availability_mode": "L1", + "nonce": "0x1", + "nonce_data_availability_mode": "L1", + "paymaster_data": [], + "resource_bounds": { + "l1_data_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + }, + "l1_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + }, + "l2_gas": { + "max_amount": "0x1", + "max_price_per_unit": "0x1" + } + }, + "sender_address": "0x1", + "signature": [ + "0x1", + "0x1" + ], + "tip": "0x12c", + "transaction_hash": "0x1", + "type": "INVOKE", + "version": "0x3" + } + ] + } + } + """.data(using: .utf8)! + + let session = makeMockedURLSession(data: json) + let provider = StarknetProvider(url: "https://node.url", urlSession: session) + + let tip = try await estimateTip(provider: provider!) + XCTAssertEqual(tip, UInt64AsHex(200)) + } +}