diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index 981b71eb82..5a876f957d 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -2208,6 +2208,7 @@ export const simulateTransaction = async (args: { body: JSON.stringify({ xdr, network_passphrase: networkDetails.networkPassphrase, + network_url: networkDetails.sorobanRpcUrl, }), }; const res = await fetch(`${INDEXER_URL}/simulate-tx`, options); diff --git a/extension/e2e-tests/reviewTxFees.test.ts b/extension/e2e-tests/reviewTxFees.test.ts new file mode 100644 index 0000000000..6f20e5fb3c --- /dev/null +++ b/extension/e2e-tests/reviewTxFees.test.ts @@ -0,0 +1,823 @@ +import { loginToTestAccount } from "./helpers/login"; +import { + stubAccountBalancesE2e, + stubContractSpec, + stubSimulateSendCollectible, +} from "./helpers/stubs"; +import { TEST_TOKEN_ADDRESS } from "./helpers/test-token"; +import { test, expect } from "./test-fixtures"; + +const FUNDED_DESTINATION = + "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"; +// Second funded destination used for destination-change re-simulation tests +const FUNDED_DESTINATION_2 = + "GDMDFPJPFH4Z2LLUCNNQT3HVQ2XU2TMZBA6OL37C752WCKU7JZO2S52R"; + +// Token send (Soroban): stubSimulateTokenTransfer returns minResourceFee "93238" stroops +// = 0.0093238 XLM. baseFee = 0.00001 XLM. Total = 0.0093338 XLM. +test("Fee breakdown pane shows Soroban fees for token send", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + const stubOverrides = async () => { + await stubAccountBalancesE2e(page); + }; + await stubContractSpec(page, TEST_TOKEN_ADDRESS, true); + + await loginToTestAccount({ page, extensionId, context, stubOverrides }); + + // Navigate to token send via Asset Detail + await page.getByText("E2E").click(); + await page.getByTestId("asset-detail-send-button").click(); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await page.getByTestId("send-amount-amount-input").fill("0.1"); + + // Set destination (address tile in SendAmount opens the SendTo step) + await page.getByTestId("address-tile").click(); + await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); + await page.getByText("Continue").click(); + + // Wait for simulation to finish, then click Review Send + const reviewSendButton = page.getByTestId("send-amount-btn-continue"); + await expect(reviewSendButton).toBeEnabled({ timeout: 10000 }); + await reviewSendButton.click({ force: true }); + + await expect(page.getByText("You are sending").first()).toBeVisible(); + + // Open fees breakdown pane + await page.getByTestId("review-tx-fee-info-btn").click(); + await expect(page.getByTestId("review-tx-fees-pane")).toBeVisible(); + + // Inclusion fee = baseFee = 0.00001 XLM + await expect(page.getByTestId("review-tx-inclusion-fee")).toHaveText( + "0.00001 XLM", + ); + + // Resource fee = 0.0093238 XLM (93238 stroops / 1e7) + await expect(page.getByTestId("review-tx-resource-fee")).toHaveText( + "0.0093238 XLM", + ); + + // Total fee = inclusion + resource + await expect(page.getByTestId("review-tx-total-fee")).toHaveText( + "0.0093338 XLM", + ); + + // Description explains Soroban simulation adjustments + await expect(page.getByTestId("review-tx-fees-description")).toContainText( + "Soroban", + ); + + // Closing the pane returns to the review screen + await page.getByTestId("review-tx-fees-close-btn").click(); + await expect(page.getByTestId("review-tx-fees-pane")).not.toBeVisible(); + await expect(page.getByText("You are sending").first()).toBeVisible(); +}); + +// Classic XLM send: no resource fee, only total fee shown +test("Fee breakdown pane shows only total fee for classic XLM send", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + await loginToTestAccount({ page, extensionId, context }); + + await page.getByTestId("nav-link-send").click({ force: true }); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + + // Navigate to Send To screen + await page.getByTestId("address-tile").click(); + await expect(page.getByTestId("send-to-input")).toBeVisible(); + await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); + await page.getByText("Continue").click({ force: true }); + + // Back on Send Amount: enter amount + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await page.getByTestId("send-amount-amount-input").fill("1"); + + await page.getByText("Review Send").click({ force: true }); + await expect(page.getByText("You are sending").first()).toBeVisible(); + + // Open fees breakdown pane + await page.getByTestId("review-tx-fee-info-btn").click(); + await expect(page.getByTestId("review-tx-fees-pane")).toBeVisible(); + + // No inclusion/resource fee rows for classic transactions + await expect(page.getByTestId("review-tx-inclusion-fee")).not.toBeVisible(); + await expect(page.getByTestId("review-tx-resource-fee")).not.toBeVisible(); + + // Total fee = base fee = 0.00001 XLM + await expect(page.getByTestId("review-tx-total-fee")).toHaveText( + "0.00001 XLM", + ); + + // Description is the classic variant (no mention of Soroban simulation) + await expect( + page.getByTestId("review-tx-fees-description"), + ).not.toContainText("Soroban"); + await expect(page.getByTestId("review-tx-fees-description")).toContainText( + "These fees go to the network", + ); + + // Closing the pane returns to the review screen + await page.getByTestId("review-tx-fees-close-btn").click(); + await expect(page.getByTestId("review-tx-fees-pane")).not.toBeVisible(); + await expect(page.getByText("You are sending").first()).toBeVisible(); +}); + +// Collectible send (Soroban): stubSimulateSendCollectible returns minResourceFee "100" stroops +// = 0.00001 XLM. baseFee = 0.00001 XLM. Total = 0.00002 XLM. +test("Fee breakdown pane shows Soroban fees for collectible send", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + const stubOverrides = async () => { + await stubSimulateSendCollectible(page); + }; + await stubContractSpec( + page, + "CCTYMI5ME6NFJC675P2CHNVG467YQJQ5E4TWP5RAPYYNKWK7DIUUDENN", + true, + ); + + await loginToTestAccount({ page, extensionId, context, stubOverrides }); + + await page.getByTestId("nav-link-send").click({ force: true }); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + + // Select a collectible + await page.getByTestId("send-amount-edit-dest-asset").click(); + await page.getByTestId("account-tab-collectibles").click(); + await page.getByText("Stellar Frog 1").click(); + await expect(page.getByTestId("SelectedCollectible")).toBeVisible(); + + // Set destination + await page.getByTestId("address-tile").click(); + await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); + await page.getByText("Continue").click({ force: true }); + + // Wait for simulation to finish, then click Review Send + const reviewSendButton = page.getByTestId("send-collectible-btn-continue"); + await expect(reviewSendButton).toBeEnabled({ timeout: 10000 }); + await reviewSendButton.click({ force: true }); + + await expect(page.getByText("You are sending").first()).toBeVisible(); + + // Open fees breakdown pane + await page.getByTestId("review-tx-fee-info-btn").click(); + await expect(page.getByTestId("review-tx-fees-pane")).toBeVisible(); + + // Inclusion fee = baseFee = 0.00001 XLM + await expect(page.getByTestId("review-tx-inclusion-fee")).toHaveText( + "0.00001 XLM", + ); + + // Resource fee = 0.00001 XLM (100 stroops / 1e7) + await expect(page.getByTestId("review-tx-resource-fee")).toHaveText( + "0.00001 XLM", + ); + + // Total fee = 0.00001 + 0.00001 + await expect(page.getByTestId("review-tx-total-fee")).toHaveText( + "0.00002 XLM", + ); + + // Description explains Soroban simulation adjustments + await expect(page.getByTestId("review-tx-fees-description")).toContainText( + "Soroban", + ); + + // Closing the pane returns to the review screen + await page.getByTestId("review-tx-fees-close-btn").click(); + await expect(page.getByTestId("review-tx-fees-pane")).not.toBeVisible(); + await expect(page.getByText("You are sending").first()).toBeVisible(); +}); + +// ── Comprehensive scenario 1: custom token only (no destination) ───────────── +// Full fee lifecycle: open settings → open FeesPane (no simulation data) → +// go back → change draft → open FeesPane again (draft reflected) → Save → +// all places updated → reopen settings shows saved → reopen FeesPane shows saved. +test("Custom token without destination — full fee lifecycle in EditSettings and FeesPane", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + const stubOverrides = async () => { + await stubAccountBalancesE2e(page); + }; + await stubContractSpec(page, TEST_TOKEN_ADDRESS, true); + + await loginToTestAccount({ page, extensionId, context, stubOverrides }); + + // Navigate to token send (no destination set yet) + await page.getByText("E2E").click(); + await page.getByTestId("asset-detail-send-button").click(); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + + // Without a destination, no auto-simulation fires — fee display stays at base fee + await expect(page.getByTestId("send-amount-fee-display")).toHaveText( + "0.00001 XLM", + ); + + // ── Open Edit Settings ────────────────────────────────────────────────────── + await page.getByTestId("send-amount-btn-fee").click(); + await expect(page.getByText("Inclusion Fee")).toBeVisible(); + // No auto-simulation yet — shows recommended base fee + await expect(page.getByTestId("edit-tx-settings-fee-input")).toHaveValue( + "0.00001", + ); + + // ── Open FeesPane — simulation is triggered by the gear click ───────────── + // The stub responds even without a destination; wait for it to settle + await page.getByTestId("edit-settings-fees-info-btn").click(); + await expect(page.getByTestId("review-tx-fees-pane")).toBeVisible(); + await expect(page.getByTestId("review-tx-inclusion-fee")).toHaveText( + "0.00001 XLM", + { timeout: 10000 }, + ); + await expect(page.getByTestId("review-tx-resource-fee")).toHaveText( + "0.0093238 XLM", + ); + await expect(page.getByTestId("review-tx-total-fee")).toHaveText( + "0.0093338 XLM", + ); + await expect(page.getByTestId("review-tx-fees-description")).toContainText( + "Soroban", + ); + + // ── Close FeesPane → back to Edit Settings ──────────────────────────────── + await page.getByTestId("review-tx-fees-close-btn").click(); + await expect(page.getByTestId("review-tx-fees-pane")).not.toBeVisible(); + await expect(page.getByText("Inclusion Fee")).toBeVisible(); + + // ── Change draft fee ────────────────────────────────────────────────────── + await page.getByTestId("edit-tx-settings-fee-input").fill("0.00005"); + await expect(page.getByTestId("edit-tx-settings-fee-input")).toHaveValue( + "0.00005", + ); + + // ── Open FeesPane again — must reflect the draft fee + resource ─────────── + await page.getByTestId("edit-settings-fees-info-btn").click(); + await expect(page.getByTestId("review-tx-fees-pane")).toBeVisible(); + // Total = draft(0.00005) + resource(0.0093238) = 0.0093738 + await expect(page.getByTestId("review-tx-total-fee")).toHaveText( + "0.0093738 XLM", + { timeout: 5000 }, + ); + + // ── Close FeesPane → draft still in the input ───────────────────────────── + await page.getByTestId("review-tx-fees-close-btn").click(); + await expect(page.getByTestId("review-tx-fees-pane")).not.toBeVisible(); + await expect(page.getByTestId("edit-tx-settings-fee-input")).toHaveValue( + "0.00005", + ); + + // ── Save the custom fee ─────────────────────────────────────────────────── + await page.getByRole("button", { name: "Save" }).click(); + await expect(page.getByText("Inclusion Fee")).not.toBeVisible(); + // Re-simulation with baseFee=0.00005 → total = 0.0093738 + await expect(page.getByTestId("send-amount-fee-display")).toHaveText( + "0.0093738 XLM", + { timeout: 10000 }, + ); + + // ── Reopen Edit Settings — must show saved fee, not the base default ─────── + await page.getByTestId("send-amount-btn-fee").click(); + await expect(page.getByText("Inclusion Fee")).toBeVisible(); + await expect(page.getByTestId("edit-tx-settings-fee-input")).toHaveValue( + "0.00005", + ); + + // ── Open FeesPane from re-opened settings — must still show saved fee ────── + await page.getByTestId("edit-settings-fees-info-btn").click(); + await expect(page.getByTestId("review-tx-fees-pane")).toBeVisible(); + await expect(page.getByTestId("review-tx-inclusion-fee")).toHaveText( + "0.00005 XLM", + { timeout: 10000 }, + ); + await expect(page.getByTestId("review-tx-resource-fee")).toHaveText( + "0.0093238 XLM", + ); + await expect(page.getByTestId("review-tx-total-fee")).toHaveText( + "0.0093738 XLM", + ); + await expect(page.getByTestId("review-tx-fees-description")).toContainText( + "Soroban", + ); +}); + +// ── Comprehensive scenario 2: custom token + recipient (Soroban) ────────────── +// Same lifecycle as scenario 1, but with destination set so simulation data is +// available: full inclusion + resource breakdown, draft total = inclusion + resource, +// Save triggers re-simulation, re-opened settings shows saved fee, FeesPane updated. +test("Custom token with recipient — full fee lifecycle in EditSettings and FeesPane", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + const stubOverrides = async () => { + await stubAccountBalancesE2e(page); + }; + await stubContractSpec(page, TEST_TOKEN_ADDRESS, true); + + await loginToTestAccount({ page, extensionId, context, stubOverrides }); + + // Navigate to token send and set destination + await page.getByText("E2E").click(); + await page.getByTestId("asset-detail-send-button").click(); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await page.getByTestId("send-amount-amount-input").fill("0.1"); + + await page.getByTestId("address-tile").click(); + await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); + await page.getByText("Continue").click(); + + // Wait for auto-simulation: total = baseFee(0.00001) + resource(0.0093238) + await expect(page.getByTestId("send-amount-fee-display")).toHaveText( + "0.0093338 XLM", + { timeout: 10000 }, + ); + + // ── Open Edit Settings ────────────────────────────────────────────────────── + await page.getByTestId("send-amount-btn-fee").click(); + await expect(page.getByText("Inclusion Fee")).toBeVisible(); + // Shows the inclusion fee from simulation (base fee only, not the total) + await expect(page.getByTestId("edit-tx-settings-fee-input")).toHaveValue( + "0.00001", + ); + + // ── Open FeesPane (full simulation data) ───────────────────────────────── + await page.getByTestId("edit-settings-fees-info-btn").click(); + await expect(page.getByTestId("review-tx-fees-pane")).toBeVisible(); + await expect(page.getByTestId("review-tx-inclusion-fee")).toHaveText( + "0.00001 XLM", + ); + await expect(page.getByTestId("review-tx-resource-fee")).toHaveText( + "0.0093238 XLM", + ); + await expect(page.getByTestId("review-tx-total-fee")).toHaveText( + "0.0093338 XLM", + ); + await expect(page.getByTestId("review-tx-fees-description")).toContainText( + "Soroban", + ); + + // ── Close FeesPane → back to Edit Settings ──────────────────────────────── + await page.getByTestId("review-tx-fees-close-btn").click(); + await expect(page.getByTestId("review-tx-fees-pane")).not.toBeVisible(); + await expect(page.getByText("Inclusion Fee")).toBeVisible(); + + // ── Change draft fee ────────────────────────────────────────────────────── + await page.getByTestId("edit-tx-settings-fee-input").fill("0.00005"); + await expect(page.getByTestId("edit-tx-settings-fee-input")).toHaveValue( + "0.00005", + ); + + // ── Open FeesPane again — total must use draft + resource ───────────────── + await page.getByTestId("edit-settings-fees-info-btn").click(); + await expect(page.getByTestId("review-tx-fees-pane")).toBeVisible(); + // Total = draft(0.00005) + resource(0.0093238) = 0.0093738 + await expect(page.getByTestId("review-tx-total-fee")).toHaveText( + "0.0093738 XLM", + ); + + // ── Close FeesPane → draft still in the input ───────────────────────────── + await page.getByTestId("review-tx-fees-close-btn").click(); + await expect(page.getByTestId("review-tx-fees-pane")).not.toBeVisible(); + await expect(page.getByTestId("edit-tx-settings-fee-input")).toHaveValue( + "0.00005", + ); + + // ── Save the custom fee ─────────────────────────────────────────────────── + await page.getByRole("button", { name: "Save" }).click(); + await expect(page.getByText("Inclusion Fee")).not.toBeVisible(); + + // Re-simulation runs with new baseFee = 0.00005 → total = 0.0093738 + await expect(page.getByTestId("send-amount-fee-display")).toHaveText( + "0.0093738 XLM", + { timeout: 10000 }, + ); + + // ── Reopen Edit Settings — saved fee must survive re-simulation ──────────── + await page.getByTestId("send-amount-btn-fee").click(); + await expect(page.getByText("Inclusion Fee")).toBeVisible(); + await expect(page.getByTestId("edit-tx-settings-fee-input")).toHaveValue( + "0.00005", + ); + + // ── Open FeesPane from re-opened settings ───────────────────────────────── + // Re-simulation used baseFee=0.00005 → inclusionFee=0.00005, resource unchanged + await page.getByTestId("edit-settings-fees-info-btn").click(); + await expect(page.getByTestId("review-tx-fees-pane")).toBeVisible(); + await expect(page.getByTestId("review-tx-inclusion-fee")).toHaveText( + "0.00005 XLM", + ); + await expect(page.getByTestId("review-tx-resource-fee")).toHaveText( + "0.0093238 XLM", + ); + await expect(page.getByTestId("review-tx-total-fee")).toHaveText( + "0.0093738 XLM", + ); + await expect(page.getByTestId("review-tx-fees-description")).toContainText( + "Soroban", + ); +}); + +// ── Comprehensive scenario 3: fee override is session-scoped ────────────────── +// After saving a custom fee, navigating back to the home screen and re-entering +// the send flow must reset to the default simulated fee. The manual override +// is intentionally not persisted across navigation sessions. +test("Custom fee resets to default when re-entering send flow from home screen", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + const stubOverrides = async () => { + await stubAccountBalancesE2e(page); + }; + await stubContractSpec(page, TEST_TOKEN_ADDRESS, true); + + await loginToTestAccount({ page, extensionId, context, stubOverrides }); + + // ── First session: set custom fee ───────────────────────────────────────── + await page.getByText("E2E").click(); + await page.getByTestId("asset-detail-send-button").click(); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await page.getByTestId("send-amount-amount-input").fill("0.1"); + + await page.getByTestId("address-tile").click(); + await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); + await page.getByText("Continue").click(); + + // Wait for simulation + await expect(page.getByTestId("send-amount-fee-display")).toHaveText( + "0.0093338 XLM", + { timeout: 10000 }, + ); + + // Save a custom fee + await page.getByTestId("send-amount-btn-fee").click(); + await expect(page.getByText("Inclusion Fee")).toBeVisible(); + await page.getByTestId("edit-tx-settings-fee-input").fill("0.00005"); + await page.getByRole("button", { name: "Save" }).click(); + await expect(page.getByText("Inclusion Fee")).not.toBeVisible(); + + // Confirm the override is active: re-simulation total = 0.0093738 + await expect(page.getByTestId("send-amount-fee-display")).toHaveText( + "0.0093738 XLM", + { timeout: 10000 }, + ); + + // ── Navigate back to home screen ────────────────────────────────────────── + await page.getByTestId("BackButton").click(); + // goBack() dispatches resetSubmission() (clears destination / fees / state) + // and navigates to ROUTES.account (the main AccountView) + await expect(page.getByTestId("account-view")).toBeVisible({ + timeout: 10000, + }); + + // ── Second session: re-enter the same send flow ─────────────────────────── + await page.getByText("E2E").click(); + await page.getByTestId("asset-detail-send-button").click(); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + + // resetSubmission cleared destination → no auto-simulation fires on mount. + // Fee display shows the base fee, NOT the previous override total (0.0093738). + await expect(page.getByTestId("send-amount-fee-display")).toHaveText( + "0.00001 XLM", + ); + + // ── EditSettings must show the default inclusion fee, not the saved "0.00005" ─ + await page.getByTestId("send-amount-btn-fee").click(); + await expect(page.getByText("Inclusion Fee")).toBeVisible(); + await expect(page.getByTestId("edit-tx-settings-fee-input")).toHaveValue( + "0.00001", + ); + + // ── FeesPane (simulation triggered by gear click) shows default total ───── + await page.getByTestId("edit-settings-fees-info-btn").click(); + await expect(page.getByTestId("review-tx-fees-pane")).toBeVisible(); + // Once simulation settles, total = base(0.00001) + resource(0.0093238) = 0.0093338 + await expect(page.getByTestId("review-tx-total-fee")).toHaveText( + "0.0093338 XLM", + { timeout: 10000 }, + ); +}); + +// Auto-simulation: after setting destination for a Soroban token send the fee +// display on the SendAmount screen updates to the simulated total WITHOUT the +// user needing to click "Review Send" first. +test("Auto-simulation updates fee display on SendAmount before Review Send", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + const stubOverrides = async () => { + await stubAccountBalancesE2e(page); + }; + await stubContractSpec(page, TEST_TOKEN_ADDRESS, true); + + await loginToTestAccount({ page, extensionId, context, stubOverrides }); + + // Navigate to token send via Asset Detail + await page.getByText("E2E").click(); + await page.getByTestId("asset-detail-send-button").click(); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await page.getByTestId("send-amount-amount-input").fill("0.1"); + + // Set destination — auto-simulation fires automatically after returning to SendAmount + await page.getByTestId("address-tile").click(); + await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); + await page.getByText("Continue").click(); + + // Without clicking "Review Send", the fee display should update to the + // simulated total (inclusion + resource). + await expect(page.getByTestId("send-amount-fee-display")).toHaveText( + "0.0093338 XLM", + { timeout: 10000 }, + ); +}); + +test("Soroban token — manually set fee is preserved when recipient is selected after saving", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + const stubOverrides = async () => { + await stubAccountBalancesE2e(page); + }; + await stubContractSpec(page, TEST_TOKEN_ADDRESS, true); + await loginToTestAccount({ page, extensionId, context, stubOverrides }); + + await page.getByText("E2E").click(); + await page.getByTestId("asset-detail-send-button").click(); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await page.getByTestId("send-amount-amount-input").fill("0.1"); + + // Set custom fee before picking a recipient + await page.getByTestId("send-amount-btn-fee").click(); + await expect(page.getByText("Inclusion Fee")).toBeVisible(); + await expect(page.getByTestId("edit-tx-settings-fee-input")).toHaveValue( + "0.00001", + ); + await page.getByTestId("edit-tx-settings-fee-input").fill("0.00005"); + await page.getByRole("button", { name: "Save" }).click(); + await expect(page.getByText("Inclusion Fee")).not.toBeVisible(); + + // Select recipient — SendAmount remounts; fee must survive via Redux persistence + await page.getByTestId("address-tile").click(); + await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); + await page.getByText("Continue").click(); + + // Re-simulation uses saved baseFee=0.00005 → total = 0.00005 + 0.0093238 + await expect(page.getByTestId("send-amount-fee-display")).toHaveText( + "0.0093738 XLM", + { timeout: 10000 }, + ); + + await page.getByTestId("send-amount-btn-fee").click(); + await expect(page.getByText("Inclusion Fee")).toBeVisible(); + await expect(page.getByTestId("edit-tx-settings-fee-input")).toHaveValue( + "0.00005", + ); +}); + +test("Classic send — manually set fee is applied and shown in settings", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + await loginToTestAccount({ page, extensionId, context }); + + await page.getByTestId("nav-link-send").click({ force: true }); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await expect(page.getByTestId("send-amount-fee-display")).toHaveText( + "0.00001 XLM", + ); + + await page.getByTestId("send-amount-btn-fee").click(); + await expect(page.getByText("Transaction Fee")).toBeVisible(); + await expect(page.getByTestId("edit-tx-settings-fee-input")).toHaveValue( + "0.00001", + ); + + await page.getByTestId("edit-tx-settings-fee-input").fill("0.00005"); + await page.getByRole("button", { name: "Save" }).click(); + await expect(page.getByText("Transaction Fee")).not.toBeVisible(); + await expect(page.getByTestId("send-amount-fee-display")).toHaveText( + "0.00005 XLM", + ); + + await page.getByTestId("send-amount-btn-fee").click(); + await expect(page.getByText("Transaction Fee")).toBeVisible(); + await expect(page.getByTestId("edit-tx-settings-fee-input")).toHaveValue( + "0.00005", + ); +}); + +test("Classic send — manually set fee carries through to Review Send", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + await loginToTestAccount({ page, extensionId, context }); + + await page.getByTestId("nav-link-send").click({ force: true }); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + + await page.getByTestId("address-tile").click(); + await expect(page.getByTestId("send-to-input")).toBeVisible(); + await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); + await page.getByText("Continue").click({ force: true }); + + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await page.getByTestId("send-amount-amount-input").fill("1"); + await expect(page.getByTestId("send-amount-fee-display")).toHaveText( + "0.00001 XLM", + ); + + await page.getByTestId("send-amount-btn-fee").click(); + await expect(page.getByText("Transaction Fee")).toBeVisible(); + await expect(page.getByTestId("edit-tx-settings-fee-input")).toHaveValue( + "0.00001", + ); + await page.getByTestId("edit-tx-settings-fee-input").fill("0.00005"); + await page.getByRole("button", { name: "Save" }).click(); + await expect(page.getByText("Transaction Fee")).not.toBeVisible(); + await expect(page.getByTestId("send-amount-fee-display")).toHaveText( + "0.00005 XLM", + ); + + await page.getByTestId("send-amount-btn-fee").click(); + await expect(page.getByText("Transaction Fee")).toBeVisible(); + await expect(page.getByTestId("edit-tx-settings-fee-input")).toHaveValue( + "0.00005", + ); + await page.getByRole("button", { name: "Save" }).click(); + await expect(page.getByText("Transaction Fee")).not.toBeVisible(); + + await page.getByText("Review Send").click({ force: true }); + await expect(page.getByText("You are sending").first()).toBeVisible(); + await expect(page.getByTestId("review-tx-fee")).toHaveText("0.00005 XLM"); +}); + +test("Classic send — manually set fee resets when re-entering send flow from home", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + await loginToTestAccount({ page, extensionId, context }); + + await page.getByTestId("nav-link-send").click({ force: true }); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + + await page.getByTestId("send-amount-btn-fee").click(); + await expect(page.getByText("Transaction Fee")).toBeVisible(); + await page.getByTestId("edit-tx-settings-fee-input").fill("0.00005"); + await page.getByRole("button", { name: "Save" }).click(); + await expect(page.getByText("Transaction Fee")).not.toBeVisible(); + await expect(page.getByTestId("send-amount-fee-display")).toHaveText( + "0.00005 XLM", + ); + + await page.getByTestId("BackButton").click(); + await expect(page.getByTestId("account-view")).toBeVisible({ + timeout: 10000, + }); + + await page.getByTestId("nav-link-send").click({ force: true }); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await expect(page.getByTestId("send-amount-fee-display")).toHaveText( + "0.00001 XLM", + ); + + await page.getByTestId("send-amount-btn-fee").click(); + await expect(page.getByText("Transaction Fee")).toBeVisible(); + await expect(page.getByTestId("edit-tx-settings-fee-input")).toHaveValue( + "0.00001", + ); +}); + +test("Classic send — manually set fee is preserved across change of recipient", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + await loginToTestAccount({ page, extensionId, context }); + + await page.getByTestId("nav-link-send").click({ force: true }); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + + // Set custom fee before picking a recipient + await page.getByTestId("send-amount-btn-fee").click(); + await expect(page.getByText("Transaction Fee")).toBeVisible(); + await page.getByTestId("edit-tx-settings-fee-input").fill("0.00005"); + await page.getByRole("button", { name: "Save" }).click(); + await expect(page.getByText("Transaction Fee")).not.toBeVisible(); + await expect(page.getByTestId("send-amount-fee-display")).toHaveText( + "0.00005 XLM", + ); + + // Select recipient — SendAmount remounts; no simulation runs for classic + await page.getByTestId("address-tile").click(); + await expect(page.getByTestId("send-to-input")).toBeVisible(); + await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); + await page.getByText("Continue").click({ force: true }); + + await expect(page.getByTestId("send-amount-fee-display")).toHaveText( + "0.00005 XLM", + ); + + await page.getByTestId("send-amount-btn-fee").click(); + await expect(page.getByText("Transaction Fee")).toBeVisible(); + await expect(page.getByTestId("edit-tx-settings-fee-input")).toHaveValue( + "0.00005", + ); + await page.getByRole("button", { name: "Save" }).click(); + await expect(page.getByText("Transaction Fee")).not.toBeVisible(); + + await page.getByTestId("send-amount-amount-input").fill("1"); + await page.getByText("Review Send").click({ force: true }); + await expect(page.getByText("You are sending").first()).toBeVisible(); + await expect(page.getByTestId("review-tx-fee")).toHaveText("0.00005 XLM"); +}); + +// Re-simulation: changing the destination triggers a new simulation. The +// baseFee reset ensures the second simulation still uses the original inclusion +// fee (0.00001) as its base — EditSettings must show that value, not the inflated total. +test("Re-simulation on destination change shows correct inclusion fee in EditSettings", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + const stubOverrides = async () => { + await stubAccountBalancesE2e(page); + }; + await stubContractSpec(page, TEST_TOKEN_ADDRESS, true); + + await loginToTestAccount({ page, extensionId, context, stubOverrides }); + + // Navigate to token send + await page.getByText("E2E").click(); + await page.getByTestId("asset-detail-send-button").click(); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await page.getByTestId("send-amount-amount-input").fill("0.1"); + + // Set first destination — auto-simulation fires + await page.getByTestId("address-tile").click(); + await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); + await page.getByText("Continue").click(); + + // Wait for first simulation to finish + await expect(page.getByTestId("send-amount-fee-display")).toHaveText( + "0.0093338 XLM", + { timeout: 10000 }, + ); + + // Change to a different destination — re-simulation should fire + await page.getByTestId("address-tile").click(); + await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION_2); + await page.getByText("Continue").click(); + + // Wait for re-simulation to complete + await expect(page.getByTestId("send-amount-fee-display")).toHaveText( + "0.0093338 XLM", + { timeout: 10000 }, + ); + + // Open EditSettings — inclusion fee must be the base fee (0.00001 XLM), + // not the inflated total that was briefly stored in Redux after the first simulation. + await page.getByTestId("send-amount-btn-fee").click(); + await expect(page.getByText("Inclusion Fee")).toBeVisible(); + await expect(page.getByTestId("edit-tx-settings-fee-input")).toHaveValue( + "0.00001", + ); +}); diff --git a/extension/src/popup/components/InternalTransaction/EditSettings/index.tsx b/extension/src/popup/components/InternalTransaction/EditSettings/index.tsx index ff0919e0cc..681d66df6f 100644 --- a/extension/src/popup/components/InternalTransaction/EditSettings/index.tsx +++ b/extension/src/popup/components/InternalTransaction/EditSettings/index.tsx @@ -1,5 +1,5 @@ -import React from "react"; -import { Button, Card, Input } from "@stellar/design-system"; +import React, { useRef } from "react"; +import { Button, Card, Icon, Input } from "@stellar/design-system"; import { Field, FieldProps, Formik, Form } from "formik"; import { useTranslation } from "react-i18next"; @@ -17,7 +17,10 @@ interface EditSettingsProps { timeout: number; congestion: string; title: string; + isSoroban?: boolean; onClose: () => void; + onFeeChange?: (fee: string) => void; + onShowFeesInfo?: (currentDraftFee: string) => void; onSubmit: (args: EditSettingsFormValue) => void; } @@ -26,10 +29,16 @@ export const EditSettings = ({ timeout, congestion, title, + isSoroban = false, onClose, + onFeeChange, + onShowFeesInfo, onSubmit, }: EditSettingsProps) => { const { t } = useTranslation(); + // Tracks the current draft fee so onShowFeesInfo can pass it to the parent + // for an accurate FeesPane total without requiring a Redux save first. + const draftFeeRef = useRef(fee); const initialValues: EditSettingsFormValue = { fee, timeout, @@ -38,6 +47,23 @@ export const EditSettings = ({ onSubmit(values); }; + const feeLabel = ( + + {isSoroban ? t("Inclusion Fee") : t("Transaction Fee")} + {isSoroban && onShowFeesInfo && ( + + )} + + ); + return (
@@ -57,13 +83,15 @@ export const EditSettings = ({ autoComplete="off" id="fee" placeholder={t("Fee")} - label={t("Transaction Fee")} + label={feeLabel} {...field} error={errors.fee} onChange={(e) => { let value = e.target.value; if (value === "") { setFieldValue("fee", ""); + draftFeeRef.current = ""; + onFeeChange?.(""); return; } @@ -76,13 +104,19 @@ export const EditSettings = ({ } setFieldValue("fee", value); + draftFeeRef.current = value; + onFeeChange?.(value); }} rightElement={ diff --git a/extension/src/popup/components/InternalTransaction/EditSettings/styles.scss b/extension/src/popup/components/InternalTransaction/EditSettings/styles.scss index eaa3055e74..8dc587b683 100644 --- a/extension/src/popup/components/InternalTransaction/EditSettings/styles.scss +++ b/extension/src/popup/components/InternalTransaction/EditSettings/styles.scss @@ -28,6 +28,31 @@ } } + &__fee-label { + display: inline-flex; + align-items: center; + gap: pxToRem(4px); + } + + &__fee-info-btn { + background: none; + border: none; + cursor: pointer; + padding: 0; + display: inline-flex; + align-items: center; + color: var(--sds-clr-gray-09); + + svg { + width: pxToRem(14px); + height: pxToRem(14px); + } + + &:hover { + color: var(--sds-clr-gray-12); + } + } + &__actions { display: flex; margin-top: pxToRem(32px); diff --git a/extension/src/popup/components/InternalTransaction/FeesPane/index.tsx b/extension/src/popup/components/InternalTransaction/FeesPane/index.tsx new file mode 100644 index 0000000000..70e8facf26 --- /dev/null +++ b/extension/src/popup/components/InternalTransaction/FeesPane/index.tsx @@ -0,0 +1,97 @@ +import React from "react"; +import { Icon } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; + +import { RequestState, State } from "constants/request"; +import { SimulateTxData } from "types/transactions"; + +import "./styles.scss"; + +export interface FeesPaneProps { + fee: string; + simulationState: State; + isSoroban?: boolean; + onClose: () => void; +} + +export const FeesPane = ({ + fee, + simulationState, + isSoroban = false, + onClose, +}: FeesPaneProps) => { + const { t } = useTranslation(); + + const isLoading = + simulationState.state === RequestState.IDLE || + simulationState.state === RequestState.LOADING; + + return ( +
+
+
+ +
+ +
+
+ {t("Fees")} +
+
+ {!isLoading && simulationState.data?.inclusionFee && ( +
+ + {t("Inclusion Fee")} + + + {simulationState.data.inclusionFee} XLM + +
+ )} + {!isLoading && simulationState.data?.resourceFee && ( +
+ + {t("Resource Fee")} + + + {simulationState.data.resourceFee} XLM + +
+ )} +
+ + {t("Total Fee")} + + + {isLoading ? t("Calculating...") : `${fee} XLM`} + +
+
+
+ {isSoroban + ? t("Fees description soroban") + : t("Fees description classic")} +
+
+ ); +}; diff --git a/extension/src/popup/components/InternalTransaction/FeesPane/styles.scss b/extension/src/popup/components/InternalTransaction/FeesPane/styles.scss new file mode 100644 index 0000000000..9114009b33 --- /dev/null +++ b/extension/src/popup/components/InternalTransaction/FeesPane/styles.scss @@ -0,0 +1,101 @@ +@use "../../../styles/utils.scss" as *; + +.FeesPane { + display: flex; + flex-direction: column; + width: 100%; + padding: 0 0; + gap: pxToRem(16px); + + .multi-pane-slider & { + padding: 0; + } + + &__Header { + display: flex; + justify-content: space-between; + align-items: center; + + &__Icon { + display: flex; + width: pxToRem(32px); + height: pxToRem(32px); + justify-content: center; + align-items: center; + border-radius: pxToRem(8px); + background: var(--sds-clr-lilac-03); + border: 1px solid var(--sds-clr-lilac-06); + + svg { + width: pxToRem(20px); + height: pxToRem(20px); + color: var(--sds-clr-lilac-09); + } + } + + &__Close { + display: flex; + width: pxToRem(32px); + height: pxToRem(32px); + justify-content: center; + align-items: center; + border-radius: 1000px; + background: var(--sds-clr-gray-03); + color: var(--sds-clr-gray-09); + cursor: pointer; + } + } + + &__Title { + display: flex; + align-items: center; + width: 100%; + font-size: pxToRem(18px); + font-weight: var(--font-weight-medium); + } + + &__Card { + display: flex; + flex-direction: column; + border-radius: pxToRem(16px); + background-color: var(--sds-clr-gray-03); + padding: pxToRem(4px) pxToRem(16px); + + &__Row { + display: flex; + justify-content: space-between; + align-items: center; + padding: pxToRem(12px) 0; + + &:not(:last-child) { + border-bottom: 1px solid var(--sds-clr-gray-06); + } + + &__Label { + font-size: pxToRem(14px); + font-weight: var(--font-weight-medium); + color: var(--sds-clr-gray-11); + + &--total { + color: var(--sds-clr-lilac-11); + } + } + + &__Value { + font-size: pxToRem(14px); + font-weight: var(--font-weight-medium); + color: var(--sds-clr-gray-12); + + &--total { + color: var(--sds-clr-lilac-11); + } + } + } + } + + &__Description { + color: var(--sds-clr-gray-11); + font-size: pxToRem(14px); + line-height: pxToRem(20px); + } +} diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx b/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx index 18713a7711..2c98e1500e 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/index.tsx @@ -21,7 +21,7 @@ import { checkIsMuxedSupported, getMemoDisabledState, } from "helpers/muxedAddress"; -import { SimulateTxData } from "popup/components/send/SendAmount/hooks/useSimulateTxData"; +import { SimulateTxData } from "types/transactions"; import { View } from "popup/basics/layout/View"; import { HardwareSign } from "popup/components/hardwareConnect/HardwareSign"; import { hardwareWalletTypeSelector } from "popup/ducks/accountServices"; @@ -38,6 +38,7 @@ import { MemoRequiredLabel, } from "popup/components/WarningMessages"; import { CopyValue } from "popup/components/CopyValue"; +import { FeesPane } from "popup/components/InternalTransaction/FeesPane"; import { ActionButtons } from "./components/ActionButtons"; import { SendAsset, SendDestination } from "./components"; @@ -180,8 +181,8 @@ export const ReviewTx = ({ /** * Pane state machine: - * - With Blockaid warning: [Review, Memo, Blockaid] - Blockaid accessible via banner click - * - No warning: [Review, Memo] + * - No warning: [Review (0), Memo (1), Fees (2)] + * - With Blockaid warning: [Review (0), Memo (1), Blockaid (2), Fees (3)] - Blockaid accessible via banner click */ const paneConfig = React.useMemo( () => @@ -190,11 +191,13 @@ export const ReviewTx = ({ blockaidIndex: null, reviewIndex: 0, memoIndex: 1, + feesIndex: 2, } : { blockaidIndex: 2, reviewIndex: 0, memoIndex: 1, + feesIndex: 3, }, [shouldShowTxWarning], ); @@ -203,6 +206,8 @@ export const ReviewTx = ({ paneConfig.blockaidIndex !== null && activePaneIndex === paneConfig.blockaidIndex; + const isOnFeesPane = activePaneIndex === paneConfig.feesIndex; + // Extract contract ID for custom tokens or collectibles const contractId = React.useMemo( () => @@ -369,6 +374,14 @@ export const ReviewTx = ({ className="ReviewTx__Details__Row__Value" data-testid="review-tx-fee" > + {fee} XLM
@@ -428,12 +441,21 @@ export const ReviewTx = ({ ); + const feesPane = ( + setActivePaneIndex(paneConfig.reviewIndex)} + /> + ); + // Build panes in order (no hooks on JSX) const panes: React.ReactNode[] = []; if (shouldShowTxWarning) { - panes.push(reviewPane, memoPane, blockaidPane); + panes.push(reviewPane, memoPane, blockaidPane, feesPane); } else { - panes.push(reviewPane, memoPane); + panes.push(reviewPane, memoPane, feesPane); } return ( @@ -447,25 +469,27 @@ export const ReviewTx = ({ ) : (
-
- -
+ {!isOnFeesPane && ( +
+ +
+ )}
)} diff --git a/extension/src/popup/components/InternalTransaction/ReviewTransaction/styles.scss b/extension/src/popup/components/InternalTransaction/ReviewTransaction/styles.scss index 5b81a19162..4c3ab2b6df 100644 --- a/extension/src/popup/components/InternalTransaction/ReviewTransaction/styles.scss +++ b/extension/src/popup/components/InternalTransaction/ReviewTransaction/styles.scss @@ -58,6 +58,7 @@ flex: 2; display: flex; color: var(--sds-clr-gray-09); + align-items: center; svg { margin-right: pxToRem(6px); @@ -67,8 +68,8 @@ &__Value { flex: 2; display: flex; - flex-direction: column; - align-items: end; + flex-direction: row; + justify-content: flex-end; .CopyValue { max-width: pxToRem(120px); @@ -255,4 +256,25 @@ line-height: 1.5rem; } } + + &__Details__Row__FeesInfoBtn { + background: none; + border: none; + cursor: pointer; + padding: 0; + margin-right: pxToRem(6px); + display: inline-flex; + align-items: center; + color: var(--sds-clr-gray-09); + margin-bottom: pxToRem(2px); + + svg { + width: pxToRem(14px); + height: pxToRem(14px); + } + + &:hover { + color: var(--sds-clr-gray-12); + } + } } diff --git a/extension/src/popup/components/send/SendAmount/hooks/useSimulateTxData.tsx b/extension/src/popup/components/send/SendAmount/hooks/useSimulateTxData.tsx index 52482bc9d0..4a25f8137e 100644 --- a/extension/src/popup/components/send/SendAmount/hooks/useSimulateTxData.tsx +++ b/extension/src/popup/components/send/SendAmount/hooks/useSimulateTxData.tsx @@ -48,6 +48,9 @@ import { checkIsMuxedSupported, determineMuxedDestination, } from "helpers/muxedAddress"; +import { SimulateTxData } from "types/transactions"; + +export type { SimulateTxData }; interface SimClassic { type: "classic"; @@ -69,11 +72,6 @@ interface SimSoroban { xdr: string; } -export interface SimulateTxData { - transactionXdr: string; - scanResult?: BlockAidScanTxResult | null; -} - const CREATE_ACCOUNT_MIN_XLM = new BigNumber(1); /** @@ -341,6 +339,8 @@ const simulateTx = async ({ return { payload: response, recommendedFee: baseFee.plus(new BigNumber(minResourceFee)).toString(), + inclusionFee: baseFee.toString(), + resourceFee: minResourceFee, }; } @@ -545,6 +545,13 @@ function useSimulateTxData({ }), ); + if (simResponse.inclusionFee !== undefined) { + payload.inclusionFee = simResponse.inclusionFee; + } + if (simResponse.resourceFee !== undefined) { + payload.resourceFee = simResponse.resourceFee; + } + const scanUrlstub = "internal"; if (simParams.type === "classic") { const { diff --git a/extension/src/popup/components/send/SendAmount/index.tsx b/extension/src/popup/components/send/SendAmount/index.tsx index 60d81a1253..2288b21d78 100644 --- a/extension/src/popup/components/send/SendAmount/index.tsx +++ b/extension/src/popup/components/send/SendAmount/index.tsx @@ -37,6 +37,7 @@ import { saveIsToken, saveMemo, saveTransactionFee, + saveManualTransactionFee, saveTransactionTimeout, saveAmountUsd, } from "popup/ducks/transactionSubmission"; @@ -51,6 +52,7 @@ import { AMOUNT_ERROR, InputType } from "helpers/transaction"; import { reRouteOnboarding } from "popup/helpers/route"; import { AssetIcon } from "popup/components/account/AccountAssets"; import { EditSettings } from "popup/components/InternalTransaction/EditSettings"; +import { FeesPane } from "popup/components/InternalTransaction/FeesPane"; import { EditMemo } from "popup/components/InternalTransaction/EditMemo"; import { ReviewTx } from "popup/components/InternalTransaction/ReviewTransaction"; import { AddressTile } from "popup/components/send/AddressTile"; @@ -73,6 +75,19 @@ import "../styles.scss"; const DEFAULT_INPUT_WIDTH = 25; +// Returns the value to show in FeesPane's total row given the user's current +// draft inclusion fee and the simulated resource fee. For classic (no +// resource fee) the inclusion fee IS the total. +function buildFeesPaneTotal( + inclusionFee: string, + resourceFee: string | undefined, +): string { + if (!resourceFee) { + return inclusionFee; + } + return new BigNumber(inclusionFee).plus(resourceFee).toFixed(); +} + export const SendAmount = ({ goBack, goToNext, @@ -109,9 +124,44 @@ export const SendAmount = ({ transactionFee, isCollectible, collectibleData, + manualTransactionFee, } = transactionData; const fee = transactionFee || recommendedFee; + // Persist the last-known inclusion fee across re-simulations so the + // EditSettings input never jumps back to the total fee while LOADING + // (the request reducer sets data: null on FETCH_DATA_START). + const lastInclusionFeeRef = useRef(null); + if ( + simulationState.state === RequestState.SUCCESS && + simulationState.data?.inclusionFee + ) { + lastInclusionFeeRef.current = simulationState.data.inclusionFee; + } + + // Tracks the fee the user explicitly saved via EditSettings this session. + // Once set, re-simulations no longer overwrite the displayed inclusion fee, + // mirroring the mobile hasManuallyChanged pattern. + // Initialized from Redux so the value survives SendAmount unmount/remount + // (e.g. when the user navigates to pick a recipient address and returns). + const hasManuallySetFeeRef = useRef(manualTransactionFee); + + // For Soroban: prefer the user's manually-saved fee, then the last simulated + // inclusion fee (base fee only). For classic: use the current total fee. + const editSettingsFee = + isToken || isCollectible + ? (hasManuallySetFeeRef.current ?? + lastInclusionFeeRef.current ?? + recommendedFee) + : fee; + + // Holds the fee the user has typed but not yet saved. Survives the + // EditSettings unmount that occurs when the fees pane opens so that the + // input re-initialises to the draft value on return. + const [draftFeeForDisplay, setDraftFeeForDisplay] = React.useState< + string | null + >(null); + const { state: sendAmountData, fetchData } = useGetSendAmountData( { showHidden: false, @@ -131,10 +181,17 @@ export const SendAmount = ({ const cryptoInputRef = useRef(null); const usdInputRef = useRef(null); + // Tracks the dest+asset pair that simulation was last triggered for, so we + // can detect changes and re-simulate without watching simulationState.data. + const simulationDataRef = useRef({ destination: "", asset: "" }); const [inputType, setInputType] = useState("crypto"); const [isEditingMemo, setIsEditingMemo] = React.useState(false); const [isEditingSettings, setIsEditingSettings] = React.useState(false); + const [isShowingFeesPane, setIsShowingFeesPane] = React.useState(false); + // Holds the fee value shown in FeesPane's total row. Updated to reflect the + // user's current draft inclusion fee before the pane opens. + const [feesPaneTotal, setFeesPaneTotal] = React.useState(fee); const [isReviewingTx, setIsReviewingTx] = React.useState(false); const [contractSupportsMuxed, setContractSupportsMuxed] = React.useState< boolean | null @@ -235,7 +292,20 @@ export const SendAmount = ({ }; const handleContinue = async () => { - if (!transactionFee) { + if (isToken || isCollectible) { + // Reset to the inclusion fee before re-simulating. After a prior + // simulation, saveTransactionFee stored the TOTAL (inclusion + resource). + // Without this reset that total would be used as baseFee on the next run, + // inflating both inclusionFee and recommendedFee. Prefer any fee the + // user explicitly saved via EditSettings over the simulated base fee. + dispatch( + saveTransactionFee( + hasManuallySetFeeRef.current ?? + lastInclusionFeeRef.current ?? + recommendedFee, + ), + ); + } else if (!transactionFee) { dispatch(saveTransactionFee(fee)); } await fetchSimulationData(); @@ -300,6 +370,39 @@ export const SendAmount = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Soroban: re-simulate whenever destination or asset changes (and on first + // mount if both are ready). simulationDataRef tracks what was last simulated so + // we detect genuine changes without watching simulationState.data. + // simulationState.state is included so that if a change arrives while a + // simulation is already in-flight, we retry once it finishes. + useEffect(() => { + if (!(isToken || isCollectible)) return; + if (!destination) return; + // Don't stack concurrent simulations. + if (simulationState.state === RequestState.LOADING) return; + + const destChanged = simulationDataRef.current.destination !== destination; + const assetChanged = simulationDataRef.current.asset !== asset; + + if (destChanged || assetChanged) { + // Reset to inclusion fee before re-simulating so total fee from a prior + // simulation isn't used as baseFee (which would inflate the result). + // Prefer the user's manually-saved fee if present. + if (isToken || isCollectible) { + dispatch( + saveTransactionFee( + hasManuallySetFeeRef.current ?? + lastInclusionFeeRef.current ?? + recommendedFee, + ), + ); + } + simulationDataRef.current = { destination, asset }; + fetchSimulationData(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [destination, asset, isToken, isCollectible, simulationState.state]); + const getAmountFontSize = () => { const length = formik.values.amount.length; if (length <= 9) { @@ -393,12 +496,22 @@ export const SendAmount = ({ dispatch(saveIsToken(false)); dispatch(saveAmount("0")); dispatch(saveAmountUsd("0.00")); + // Clear any manually-saved fee so the next send session always starts from + // the simulated base fee rather than a stale override. + dispatch(saveTransactionFee("")); + dispatch(saveManualTransactionFee(null)); goBack(); if (isCollectible) { goToChooseAssetAction(); } }; const goToChooseAssetAction = () => { + // Changing the asset may switch between Soroban and classic (or a different + // token), so any manually-saved fee from the prior asset should not carry + // over. Clear it here before navigating so post-remount the fee is derived + // freshly from the new asset's simulation. + dispatch(saveManualTransactionFee(null)); + hasManuallySetFeeRef.current = null; goToChooseAsset(); }; @@ -429,9 +542,12 @@ export const SendAmount = ({ {t("Fee")}: - {inputType === "crypto" - ? `${fee} ${t("XLM")}` - : recommendedFeeUsd} + {(isToken || isCollectible) && + simulationState.state === RequestState.LOADING + ? t("Calculating...") + : inputType === "crypto" + ? `${fee} ${t("XLM")}` + : recommendedFeeUsd}
@@ -454,7 +570,18 @@ export const SendAmount = ({ size="md" isRounded variant="tertiary" - onClick={() => setIsEditingSettings(true)} + onClick={() => { + setIsEditingSettings(true); + // For Soroban tokens, trigger simulation immediately so fee + // input and FeesPane both reflect the correct simulated amounts + if ( + (isToken || isCollectible) && + simulationState.state !== RequestState.SUCCESS && + simulationState.state !== RequestState.LOADING + ) { + fetchSimulationData(); + } + }} > @@ -464,7 +591,7 @@ export const SendAmount = ({