diff --git a/Cargo.toml b/Cargo.toml index a78d69b5..22962a3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,8 @@ [workspace] resolver = "2" members = [ - "contracts/error_codes", - "contracts/math", - "contracts/staking_rewards", + "contracts/*", ] + +[workspace.dependencies] +soroban-sdk = "22.0.0" diff --git a/contracts/auction_factory/Cargo.toml b/contracts/auction_factory/Cargo.toml index fabfb540..6dc62617 100644 --- a/contracts/auction_factory/Cargo.toml +++ b/contracts/auction_factory/Cargo.toml @@ -8,11 +8,11 @@ publish = false crate-type = ["rlib"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true emergency_guard = { path = "../emergency_guard", default-features = false } [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } emergency_guard = { path = "../emergency_guard", default-features = false } [features] diff --git a/contracts/batch_transfer/Cargo.toml b/contracts/batch_transfer/Cargo.toml index 416025da..50b99f7c 100644 --- a/contracts/batch_transfer/Cargo.toml +++ b/contracts/batch_transfer/Cargo.toml @@ -8,8 +8,8 @@ publish = false crate-type = ["cdylib", "rlib"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } token-contract = { package = "soroban-token-contract", path = "../token" } diff --git a/contracts/cdp/Cargo.toml b/contracts/cdp/Cargo.toml index 12f87b08..bd696de0 100644 --- a/contracts/cdp/Cargo.toml +++ b/contracts/cdp/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib", "rlib"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/concentrated_amm/Cargo.toml b/contracts/concentrated_amm/Cargo.toml index 846e3df9..3d0bbfb8 100644 --- a/contracts/concentrated_amm/Cargo.toml +++ b/contracts/concentrated_amm/Cargo.toml @@ -10,7 +10,7 @@ crate-type = ["cdylib", "rlib"] testutils = ["soroban-sdk/testutils"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/cpu_heavy/Cargo.toml b/contracts/cpu_heavy/Cargo.toml index 5ddca619..8dc646c7 100644 --- a/contracts/cpu_heavy/Cargo.toml +++ b/contracts/cpu_heavy/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } \ No newline at end of file +soroban-sdk = { workspace = true, features = ["testutils"] } \ No newline at end of file diff --git a/contracts/cross_call/Cargo.toml b/contracts/cross_call/Cargo.toml index 5a3f6206..2702824c 100644 --- a/contracts/cross_call/Cargo.toml +++ b/contracts/cross_call/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/cross_chain_payload/Cargo.toml b/contracts/cross_chain_payload/Cargo.toml index d0356009..ee889d69 100644 --- a/contracts/cross_chain_payload/Cargo.toml +++ b/contracts/cross_chain_payload/Cargo.toml @@ -8,5 +8,4 @@ publish = false crate-type = ["cdylib"] [dependencies] -soroban-sdk = { version = "20.5.0", features = ["testutils"] } -soroban-sdk = { version = "22.0.0", features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/cross_chain_verifier/Cargo.toml b/contracts/cross_chain_verifier/Cargo.toml index d74d551b..13a64a3e 100644 --- a/contracts/cross_chain_verifier/Cargo.toml +++ b/contracts/cross_chain_verifier/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" crate-type = ["cdylib", "rlib"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/crucible-example-gasless/Cargo.toml b/contracts/crucible-example-gasless/Cargo.toml index d0c53e45..d186802c 100644 --- a/contracts/crucible-example-gasless/Cargo.toml +++ b/contracts/crucible-example-gasless/Cargo.toml @@ -7,5 +7,5 @@ edition = "2021" crate-type = ["cdylib", "rlib"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true diff --git a/contracts/did_registry/Cargo.toml b/contracts/did_registry/Cargo.toml index b3e5ff67..f939e829 100644 --- a/contracts/did_registry/Cargo.toml +++ b/contracts/did_registry/Cargo.toml @@ -9,7 +9,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } \ No newline at end of file +soroban-sdk = { workspace = true, features = ["testutils"] } \ No newline at end of file diff --git a/contracts/dutch_auction/Cargo.toml b/contracts/dutch_auction/Cargo.toml index 5d6b1d1f..bf4208d0 100644 --- a/contracts/dutch_auction/Cargo.toml +++ b/contracts/dutch_auction/Cargo.toml @@ -8,10 +8,10 @@ publish = false crate-type = ["cdylib", "rlib"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } token-contract = { package = "soroban-token-contract", path = "../token" } [features] diff --git a/contracts/emergency_guard/Cargo.toml b/contracts/emergency_guard/Cargo.toml index 952dfdba..a3138c8c 100644 --- a/contracts/emergency_guard/Cargo.toml +++ b/contracts/emergency_guard/Cargo.toml @@ -7,10 +7,10 @@ edition = "2021" crate-type = ["rlib"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } [features] default = ["contract"] diff --git a/contracts/english_auction/Cargo.toml b/contracts/english_auction/Cargo.toml index f181a63b..4815c94c 100644 --- a/contracts/english_auction/Cargo.toml +++ b/contracts/english_auction/Cargo.toml @@ -8,10 +8,10 @@ publish = false crate-type = ["cdylib", "rlib"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } token-contract = { package = "soroban-token-contract", path = "../token" } [features] diff --git a/contracts/error_codes/Cargo.toml b/contracts/error_codes/Cargo.toml index 4dcf2ee7..e9375dbc 100644 --- a/contracts/error_codes/Cargo.toml +++ b/contracts/error_codes/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" crate-type = ["rlib"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/factory/Cargo.toml b/contracts/factory/Cargo.toml index 874c3587..6eaf353c 100644 --- a/contracts/factory/Cargo.toml +++ b/contracts/factory/Cargo.toml @@ -9,10 +9,10 @@ crate-type = ["cdylib", "rlib"] [dependencies] emergency_guard = { path = "../emergency_guard", default-features = false } -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } emergency_guard = { path = "../emergency_guard" } # We need the pool contract code for integration tests diff --git a/contracts/flash_loan_vault/Cargo.toml b/contracts/flash_loan_vault/Cargo.toml index b955d0bc..c737227e 100644 --- a/contracts/flash_loan_vault/Cargo.toml +++ b/contracts/flash_loan_vault/Cargo.toml @@ -7,8 +7,8 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true emergency_guard = { path = "../emergency_guard" } [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/governance/Cargo.toml b/contracts/governance/Cargo.toml index b4301af8..bd901003 100644 --- a/contracts/governance/Cargo.toml +++ b/contracts/governance/Cargo.toml @@ -9,8 +9,8 @@ crate-type = ["cdylib", "rlib"] [dependencies] emergency_guard = { path = "../emergency_guard" } -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } emergency_guard = { path = "../emergency_guard" } diff --git a/contracts/hello_soroban/Cargo.toml b/contracts/hello_soroban/Cargo.toml index 199b38cc..01ef9c5b 100644 --- a/contracts/hello_soroban/Cargo.toml +++ b/contracts/hello_soroban/Cargo.toml @@ -7,9 +7,9 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/hybrid_amm_lob/Cargo.toml b/contracts/hybrid_amm_lob/Cargo.toml index cf2cdd6c..d00c45e9 100644 --- a/contracts/hybrid_amm_lob/Cargo.toml +++ b/contracts/hybrid_amm_lob/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/liquidity_pool/Cargo.toml b/contracts/liquidity_pool/Cargo.toml index 012ee926..fe50cbf4 100644 --- a/contracts/liquidity_pool/Cargo.toml +++ b/contracts/liquidity_pool/Cargo.toml @@ -7,10 +7,10 @@ edition = "2021" crate-type = ["cdylib", "rlib"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true emergency_guard = { path = "../emergency_guard", default-features = false } [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } emergency_guard = { path = "../emergency_guard" } proptest = "1.0" diff --git a/contracts/math/Cargo.toml b/contracts/math/Cargo.toml index ddfa3876..9acf8d71 100644 --- a/contracts/math/Cargo.toml +++ b/contracts/math/Cargo.toml @@ -7,8 +7,8 @@ edition = "2021" crate-type = ["cdylib", "rlib"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true soroscope-error-codes = { path = "../error_codes" } [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/multi_yield_vault/Cargo.toml b/contracts/multi_yield_vault/Cargo.toml index 6b41d2e7..84c0dbde 100644 --- a/contracts/multi_yield_vault/Cargo.toml +++ b/contracts/multi_yield_vault/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/oracle_aggregator/Cargo.toml b/contracts/oracle_aggregator/Cargo.toml index 48425915..662e2b8f 100644 --- a/contracts/oracle_aggregator/Cargo.toml +++ b/contracts/oracle_aggregator/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/private_transfer/Cargo.toml b/contracts/private_transfer/Cargo.toml index 632c00d9..5e39c98a 100644 --- a/contracts/private_transfer/Cargo.toml +++ b/contracts/private_transfer/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib", "rlib"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/proxy/Cargo.toml b/contracts/proxy/Cargo.toml index ae645b91..55a946ce 100644 --- a/contracts/proxy/Cargo.toml +++ b/contracts/proxy/Cargo.toml @@ -4,4 +4,4 @@ version = "0.1.0" edition = "2021" [dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/soulbound_token/Cargo.toml b/contracts/soulbound_token/Cargo.toml index 9f762521..0424e4b7 100644 --- a/contracts/soulbound_token/Cargo.toml +++ b/contracts/soulbound_token/Cargo.toml @@ -9,7 +9,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } \ No newline at end of file +soroban-sdk = { workspace = true, features = ["testutils"] } \ No newline at end of file diff --git a/contracts/staking_rewards/Cargo.toml b/contracts/staking_rewards/Cargo.toml index c834259a..8401fa1f 100644 --- a/contracts/staking_rewards/Cargo.toml +++ b/contracts/staking_rewards/Cargo.toml @@ -8,10 +8,10 @@ publish = false crate-type = ["cdylib"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true soroscope-math = { path = "../math" } soroscope-error-codes = { path = "../error_codes" } emergency_guard = { path = "../emergency_guard" } [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } \ No newline at end of file +soroban-sdk = { workspace = true, features = ["testutils"] } \ No newline at end of file diff --git a/contracts/storage_heavy/Cargo.toml b/contracts/storage_heavy/Cargo.toml index 8b416e91..4aeeeaee 100644 --- a/contracts/storage_heavy/Cargo.toml +++ b/contracts/storage_heavy/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/timelocked_escrow/Cargo.toml b/contracts/timelocked_escrow/Cargo.toml index 7f5442f8..e6442f08 100644 --- a/contracts/timelocked_escrow/Cargo.toml +++ b/contracts/timelocked_escrow/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/token/Cargo.toml b/contracts/token/Cargo.toml index edb0927f..f531eeae 100644 --- a/contracts/token/Cargo.toml +++ b/contracts/token/Cargo.toml @@ -10,8 +10,8 @@ crate-type = ["cdylib", "rlib"] [dependencies] emergency_guard = { path = "../emergency_guard", default-features = false } -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } emergency_guard = { path = "../emergency_guard", default-features = false, features = ["contract"] } diff --git a/contracts/twap_oracle/Cargo.toml b/contracts/twap_oracle/Cargo.toml index 9955edd9..f1cd7602 100644 --- a/contracts/twap_oracle/Cargo.toml +++ b/contracts/twap_oracle/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } \ No newline at end of file +soroban-sdk = { workspace = true, features = ["testutils"] } \ No newline at end of file diff --git a/contracts/typed_data_auth/Cargo.toml b/contracts/typed_data_auth/Cargo.toml index 53180541..89d90ad8 100644 --- a/contracts/typed_data_auth/Cargo.toml +++ b/contracts/typed_data_auth/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] -soroban-sdk = "22.0.0" +soroban-sdk.workspace = true [dev-dependencies] -soroban-sdk = { version = "22.0.0", features = ["testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/web/components/FeeEstimationPreview.test.cjs b/web/components/FeeEstimationPreview.test.cjs new file mode 100644 index 00000000..cdc86618 --- /dev/null +++ b/web/components/FeeEstimationPreview.test.cjs @@ -0,0 +1,264 @@ +// FeeEstimationPreview.test.cjs — unit tests for FeeEstimationPreview state and bump selection logic +// Closes Issue #595 +// Runs with: node --test ./components/FeeEstimationPreview.test.cjs + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +// ── Inline fee estimation logic (mirrors src/lib/stellarRpc.ts) ────────────── + +const FeeBumpLevel = Object.freeze({ + LOW: 'low', + MEDIUM: 'medium', + HIGH: 'high', +}); + +const BUMP_CONFIG = Object.freeze({ + [FeeBumpLevel.LOW]: { + label: 'Low (Min)', + multiplier: 1.0, + description: 'Minimum fee, slower confirmation', + }, + [FeeBumpLevel.MEDIUM]: { + label: 'Medium', + multiplier: 1.5, + description: 'Balanced speed and cost', + }, + [FeeBumpLevel.HIGH]: { + label: 'High', + multiplier: 2.0, + description: 'Priority processing', + }, +}); + +function stroopsToXlm(stroops) { + const xlm = stroops / 10_000_000; + if (xlm === 0) return '0'; + if (xlm < 0.000001) return xlm.toFixed(9); + if (xlm < 0.001) return xlm.toFixed(7); + return xlm.toFixed(5); +} + +function estimateFees(resourceCostStroops, feeStats) { + const baseClassicFee = (feeStats && feeStats.min_ledger_fee) || 100; + const surge = (feeStats && feeStats.surge_multiplier) || 1; + const sorobanRate = (feeStats && feeStats.soroban_fee_rate) || 1; + + const minResourceFeeStroops = resourceCostStroops; + const classicFeeStroops = baseClassicFee; + const baseTotal = minResourceFeeStroops + classicFeeStroops; + + const feeBumps = Object.values(BUMP_CONFIG).map((cfg) => { + const feeStroops = Math.ceil(baseTotal * cfg.multiplier * sorobanRate); + return { + label: cfg.label, + multiplier: cfg.multiplier, + feeStroops, + feeXlm: stroopsToXlm(feeStroops), + description: cfg.description, + }; + }); + + return { + minResourceFeeStroops, + classicFeeStroops, + totalFeeStroops: baseTotal, + totalFeeXlm: stroopsToXlm(baseTotal), + feeBumps, + networkFees: { + low: feeBumps[0].feeStroops, + medium: feeBumps[1].feeStroops, + high: feeBumps[2].feeStroops, + }, + surgeMultiplier: surge, + }; +} + +// ── FeeEstimationPreview state machine ──────────────────────────────────────── + +function createFeeEstimationState(options = {}) { + const { + costStroops = 500_000, + rpcAvailable = true, + } = options; + + let feeEstimate = null; + let selectedBump = 'Low (Min)'; + let fetching = false; + let isRpcAvailable = rpcAvailable; + let lastCallbackArgs = null; + + function loadFeeEstimate(stats) { + if (costStroops <= 0) { + feeEstimate = null; + return; + } + fetching = true; + // Simulate real component: if stats is null/undefined, + // RPC fetch failed (fetchFeeStats returned null or threw). + if (stats) { + feeEstimate = estimateFees(costStroops, stats); + isRpcAvailable = true; + } else { + feeEstimate = estimateFees(costStroops, null); + isRpcAvailable = false; + } + fetching = false; + } + + function handleBumpSelect(bump, callback) { + selectedBump = bump.label; + if (callback) { + const level = bump.label.toLowerCase().includes('low') ? 'low' + : bump.label.toLowerCase().includes('high') ? 'high' + : 'medium'; + lastCallbackArgs = { level, feeStroops: bump.feeStroops }; + callback(level, bump.feeStroops); + } + } + + function refresh() { + loadFeeEstimate(isRpcAvailable ? { min_ledger_fee: 100, surge_multiplier: 1, soroban_fee_rate: 1 } : null); + } + + return { + get feeEstimate() { return feeEstimate; }, + get selectedBump() { return selectedBump; }, + get isFetching() { return fetching; }, + get isRpcAvailable() { return isRpcAvailable; }, + get lastCallbackArgs() { return lastCallbackArgs; }, + loadFeeEstimate, + handleBumpSelect, + refresh, + setRpcAvailable(v) { isRpcAvailable = v; }, + setSelectedBump(v) { selectedBump = v; }, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +test('FeeEstimationPreview: initial state is empty when no cost data', () => { + const state = createFeeEstimationState({ costStroops: 0 }); + state.loadFeeEstimate(null); + assert.equal(state.feeEstimate, null); +}); + +test('FeeEstimationPreview: loads fee estimate from resource cost', () => { + const state = createFeeEstimationState({ costStroops: 500_000 }); + const mockStats = { min_ledger_fee: 100, surge_multiplier: 1, soroban_fee_rate: 1 }; + state.loadFeeEstimate(mockStats); + + assert.ok(state.feeEstimate !== null); + assert.equal(state.feeEstimate.minResourceFeeStroops, 500_000); + assert.equal(state.feeEstimate.classicFeeStroops, 100); + assert.ok(state.feeEstimate.feeBumps.length === 3); +}); + +test('FeeEstimationPreview: shows three fee bump options', () => { + const state = createFeeEstimationState(); + state.loadFeeEstimate(null); + + const bumps = state.feeEstimate.feeBumps; + assert.equal(bumps.length, 3); + + assert.equal(bumps[0].label, 'Low (Min)'); + assert.equal(bumps[1].label, 'Medium'); + assert.equal(bumps[2].label, 'High'); +}); + +test('FeeEstimationPreview: selecting a fee bump updates selected state', () => { + const state = createFeeEstimationState(); + state.loadFeeEstimate(null); + + const bumps = state.feeEstimate.feeBumps; + state.handleBumpSelect(bumps[1]); + + assert.equal(state.selectedBump, 'Medium'); +}); + +test('FeeEstimationPreview: fee bump selection fires callback with correct level', () => { + const state = createFeeEstimationState(); + state.loadFeeEstimate(null); + + const bumps = state.feeEstimate.feeBumps; + let callbackResult = null; + + state.handleBumpSelect(bumps[2], (level, feeStroops) => { + callbackResult = { level, feeStroops }; + }); + + assert.equal(callbackResult.level, 'high'); + assert.ok(callbackResult.feeStroops > 0); +}); + +test('FeeEstimationPreview: low bump has lowest fee, high has highest', () => { + const state = createFeeEstimationState(); + state.loadFeeEstimate(null); + + const fees = state.feeEstimate.networkFees; + assert.ok(fees.low < fees.medium, 'Low < Medium'); + assert.ok(fees.medium < fees.high, 'Medium < High'); +}); + +test('FeeEstimationPreview: rpc offline flag shown when stats fetch fails', () => { + const state = createFeeEstimationState({ rpcAvailable: false }); + state.loadFeeEstimate(null); + + assert.equal(state.isRpcAvailable, false); + assert.ok(state.feeEstimate !== null); +}); + +test('FeeEstimationPreview: refresh reloads fee estimate', () => { + const state = createFeeEstimationState({ costStroops: 100_000 }); + state.loadFeeEstimate(null); + + const beforeFee = state.feeEstimate.totalFeeStroops; + + state.setRpcAvailable(true); + state.refresh(); + + assert.ok(state.feeEstimate !== null); + assert.equal(state.feeEstimate.totalFeeStroops, beforeFee); +}); + +test('FeeEstimationPreview: returns correct fee breakdown for a known resource cost', () => { + const state = createFeeEstimationState({ costStroops: 1_234_567 }); + state.loadFeeEstimate({ min_ledger_fee: 100, surge_multiplier: 1, soroban_fee_rate: 1 }); + + const est = state.feeEstimate; + assert.equal(est.minResourceFeeStroops, 1_234_567); + assert.equal(est.classicFeeStroops, 100); + assert.equal(est.totalFeeStroops, 1_234_667); + assert.equal(est.surgeMultiplier, 1); +}); + +test('FeeEstimationPreview: surge multiplier > 1 increases all fee bump levels', () => { + const baseCost = 500_000; + const normal = estimateFees(baseCost, { min_ledger_fee: 100, surge_multiplier: 1, soroban_fee_rate: 1 }); + const surged = estimateFees(baseCost, { min_ledger_fee: 100, surge_multiplier: 3, soroban_fee_rate: 2 }); + + assert.ok(surged.surgeMultiplier > normal.surgeMultiplier); + assert.ok(surged.networkFees.low > normal.networkFees.low); + assert.ok(surged.networkFees.medium > normal.networkFees.medium); + assert.ok(surged.networkFees.high > normal.networkFees.high); +}); + +test('FeeEstimationPreview: handles very small resource costs', () => { + const state = createFeeEstimationState({ costStroops: 1 }); + state.loadFeeEstimate(null); + + assert.ok(state.feeEstimate !== null); + assert.equal(state.feeEstimate.minResourceFeeStroops, 1); + assert.ok(state.feeEstimate.totalFeeStroops > 1); +}); + +test('FeeEstimationPreview: handles very large resource costs without overflow', () => { + const state = createFeeEstimationState({ costStroops: 1_000_000_000_000 }); + state.loadFeeEstimate(null); + + assert.ok(state.feeEstimate !== null); + assert.ok(state.feeEstimate.totalFeeStroops > 1_000_000_000_000); + assert.ok(Number.isFinite(state.feeEstimate.totalFeeStroops)); +}); diff --git a/web/components/FeeEstimationPreview.tsx b/web/components/FeeEstimationPreview.tsx new file mode 100644 index 00000000..adedcac8 --- /dev/null +++ b/web/components/FeeEstimationPreview.tsx @@ -0,0 +1,189 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import { Fuel, Zap, TrendingUp, Gauge, DollarSign } from 'lucide-react'; +import type { FeeEstimate, FeeBumpOption } from '../lib/sorobantypes'; +import { useNetwork } from '../context/NetworkContext'; +import { fetchFeeStats, estimateFees, stroopsToXlm } from '../lib/stellarRpc'; + +interface FeeEstimationPreviewProps { + costStroops?: number; + loading?: boolean; + onFeeBumpChange?: (level: string, feeStroops: number) => void; +} + +export function FeeEstimationPreview({ + costStroops = 0, + loading = false, + onFeeBumpChange, +}: FeeEstimationPreviewProps) { + const { network } = useNetwork(); + const [feeEstimate, setFeeEstimate] = useState(null); + const [selectedBump, setSelectedBump] = useState('low'); + const [fetching, setFetching] = useState(false); + const [rpcAvailable, setRpcAvailable] = useState(true); + + const loadFeeEstimate = useCallback(async () => { + if (costStroops <= 0) { + setFeeEstimate(null); + return; + } + setFetching(true); + try { + const stats = await fetchFeeStats(network.rpcUrl); + setRpcAvailable(true); + const estimate = estimateFees(costStroops, stats); + setFeeEstimate(estimate); + } catch { + const estimate = estimateFees(costStroops, null); + setFeeEstimate(estimate); + setRpcAvailable(false); + } finally { + setFetching(false); + } + }, [costStroops, network.rpcUrl]); + + useEffect(() => { + loadFeeEstimate(); + }, [loadFeeEstimate]); + + const handleBumpSelect = (bump: FeeBumpOption) => { + setSelectedBump(bump.label); + if (onFeeBumpChange) { + const level = bump.label.toLowerCase().includes('low') ? 'low' + : bump.label.toLowerCase().includes('high') ? 'high' + : 'medium'; + onFeeBumpChange(level, bump.feeStroops); + } + }; + + const isBusy = loading || fetching; + const hasFees = feeEstimate && feeEstimate.totalFeeStroops > 0; + + return ( +
+
+
+ +

+ Network Fee Estimation +

+
+ {!rpcAvailable && ( + + Estimated (RPC offline) + + )} +
+ + {isBusy ? ( +
+
+
+ ) : hasFees ? ( +
+
+
+
+ + Resource Fee +
+
+ {stroopsToXlm(feeEstimate.minResourceFeeStroops)} XLM +
+
+ {feeEstimate.minResourceFeeStroops.toLocaleString()} stroops +
+
+
+
+ + Classic Fee +
+
+ {stroopsToXlm(feeEstimate.classicFeeStroops)} XLM +
+
+ {feeEstimate.classicFeeStroops.toLocaleString()} stroops +
+
+
+
+ + Total +
+
+ {feeEstimate.totalFeeXlm} XLM +
+
+ {feeEstimate.totalFeeStroops.toLocaleString()} stroops +
+
+
+ + {feeEstimate.surgeMultiplier > 1 && ( +
+ + + Network surge active ({feeEstimate.surgeMultiplier}x multiplier) — fees may be higher than usual + +
+ )} + +
+
Fee Bump Options
+
+ {feeEstimate.feeBumps.map((bump) => { + const isSelected = selectedBump === bump.label; + return ( + + ); + })} +
+
+ +
+
+ Surge multiplier: {feeEstimate.surgeMultiplier}x + +
+
+
+ ) : ( +
+ +

+ Run a simulation to see network fee estimates +

+
+ )} +
+ ); +} diff --git a/web/lib/sorobantypes.ts b/web/lib/sorobantypes.ts index 16cff6dd..2643a5fd 100644 --- a/web/lib/sorobantypes.ts +++ b/web/lib/sorobantypes.ts @@ -216,3 +216,27 @@ export function generateMockResourceCost(): ResourceCost { transaction_size_bytes: Math.floor(Math.random() * 2 * 1024), }; } + +export interface FeeBumpOption { + label: string; + multiplier: number; + feeStroops: number; + feeXlm: string; + description: string; +} + +export interface FeeEstimate { + minResourceFeeStroops: number; + classicFeeStroops: number; + totalFeeStroops: number; + totalFeeXlm: string; + feeBumps: FeeBumpOption[]; + networkFees: { + low: number; + medium: number; + high: number; + }; + surgeMultiplier: number; +} + +export type FeeBumpLevel = 'low' | 'medium' | 'high'; diff --git a/web/lib/stellarRpc.test.cjs b/web/lib/stellarRpc.test.cjs new file mode 100644 index 00000000..e3ade4fb --- /dev/null +++ b/web/lib/stellarRpc.test.cjs @@ -0,0 +1,223 @@ +// stellarRpc.test.cjs — unit tests for Stellar RPC fee estimation logic +// Closes Issue #595 +// Runs with: node --test ./lib/stellarRpc.test.cjs + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const FeeBumpLevel = Object.freeze({ + LOW: 'low', + MEDIUM: 'medium', + HIGH: 'high', +}); + +const BUMP_CONFIG = Object.freeze({ + [FeeBumpLevel.LOW]: { + label: 'Low (Min)', + multiplier: 1.0, + description: 'Minimum fee, slower confirmation', + }, + [FeeBumpLevel.MEDIUM]: { + label: 'Medium', + multiplier: 1.5, + description: 'Balanced speed and cost', + }, + [FeeBumpLevel.HIGH]: { + label: 'High', + multiplier: 2.0, + description: 'Priority processing', + }, +}); + +function stroopsToXlm(stroops) { + const xlm = stroops / 10_000_000; + if (xlm === 0) return '0'; + if (xlm < 0.000001) return xlm.toFixed(9); + if (xlm < 0.001) return xlm.toFixed(7); + return xlm.toFixed(5); +} + +function formatStroops(stroops) { + const xlm = stroops / 10_000_000; + return `${xlm.toFixed(7)} XLM`; +} + +function estimateFees(resourceCostStroops, feeStats) { + const baseClassicFee = (feeStats && feeStats.min_ledger_fee) || 100; + const surge = (feeStats && feeStats.surge_multiplier) || 1; + const sorobanRate = (feeStats && feeStats.soroban_fee_rate) || 1; + + const minResourceFeeStroops = resourceCostStroops; + const classicFeeStroops = baseClassicFee; + const baseTotal = minResourceFeeStroops + classicFeeStroops; + + const surgeMultiplier = surge; + + const feeBumps = Object.values(BUMP_CONFIG).map((cfg) => { + const feeStroops = Math.ceil(baseTotal * cfg.multiplier * sorobanRate); + return { + label: cfg.label, + multiplier: cfg.multiplier, + feeStroops, + feeXlm: stroopsToXlm(feeStroops), + description: cfg.description, + }; + }); + + return { + minResourceFeeStroops, + classicFeeStroops, + totalFeeStroops: baseTotal, + totalFeeXlm: stroopsToXlm(baseTotal), + feeBumps, + networkFees: { + low: feeBumps[0].feeStroops, + medium: feeBumps[1].feeStroops, + high: feeBumps[2].feeStroops, + }, + surgeMultiplier, + }; +} + +// ── stroopsToXlm Tests ──────────────────────────────────────────────────────── + +test('stroopsToXlm: returns 0 for zero stroops', () => { + assert.equal(stroopsToXlm(0), '0'); +}); + +test('stroopsToXlm: converts 10_000_000 stroops to 1 XLM', () => { + assert.equal(stroopsToXlm(10_000_000), '1.00000'); +}); + +test('stroopsToXlm: converts 100 stroops (very small amount)', () => { + const result = stroopsToXlm(100); + assert.ok(result.startsWith('0.00001')); +}); + +test('stroopsToXlm: converts 1_000_000 stroops to 0.1 XLM', () => { + assert.equal(stroopsToXlm(1_000_000), '0.10000'); +}); + +test('stroopsToXlm: converts 5_000_000 stroops to 0.5 XLM', () => { + assert.equal(stroopsToXlm(5_000_000), '0.50000'); +}); + +// ── estimateFees Tests ──────────────────────────────────────────────────────── + +test('estimateFees: computes base fee breakdown from resource cost', () => { + const resourceCost = 500_000; + const stats = { + min_ledger_fee: 100, + surge_multiplier: 1, + soroban_fee_rate: 1, + }; + + const result = estimateFees(resourceCost, stats); + + assert.equal(result.minResourceFeeStroops, 500_000); + assert.equal(result.classicFeeStroops, 100); + assert.equal(result.totalFeeStroops, 500_100); + assert.ok(result.totalFeeXlm.length > 0); +}); + +test('estimateFees: generates three fee bump options', () => { + const result = estimateFees(500_000, null); + + assert.equal(result.feeBumps.length, 3); + assert.equal(result.feeBumps[0].multiplier, 1.0); + assert.equal(result.feeBumps[1].multiplier, 1.5); + assert.equal(result.feeBumps[2].multiplier, 2.0); +}); + +test('estimateFees: fee bumps increase proportionally', () => { + const result = estimateFees(1_000_000, null); + + const low = result.networkFees.low; + const medium = result.networkFees.medium; + const high = result.networkFees.high; + + assert.ok(low < medium, 'Low fee should be less than medium'); + assert.ok(medium < high, 'Medium fee should be less than high'); + assert.ok(high >= low * 2 || high >= low * 1.8, 'High should be roughly 2x low'); +}); + +test('estimateFees: applies surge multiplier when provided', () => { + const baseResourceCost = 500_000; + const normalResult = estimateFees(baseResourceCost, { + min_ledger_fee: 100, + surge_multiplier: 1, + soroban_fee_rate: 1, + }); + + const surgedResult = estimateFees(baseResourceCost, { + min_ledger_fee: 100, + surge_multiplier: 5, + soroban_fee_rate: 2, + }); + + assert.ok(surgedResult.surgeMultiplier > 1); + assert.ok(surgedResult.networkFees.low > normalResult.networkFees.low); +}); + +test('estimateFees: returns valid XLM strings for all fee bump options', () => { + const result = estimateFees(750_000, null); + for (const bump of result.feeBumps) { + assert.ok(typeof bump.feeXlm === 'string'); + assert.ok(bump.feeXlm.length > 0); + assert.ok(bump.feeXlm.includes('.') || bump.feeXlm === '0'); + } +}); + +test('estimateFees: handles zero resource cost gracefully', () => { + const result = estimateFees(0, null); + + assert.equal(result.minResourceFeeStroops, 0); + assert.equal(result.classicFeeStroops, 100); + assert.equal(result.totalFeeStroops, 100); + assert.ok(result.feeBumps.length === 3); +}); + +test('estimateFees: handles very large resource costs', () => { + const largeCost = 100_000_000_000; + const result = estimateFees(largeCost, null); + + assert.equal(result.minResourceFeeStroops, largeCost); + assert.ok(result.totalFeeStroops > largeCost); + assert.ok(result.feeBumps[2].feeStroops > result.feeBumps[0].feeStroops); +}); + +test('estimateFees: uses default classic fee when no fee stats provided', () => { + const result = estimateFees(1000, null); + assert.equal(result.classicFeeStroops, 100); +}); + +test('estimateFees: uses custom classic fee from fee stats', () => { + const result = estimateFees(1000, { + min_ledger_fee: 500, + surge_multiplier: 1, + soroban_fee_rate: 1, + }); + assert.equal(result.classicFeeStroops, 500); +}); + +test('estimateFees: classic fee + resource fee sum correctly', () => { + const resourceCost = 1000; + const result = estimateFees(resourceCost, { + min_ledger_fee: 200, + surge_multiplier: 1, + soroban_fee_rate: 1, + }); + + assert.equal(result.totalFeeStroops, resourceCost + 200); + assert.equal(result.minResourceFeeStroops + result.classicFeeStroops, result.totalFeeStroops); +}); + +// ── formatStroops Tests ─────────────────────────────────────────────────────── + +test('formatStroops: formats output string correctly', () => { + const result = formatStroops(10_000_000); + assert.ok(result.includes('XLM')); + assert.ok(result.includes('1.0000000')); +}); diff --git a/web/lib/stellarRpc.ts b/web/lib/stellarRpc.ts new file mode 100644 index 00000000..00117bce --- /dev/null +++ b/web/lib/stellarRpc.ts @@ -0,0 +1,188 @@ +/** + * Stellar RPC client for fee estimation. + * + * Communicates with Soroban RPC endpoints to retrieve network fee stats and + * compute transaction fee estimates from resource-cost data. + */ + +import type { FeeBumpOption, FeeEstimate } from './sorobantypes'; + +const RPC_TIMEOUT_MS = 10_000; + +export interface FeeStatsResponse { + soroban_fee_charged: FeeStatDistribution; + soroban_fee_rate: number; + surge_multiplier: number; + classic_fee_charged: FeeStatDistribution; + classic_fee_rate: number; + min_ledger_fee: number; +} + +export interface FeeStatDistribution { + max: number; + min: number; + mode: number; + p10: number; + p20: number; + p30: number; + p40: number; + p50: number; + p60: number; + p70: number; + p80: number; + p90: number; + p95: number; + p99: number; +} + +function parseStroops(value: string | number): number { + if (typeof value === 'number') return value; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +export function stroopsToXlm(stroops: number): string { + const xlm = stroops / 10_000_000; + if (xlm === 0) return '0'; + if (xlm < 0.000001) return xlm.toFixed(9); + if (xlm < 0.001) return xlm.toFixed(7); + return xlm.toFixed(5); +} + +export function formatStroops(stroops: number): string { + const xlm = stroops / 10_000_000; + return `${xlm.toFixed(7)} XLM`; +} + +async function rpcCall( + rpcUrl: string, + method: string, + params?: unknown, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), RPC_TIMEOUT_MS); + + try { + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method, + params: params ?? {}, + }), + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`RPC HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + if (data.error) { + throw new Error(`RPC error [${data.error.code}]: ${data.error.message}`); + } + + return data.result as T; + } finally { + clearTimeout(timer); + } +} + +export async function fetchFeeStats(rpcUrl: string): Promise { + const result: Record = await rpcCall(rpcUrl, 'getFeeStats'); + + const parseDist = (prefix: string): FeeStatDistribution => { + const v = (key: string) => parseStroops(result[`${prefix}_${key}`] as string | number); + return { + max: v('max'), + min: v('min'), + mode: v('mode'), + p10: v('p10'), + p20: v('p20'), + p30: v('p30'), + p40: v('p40'), + p50: v('p50'), + p60: v('p60'), + p70: v('p70'), + p80: v('p80'), + p90: v('p90'), + p95: v('p95'), + p99: v('p99'), + }; + }; + + return { + soroban_fee_charged: parseDist('soroban_fee_charged'), + soroban_fee_rate: parseStroops(result.soroban_fee_rate as string | number), + surge_multiplier: parseStroops(result.surge_multiplier as string | number), + classic_fee_charged: parseDist('classic_fee_charged'), + classic_fee_rate: parseStroops(result.classic_fee_rate as string | number), + min_ledger_fee: parseStroops(result.last_ledger_base_fee as string | number), + }; +} + +export enum FeeBumpLevel { + LOW = 'low', + MEDIUM = 'medium', + HIGH = 'high', +} + +const BUMP_CONFIG: Record = { + [FeeBumpLevel.LOW]: { + label: 'Low (Min)', + multiplier: 1.0, + description: 'Minimum fee, slower confirmation', + }, + [FeeBumpLevel.MEDIUM]: { + label: 'Medium', + multiplier: 1.5, + description: 'Balanced speed and cost', + }, + [FeeBumpLevel.HIGH]: { + label: 'High', + multiplier: 2.0, + description: 'Priority processing', + }, +}; + +export function estimateFees( + resourceCostStroops: number, + feeStats?: FeeStatsResponse | null, +): FeeEstimate { + const baseClassicFee = feeStats?.min_ledger_fee ?? 100; + const surge = feeStats?.surge_multiplier ?? 1; + const sorobanRate = feeStats?.soroban_fee_rate ?? 1; + + const minResourceFeeStroops = resourceCostStroops; + const classicFeeStroops = baseClassicFee; + const baseTotal = minResourceFeeStroops + classicFeeStroops; + + const surgeMultiplier = surge; + + const feeBumps: FeeBumpOption[] = Object.values(BUMP_CONFIG).map((cfg) => { + const feeStroops = Math.ceil(baseTotal * cfg.multiplier * sorobanRate); + return { + label: cfg.label, + multiplier: cfg.multiplier, + feeStroops, + feeXlm: stroopsToXlm(feeStroops), + description: cfg.description, + }; + }); + + return { + minResourceFeeStroops, + classicFeeStroops, + totalFeeStroops: baseTotal, + totalFeeXlm: stroopsToXlm(baseTotal), + feeBumps, + networkFees: { + low: feeBumps[0].feeStroops, + medium: feeBumps[1].feeStroops, + high: feeBumps[2].feeStroops, + }, + surgeMultiplier, + }; +} diff --git a/web/pages/index.tsx b/web/pages/index.tsx index f74b8820..06652808 100644 --- a/web/pages/index.tsx +++ b/web/pages/index.tsx @@ -14,6 +14,7 @@ import { NutritionLabelSkeleton } from "../components/NutritionLabelSkeleton"; import { ResourceHeatmap } from "../components/ResourceHeatmap"; import { ResultViewer } from "../components/Resultviewer"; import { ResultViewerSkeleton } from "../components/ResultViewerSkeleton"; +import { FeeEstimationPreview } from "../components/FeeEstimationPreview"; import { UploadZone } from "../components/upload-zone"; import { CopyButton } from "../components/CopyButton"; import { useNetwork } from "../context/NetworkContext"; @@ -177,7 +178,7 @@ export default function Home() { ledger_read_bytes: analysisReport.ledger_read_bytes, ledger_write_bytes: analysisReport.ledger_write_bytes, transaction_size_bytes: analysisReport.transaction_size_bytes, - cost_stroops: (analysisReport as any).cost_stroops, + cost_stroops: analysisReport.cost_stroops, state_snapshot: currentResult.stateSnapshot }} />
@@ -197,8 +198,16 @@ export default function Home() { ledger_read_bytes={analysisReport.ledger_read_bytes} ledger_write_bytes={analysisReport.ledger_write_bytes} transaction_size_bytes={analysisReport.transaction_size_bytes} - cost_stroops={(analysisReport as any).cost_stroops} - testnetAverages={(analysisReport as any).testnet_averages} + cost_stroops={analysisReport.cost_stroops} + testnetAverages={analysisReport.testnet_averages} + /> + + )} + {analysisReport && analysisReport.cost_stroops !== undefined && ( +
+
)}