diff --git a/extension/e2e-tests/blockaidScan.errors.test.ts b/extension/e2e-tests/blockaidScan.errors.test.ts index a9e140d37d..df509a2af3 100644 --- a/extension/e2e-tests/blockaidScan.errors.test.ts +++ b/extension/e2e-tests/blockaidScan.errors.test.ts @@ -83,9 +83,8 @@ test.describe("BlockAid Scan - Edge Cases", () => { }); await page.getByTestId("nav-link-send").click(); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); - - await page.getByTestId("address-tile").click(); + await expect(page.getByTestId("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page .getByTestId("send-to-input") .fill("GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"); @@ -123,9 +122,8 @@ test.describe("BlockAid Scan - Edge Cases", () => { }); await page.getByTestId("nav-link-send").click(); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); - - await page.getByTestId("address-tile").click(); + await expect(page.getByTestId("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page .getByTestId("send-to-input") .fill("GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"); @@ -239,9 +237,8 @@ test.describe("BlockAid Scan - Edge Cases", () => { }); await page.getByTestId("nav-link-send").click(); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); - - await page.getByTestId("address-tile").click(); + await expect(page.getByTestId("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page .getByTestId("send-to-input") .fill("GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"); diff --git a/extension/e2e-tests/blockaidScan.malicious.test.ts b/extension/e2e-tests/blockaidScan.malicious.test.ts index 3d1f8f6e37..f9bed8ddda 100644 --- a/extension/e2e-tests/blockaidScan.malicious.test.ts +++ b/extension/e2e-tests/blockaidScan.malicious.test.ts @@ -121,9 +121,8 @@ test.describe("BlockAid Scan - Malicious States", () => { }); await page.getByTestId("nav-link-send").click(); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); - - await page.getByTestId("address-tile").click(); + await expect(page.getByTestId("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page .getByTestId("send-to-input") .fill("GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"); @@ -344,9 +343,8 @@ test.describe("BlockAid Scan - Malicious States", () => { // Go to send payment to an M-address (requires memo) await page.getByTestId("nav-link-send").click(); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); - - await page.getByTestId("address-tile").click(); + await expect(page.getByTestId("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page .getByTestId("send-to-input") .fill("GA6SXIZIKLJHCZI2KEOBEUUOFMM4JUPPM2UTWX6STAWT25JWIEUFIMFF"); diff --git a/extension/e2e-tests/blockaidScan.safe.test.ts b/extension/e2e-tests/blockaidScan.safe.test.ts index 6af71fd218..145a99cb2e 100644 --- a/extension/e2e-tests/blockaidScan.safe.test.ts +++ b/extension/e2e-tests/blockaidScan.safe.test.ts @@ -126,9 +126,8 @@ test.describe("BlockAid Scan - Safe States (No Override)", () => { }); await page.getByTestId("nav-link-send").click(); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); - - await page.getByTestId("address-tile").click(); + await expect(page.getByTestId("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page .getByTestId("send-to-input") .fill("GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"); diff --git a/extension/e2e-tests/blockaidScan.suspicious.test.ts b/extension/e2e-tests/blockaidScan.suspicious.test.ts index 8dc54b443a..2663f090f1 100644 --- a/extension/e2e-tests/blockaidScan.suspicious.test.ts +++ b/extension/e2e-tests/blockaidScan.suspicious.test.ts @@ -109,9 +109,8 @@ test.describe("BlockAid Scan - Suspicious States", () => { }); await page.getByTestId("nav-link-send").click(); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); - - await page.getByTestId("address-tile").click(); + await expect(page.getByTestId("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page .getByTestId("send-to-input") .fill("GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"); @@ -323,9 +322,8 @@ test.describe("BlockAid Scan - Suspicious States", () => { // Go to send payment to an M-address (requires memo) await page.getByTestId("nav-link-send").click(); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); - - await page.getByTestId("address-tile").click(); + await expect(page.getByTestId("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page .getByTestId("send-to-input") .fill("GA6SXIZIKLJHCZI2KEOBEUUOFMM4JUPPM2UTWX6STAWT25JWIEUFIMFF"); diff --git a/extension/e2e-tests/blockaidScan.unable.test.ts b/extension/e2e-tests/blockaidScan.unable.test.ts index b7432651f2..92d0c1c55e 100644 --- a/extension/e2e-tests/blockaidScan.unable.test.ts +++ b/extension/e2e-tests/blockaidScan.unable.test.ts @@ -129,9 +129,8 @@ test.describe("BlockAid Scan - Unable to Scan States", () => { // Navigate to send payment await page.getByTestId("nav-link-send").click(); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); - - await page.getByTestId("address-tile").click(); + await expect(page.getByTestId("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); // Use a valid test address await page .getByTestId("send-to-input") @@ -368,9 +367,8 @@ test.describe("BlockAid Scan - Unable to Scan States", () => { // Go to send payment to an M-address (requires memo) await page.getByTestId("nav-link-send").click(); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); - - await page.getByTestId("address-tile").click(); + await expect(page.getByTestId("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page .getByTestId("send-to-input") .fill("GA6SXIZIKLJHCZI2KEOBEUUOFMM4JUPPM2UTWX6STAWT25JWIEUFIMFF"); diff --git a/extension/e2e-tests/integration-tests/sendIntegration.test.ts b/extension/e2e-tests/integration-tests/sendIntegration.test.ts index 66d1e3cdd5..7508ad38cc 100644 --- a/extension/e2e-tests/integration-tests/sendIntegration.test.ts +++ b/extension/e2e-tests/integration-tests/sendIntegration.test.ts @@ -46,9 +46,8 @@ test("Send persists inputs and submits to network", async ({ }); 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("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page .getByTestId("send-to-input") @@ -137,12 +136,11 @@ test("Send XLM payments to recent federated addresses", async ({ await loginToTestAccount({ page, extensionId, context, isIntegrationMode }); 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("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page.getByTestId("send-to-input").fill("freighter.pb*lobstr.co"); - await expect(page.getByTestId("send-to-identicon")).toBeVisible(); + await expect(page.getByTestId("send-to-suggestion-button")).toBeVisible(); await page.getByText("Continue").click({ force: true }); await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); @@ -168,13 +166,23 @@ test("Send XLM payments to recent federated addresses", async ({ await page.getByText("Done").click(); await page.getByTestId("nav-link-send").click(); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); - await expect(page.getByText("Send to")).toBeVisible(); + await expect(page.getByTestId("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); - await page.getByTestId("address-tile").click(); + // Recents should be visible on the send-to screen before typing anything. + await expect(page.getByText("Recents")).toBeVisible(); + // Recents should remain visible while the user is typing. + await page.getByTestId("send-to-input").fill("freighter.pb*lobstr.co"); + await expect(page.getByText("Recents")).toBeVisible(); + + // Recents should remain visible once a suggestion resolves. + await expect(page.getByTestId("send-to-suggestion-button")).toBeVisible(); await expect(page.getByText("Recents")).toBeVisible(); + // Clear the input and use the recent address directly. + await page.getByTestId("send-to-input").fill(""); + await page.getByTestId("recent-address-button").click(); await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); @@ -215,9 +223,8 @@ test("Send XLM payment to C address", async ({ 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("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page.getByTestId("send-to-input").fill(TEST_TOKEN_ADDRESS); await page.getByText("Continue").click({ force: true }); @@ -259,9 +266,8 @@ test("Send XLM payment to M address", async ({ 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("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page.getByTestId("send-to-input").fill(TEST_M_ADDRESS); await page.getByText("Continue").click(); @@ -403,22 +409,29 @@ test("Send token payment to C address", async ({ } 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("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page.getByTestId("send-to-input").fill(TEST_TOKEN_ADDRESS); await page.getByText("Continue").click({ force: true }); await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); - await page.locator(".SendAmount__EditDestAsset").click(); + await page.getByTestId("send-amount-edit-dest-asset").click(); - await page.getByText("E2E").click(); + await page + .locator(".Send__step:not(.Send__step--hidden)") + .getByText("E2E") + .click(); await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); await page.getByTestId("send-amount-amount-input").fill(".001"); - await page.getByText("Review Send").click({ force: true }); + + // Wait for auto-simulation to complete before clicking Review Send. + // handleContinue returns early if simulationState.state === LOADING. + const reviewSendButton = page.getByTestId("send-amount-btn-continue"); + await expect(reviewSendButton).toBeEnabled({ timeout: 30000 }); + await reviewSendButton.click(); await expect(page.getByText("You are sending")).toBeVisible({ timeout: 60000, diff --git a/extension/e2e-tests/loadAccount.test.ts b/extension/e2e-tests/loadAccount.test.ts index 995d3173b1..01b89fc309 100644 --- a/extension/e2e-tests/loadAccount.test.ts +++ b/extension/e2e-tests/loadAccount.test.ts @@ -642,6 +642,7 @@ test("Loads collectibles data with successful metadata", async ({ extensionId, context, }) => { + test.slow(); const stubOverrides = async () => { await stubCollectibles(page, true); }; @@ -760,6 +761,12 @@ test("Loads collectibles data with successful metadata", async ({ // test that the send button navigates to the send payment page await page.getByTestId("CollectibleDetail__footer__buttons__send").click(); + // Send flow starts at the destination step when launched from a collectible. + await expect(page.getByTestId("send-to-input")).toBeVisible(); + await page + .getByTestId("send-to-input") + .fill("GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"); + await page.getByText("Continue").click(); await expect(page.getByTestId("SelectedCollectible")).toBeVisible(); await expect( page.getByTestId("SelectedCollectible__base-info__row__name__value"), diff --git a/extension/e2e-tests/memo.test.ts b/extension/e2e-tests/memo.test.ts index 1a3643826b..acef64b123 100644 --- a/extension/e2e-tests/memo.test.ts +++ b/extension/e2e-tests/memo.test.ts @@ -31,9 +31,8 @@ test("Send payment shows memo required warning when destination requires memo", 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("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page.getByTestId("send-to-input").fill(MEMO_REQUIRED_ADDRESS); await page.getByText("Continue").click(); @@ -69,9 +68,8 @@ test("Send payment allows submission after adding memo to memo-required address" 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("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page.getByTestId("send-to-input").fill(MEMO_REQUIRED_ADDRESS); await page.getByText("Continue").click(); @@ -135,9 +133,8 @@ test("Send payment returns to review modal after adding memo from review flow", 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("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page.getByTestId("send-to-input").fill(MEMO_REQUIRED_ADDRESS); await page.getByText("Continue").click(); @@ -190,9 +187,8 @@ test("Send payment returns to review modal after cancelling memo editor from rev 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("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page.getByTestId("send-to-input").fill(MEMO_REQUIRED_ADDRESS); await page.getByText("Continue").click(); @@ -243,9 +239,8 @@ test("Send payment shows memo value directly when memo is added before review", 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("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page.getByTestId("send-to-input").fill(MEMO_REQUIRED_ADDRESS); await page.getByText("Continue").click(); @@ -321,10 +316,10 @@ test("Send payment shows Add Memo when switching from non-memo-required to memo- 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("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); // First, set a non-memo-required address - await page.getByTestId("address-tile").click(); await page.getByTestId("send-to-input").fill(NON_MEMO_REQUIRED_ADDRESS); await page.getByText("Continue").click(); @@ -356,7 +351,9 @@ test("Send payment shows Add Memo when switching from non-memo-required to memo- await page.getByTestId("address-tile").click(); await page.getByTestId("send-to-input").clear(); await page.getByTestId("send-to-input").fill(MEMO_REQUIRED_ADDRESS); - await page.getByText("Continue").click(); + // Click the suggestion button rather than Continue — it only appears after the + // debounce settles and the new fetch resolves, confirming the address is saved. + await page.getByTestId("send-to-suggestion-button").click(); await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); // Make sure amount is still 1 XLM after switching addresses @@ -388,9 +385,8 @@ test("Send payment shows Add Memo after cancelling review and returning to memo- 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("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page.getByTestId("send-to-input").fill(MEMO_REQUIRED_ADDRESS); await page.getByText("Continue").click(); @@ -463,11 +459,11 @@ test("Send classic token to G address allows memo", async ({ 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("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); const G_ADDRESS = "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"; - await page.getByTestId("address-tile").click(); await page.getByTestId("send-to-input").fill(G_ADDRESS); await page.getByText("Continue").click(); @@ -504,9 +500,9 @@ test("Send classic token to M address doesn't allow memo", async ({ 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("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); - await page.getByTestId("address-tile").click(); await page.getByTestId("send-to-input").fill(TEST_M_ADDRESS); await page.getByText("Continue").click(); @@ -536,13 +532,11 @@ test("Send custom token without Soroban mux support to G address disables memo", await loginToTestAccount({ page, extensionId, context, stubOverrides }); await page.getByTestId("nav-link-send").click({ force: true }); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible({ - timeout: 30000, - }); + await expect(page.getByTestId("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); const G_ADDRESS = "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"; - await page.getByTestId("address-tile").click(); await page.getByTestId("send-to-input").fill(G_ADDRESS); await page.getByText("Continue").click(); @@ -551,8 +545,9 @@ test("Send custom token without Soroban mux support to G address disables memo", }); // Select custom token - await page.locator(".SendAmount__EditDestAsset").click(); + await page.getByTestId("send-amount-edit-dest-asset").click(); await page + .locator(".Send__step:not(.Send__step--hidden)") .getByTestId(`SendRow-E2E:${TEST_TOKEN_ADDRESS}`) .click({ force: true }); @@ -626,17 +621,18 @@ test("Send custom token without Soroban mux support to M address is disabled", a await loginToTestAccount({ page, extensionId, context, stubOverrides }); await page.getByTestId("nav-link-send").click({ force: true }); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await expect(page.getByTestId("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); - await page.getByTestId("address-tile").click(); await page.getByTestId("send-to-input").fill(TEST_M_ADDRESS); await page.getByText("Continue").click(); await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); // Select custom token - await page.locator(".SendAmount__EditDestAsset").click(); + await page.getByTestId("send-amount-edit-dest-asset").click(); await page + .locator(".Send__step:not(.Send__step--hidden)") .getByTestId(`SendRow-E2E:${TEST_TOKEN_ADDRESS}`) .click({ force: true }); @@ -674,19 +670,20 @@ test("Send custom token with Soroban mux support to G address allows memo", asyn await loginToTestAccount({ page, extensionId, context, stubOverrides }); await page.getByTestId("nav-link-send").click({ force: true }); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await expect(page.getByTestId("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); const G_ADDRESS = "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"; - await page.getByTestId("address-tile").click(); await page.getByTestId("send-to-input").fill(G_ADDRESS); await page.getByText("Continue").click(); await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); // Select custom token - await page.locator(".SendAmount__EditDestAsset").click(); + await page.getByTestId("send-amount-edit-dest-asset").click(); await page + .locator(".Send__step:not(.Send__step--hidden)") .getByTestId(`SendRow-E2E:${TEST_TOKEN_ADDRESS}`) .click({ force: true }); @@ -740,17 +737,18 @@ test("Send custom token with Soroban mux support to M address disables memo", as await loginToTestAccount({ page, extensionId, context, stubOverrides }); await page.getByTestId("nav-link-send").click({ force: true }); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await expect(page.getByTestId("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); - await page.getByTestId("address-tile").click(); await page.getByTestId("send-to-input").fill(TEST_M_ADDRESS); await page.getByText("Continue").click(); await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); // Select custom token - await page.locator(".SendAmount__EditDestAsset").click(); + await page.getByTestId("send-amount-edit-dest-asset").click(); await page + .locator(".Send__step:not(.Send__step--hidden)") .getByTestId(`SendRow-E2E:${TEST_TOKEN_ADDRESS}`) .click({ force: true }); diff --git a/extension/e2e-tests/reviewTxFees.test.ts b/extension/e2e-tests/reviewTxFees.test.ts index af288b8e79..d8669dff39 100644 --- a/extension/e2e-tests/reviewTxFees.test.ts +++ b/extension/e2e-tests/reviewTxFees.test.ts @@ -33,13 +33,11 @@ test("Fee breakdown pane shows Soroban fees for token send", async ({ // 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 expect(page.getByTestId("send-to-input")).toBeVisible(); await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); await page.getByText("Continue").click(); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await page.getByTestId("send-amount-amount-input").fill("0.1"); // Wait for simulation to finish, then click Review Send const reviewSendButton = page.getByTestId("send-amount-btn-continue"); @@ -89,10 +87,8 @@ test("Fee breakdown pane shows only total fee for classic XLM send", async ({ 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("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").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 }); @@ -169,25 +165,16 @@ test("Collectible simulation falls back to BASE_FEE on first mount when Redux tr await loginToTestAccount({ page, extensionId, context, stubOverrides }); await page.getByTestId("nav-link-send").click({ force: true }); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); - - // Confirm Redux transactionFee is still the empty-string default - await expect(page.getByTestId("send-amount-fee-display")).toHaveText( - "0.00001 XLM", - ); + await expect(page.getByTestId("token-list")).toBeVisible(); - // Select a collectible — Redux transactionFee remains "" - 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 — triggers first-mount simulation with transactionFee="" - await page.getByTestId("address-tile").click(); await page .getByTestId("send-to-input") .fill("GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"); await page.getByText("Continue").click({ force: true }); + await expect(page.getByTestId("SelectedCollectible")).toBeVisible(); // Simulation must complete: Continue button becomes enabled and fee shows // baseFee(0.00001) + resourceFee(0.00001) = 0.00002 XLM @@ -222,18 +209,14 @@ test("Fee breakdown pane shows Soroban fees for collectible send", async ({ await loginToTestAccount({ page, extensionId, context, stubOverrides }); await page.getByTestId("nav-link-send").click({ force: true }); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await expect(page.getByTestId("token-list")).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 }); + await expect(page.getByTestId("SelectedCollectible")).toBeVisible(); // Wait for simulation to finish, then click Review Send const reviewSendButton = page.getByTestId("send-collectible-btn-continue"); @@ -290,35 +273,41 @@ test("Custom token without destination — full fee lifecycle in EditSettings an await loginToTestAccount({ page, extensionId, context, stubOverrides }); - // Navigate to token send (no destination set yet) + // Navigate to token send and set destination. + // Auto-simulation fires as soon as destination is set (isToken=true), so the + // fee display will update to the simulated total without needing an amount. await page.getByText("E2E").click(); await page.getByTestId("asset-detail-send-button").click(); + await expect(page.getByTestId("send-to-input")).toBeVisible(); + await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); + await page.getByText("Continue").click(); await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); - // Without a destination, no auto-simulation fires — fee display stays at base fee + // Simulation fires on destination set — wait for the simulated total await expect(page.getByTestId("send-amount-fee-display")).toHaveText( - "0.00001 XLM", + "0.0093338 XLM", + { timeout: 10000 }, ); // ── 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 + // EditSettings always shows the inclusion (base) fee, not the total await expect(page.getByTestId("edit-tx-settings-fee-input")).toHaveValue( "0.00001", ); - // ── Open FeesPane — no destination so simulation does NOT fire ─────────── - // FeesPane shows the base fee as inclusion/total and "None" for resource, - // matching mobile behaviour (no error, no simulated amounts). + // ── Open FeesPane — simulation has completed ───────────────────────────── 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("None"); + await expect(page.getByTestId("review-tx-resource-fee")).toHaveText( + "0.0093238 XLM", + ); await expect(page.getByTestId("review-tx-total-fee")).toHaveText( - "0.00001 XLM", + "0.0093338 XLM", ); await expect(page.getByTestId("review-tx-fees-description")).toContainText( "Soroban", @@ -335,14 +324,16 @@ test("Custom token without destination — full fee lifecycle in EditSettings an "0.00005", ); - // ── Open FeesPane again — draft fee reflected, resource still "None" ────── + // ── Open FeesPane again — draft not saved yet, total reflects saved simulation ── await page.getByTestId("edit-settings-fees-info-btn").click(); await expect(page.getByTestId("review-tx-fees-pane")).toBeVisible(); - // No simulation data: total = draft inclusion fee only + // Draft 0.00005 is unsaved; FeesPane shows the last simulated total await expect(page.getByTestId("review-tx-total-fee")).toHaveText( - "0.00005 XLM", + "0.0093338 XLM", + ); + await expect(page.getByTestId("review-tx-resource-fee")).toHaveText( + "0.0093238 XLM", ); - await expect(page.getByTestId("review-tx-resource-fee")).toHaveText("None"); // ── Close FeesPane → draft still in the input ───────────────────────────── await page.getByTestId("review-tx-fees-close-btn").click(); @@ -354,9 +345,10 @@ test("Custom token without destination — full fee lifecycle in EditSettings an // ── Save the custom fee ─────────────────────────────────────────────────── await page.getByRole("button", { name: "Save" }).click(); await expect(page.getByText("Inclusion Fee")).not.toBeVisible(); - // No destination → no simulation → fee display shows the saved inclusion fee + // Re-simulation runs with baseFee=0.00005 → total = 0.00005 + 0.0093238 = 0.0093738 await expect(page.getByTestId("send-amount-fee-display")).toHaveText( - "0.00005 XLM", + "0.0093738 XLM", + { timeout: 10000 }, ); // ── Reopen Edit Settings — must show saved fee, not the base default ─────── @@ -366,16 +358,17 @@ test("Custom token without destination — full fee lifecycle in EditSettings an "0.00005", ); - // ── Open FeesPane from re-opened settings — saved fee reflected ──────────── - // Still no destination, so resource stays "None" and total = inclusion fee only. + // ── Open FeesPane from re-opened settings — saved fee and re-simulation reflected ── 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("None"); + await expect(page.getByTestId("review-tx-resource-fee")).toHaveText( + "0.0093238 XLM", + ); await expect(page.getByTestId("review-tx-total-fee")).toHaveText( - "0.00005 XLM", + "0.0093738 XLM", ); await expect(page.getByTestId("review-tx-fees-description")).toContainText( "Soroban", @@ -403,13 +396,13 @@ test("Custom token with recipient — full fee lifecycle in EditSettings and Fee // 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 expect(page.getByTestId("send-to-input")).toBeVisible(); await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); await page.getByText("Continue").click(); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await page.getByTestId("send-amount-amount-input").fill("0.1"); + // Wait for auto-simulation: total = baseFee(0.00001) + resource(0.0093238) await expect(page.getByTestId("send-amount-fee-display")).toHaveText( "0.0093338 XLM", @@ -523,13 +516,13 @@ test("Custom fee resets to default when re-entering send flow from home screen", // ── 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 expect(page.getByTestId("send-to-input")).toBeVisible(); await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); await page.getByText("Continue").click(); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await page.getByTestId("send-amount-amount-input").fill("0.1"); + // Wait for simulation await expect(page.getByTestId("send-amount-fee-display")).toHaveText( "0.0093338 XLM", @@ -550,7 +543,12 @@ test("Custom fee resets to default when re-entering send flow from home screen", ); // ── Navigate back to home screen ────────────────────────────────────────── - await page.getByTestId("BackButton").click(); + // The linearized flow renders all visited steps in the DOM simultaneously; + // scope to the active step to avoid strict-mode violation. + await page + .locator(".Send__step:not(.Send__step--hidden)") + .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({ @@ -558,14 +556,23 @@ test("Custom fee resets to default when re-entering send flow from home screen", }); // ── Second session: re-enter the same send flow ─────────────────────────── - await page.getByText("E2E").click(); + // closeSendFlow returns to account view with the asset detail still open + // (returnTo="asset_detail"), so asset-detail-send-button is directly available. + await expect(page.getByTestId("asset-detail-send-button")).toBeVisible({ + timeout: 5000, + }); await page.getByTestId("asset-detail-send-button").click(); + await expect(page.getByTestId("send-to-input")).toBeVisible(); + await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); + await page.getByText("Continue").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). + // resetSubmission cleared fee state. Auto-simulation fires on destination set, + // using the reset base fee (0.00001) — NOT the previous override (0.00005). + // Total = 0.00001 + 0.0093238 = 0.0093338 (not the previous 0.0093738). await expect(page.getByTestId("send-amount-fee-display")).toHaveText( - "0.00001 XLM", + "0.0093338 XLM", + { timeout: 10000 }, ); // ── EditSettings must show the default inclusion fee, not the saved "0.00005" ─ @@ -575,11 +582,11 @@ test("Custom fee resets to default when re-entering send flow from home screen", "0.00001", ); - // ── FeesPane shows base fee — no destination means no simulation ───────── + // ── FeesPane shows simulated total using the reset base 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-total-fee")).toHaveText( - "0.00001 XLM", + "0.0093338 XLM", ); }); @@ -603,13 +610,11 @@ test("Auto-simulation updates fee display on SendAmount before Review Send", asy // 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 expect(page.getByTestId("send-to-input")).toBeVisible(); await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); await page.getByText("Continue").click(); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await page.getByTestId("send-amount-amount-input").fill("0.1"); // Without clicking "Review Send", the fee display should update to the // simulated total (inclusion + resource). @@ -634,10 +639,13 @@ test("Soroban token — manually set fee is preserved when recipient is selected await page.getByText("E2E").click(); await page.getByTestId("asset-detail-send-button").click(); + await expect(page.getByTestId("send-to-input")).toBeVisible(); + await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); + await page.getByText("Continue").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 + // Set custom fee before changing 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( @@ -647,9 +655,9 @@ test("Soroban token — manually set fee is preserved when recipient is selected 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 + // Change 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.getByTestId("send-to-input").fill(FUNDED_DESTINATION_2); await page.getByText("Continue").click(); // Re-simulation uses saved baseFee=0.00005 → total = 0.00005 + 0.0093238 @@ -675,6 +683,10 @@ test("Classic send — manually set fee is applied and shown in settings", async await loginToTestAccount({ page, extensionId, context }); await page.getByTestId("nav-link-send").click({ force: true }); + await expect(page.getByTestId("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); + 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 expect(page.getByTestId("send-amount-fee-display")).toHaveText( "0.00001 XLM", @@ -710,10 +722,8 @@ test("Classic send — manually set fee carries through to Review Send", async ( 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 expect(page.getByTestId("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); await page.getByText("Continue").click({ force: true }); @@ -758,6 +768,10 @@ test("Classic send — manually set fee resets when re-entering send flow from h await loginToTestAccount({ page, extensionId, context }); await page.getByTestId("nav-link-send").click({ force: true }); + await expect(page.getByTestId("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); + 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-btn-fee").click(); @@ -769,12 +783,22 @@ test("Classic send — manually set fee resets when re-entering send flow from h "0.00005 XLM", ); - await page.getByTestId("BackButton").click(); + // The linearized Send flow renders all visited steps in the DOM simultaneously + // (hiding non-active ones). After visiting SELECT_SOURCE_ASSET → DESTINATION → + // AMOUNT there are 3 BackButton elements; scope to the active step only. + await page + .locator(".Send__step:not(.Send__step--hidden)") + .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("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); + 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 expect(page.getByTestId("send-amount-fee-display")).toHaveText( "0.00001 XLM", @@ -797,9 +821,13 @@ test("Classic send — manually set fee is preserved across change of recipient" await loginToTestAccount({ page, extensionId, context }); await page.getByTestId("nav-link-send").click({ force: true }); + await expect(page.getByTestId("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); + 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(); - // Set custom fee before picking a recipient + // Set custom fee before changing 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"); @@ -809,10 +837,10 @@ test("Classic send — manually set fee is preserved across change of recipient" "0.00005 XLM", ); - // Select recipient — SendAmount remounts; no simulation runs for classic + // Change recipient — 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.getByTestId("send-to-input").fill(FUNDED_DESTINATION_2); await page.getByText("Continue").click({ force: true }); await expect(page.getByTestId("send-amount-fee-display")).toHaveText( @@ -853,13 +881,11 @@ test("Re-simulation on destination change shows correct inclusion fee in EditSet // 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 expect(page.getByTestId("send-to-input")).toBeVisible(); await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); await page.getByText("Continue").click(); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await page.getByTestId("send-amount-amount-input").fill("0.1"); // Wait for first simulation to finish await expect(page.getByTestId("send-amount-fee-display")).toHaveText( @@ -923,12 +949,15 @@ test("FeesPane shows inclusion/resource rows immediately for Soroban — resourc await loginToTestAccount({ page, extensionId, context, stubOverrides }); - // Navigate to token send (no destination set) + // Navigate to token send and set destination (no amount — no auto-simulation fires) await page.getByText("E2E").click(); await page.getByTestId("asset-detail-send-button").click(); + await expect(page.getByTestId("send-to-input")).toBeVisible(); + await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); + await page.getByText("Continue").click(); await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); - // Open EditSettings — this triggers simulation (which is blocked) + // Open EditSettings — no amount set so simulation has not fired (stub blocks if it does) await page.getByTestId("send-amount-btn-fee").click(); await expect(page.getByText("Inclusion Fee")).toBeVisible(); @@ -978,15 +1007,14 @@ test("FeesPane shows — for all fee rows when simulation fails", async ({ await loginToTestAccount({ page, extensionId, context, stubOverrides }); - // Navigate to token send and set a destination to trigger auto-simulation + // Navigate to token send and set destination + amount to trigger auto-simulation 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 expect(page.getByTestId("send-to-input")).toBeVisible(); await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); await page.getByText("Continue").click(); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await page.getByTestId("send-amount-amount-input").fill("0.1"); // Wait for the simulation to fail — the Continue button becomes disabled const continueButton = page.getByTestId("send-amount-btn-continue"); @@ -1025,12 +1053,11 @@ test("Send settings Default button resets to recommended fee after saving custom // Navigate to token send and trigger Soroban simulation 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 expect(page.getByTestId("send-to-input")).toBeVisible(); await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); await page.getByText("Continue").click(); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await page.getByTestId("send-amount-amount-input").fill("0.1"); // Wait for initial simulated total fee display await expect(page.getByTestId("send-amount-fee-display")).toHaveText( @@ -1071,10 +1098,11 @@ const COLLECTIBLE_CONTRACT = // Assumes stubSimulateSendCollectible and stubContractSpec are already set up. async function navigateToCollectibleSend(page: Page) { await page.getByTestId("nav-link-send").click({ force: true }); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); - await page.getByTestId("send-amount-edit-dest-asset").click(); - await page.getByTestId("account-tab-collectibles").click(); + await expect(page.getByTestId("token-list")).toBeVisible(); + await page.getByText("Stellar Frog 1").click(); + await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); + await page.getByText("Continue").click({ force: true }); await expect(page.getByTestId("SelectedCollectible")).toBeVisible(); } @@ -1096,8 +1124,9 @@ test("Collectible — manually set fee before destination is used in simulation" await loginToTestAccount({ page, extensionId, context, stubOverrides }); await navigateToCollectibleSend(page); + // SelectedCollectible is visible; simulation fires as destination is already set - // Set a custom fee before providing a destination + // Set a custom fee (destination already set — will trigger re-simulation with custom fee) 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( @@ -1106,16 +1135,8 @@ test("Collectible — manually set fee before destination is used in simulation" 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(); - await expect(page.getByTestId("send-amount-fee-display")).toHaveText( - "0.00005 XLM", - ); - - // Set destination — simulation fires with saved custom fee - await page.getByTestId("address-tile").click(); - await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); - await page.getByText("Continue").click({ force: true }); - // Re-simulation uses baseFee=0.00005 → total = 0.00005 + 0.00001 = 0.00006 + // Simulation runs with baseFee=0.00005 → total = 0.00005 + 0.00001 = 0.00006 const continueButton = page.getByTestId("send-collectible-btn-continue"); await expect(continueButton).toBeEnabled({ timeout: 10000 }); await expect(page.getByTestId("send-amount-fee-display")).toHaveText( @@ -1145,11 +1166,7 @@ test("Collectible — manually set fee after simulation triggers re-simulation", await loginToTestAccount({ page, extensionId, context, stubOverrides }); await navigateToCollectibleSend(page); - - // Set destination — auto-simulation fires with BASE_FEE - await page.getByTestId("address-tile").click(); - await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); - await page.getByText("Continue").click({ force: true }); + // Destination already set — auto-simulation fires with BASE_FEE const continueButton = page.getByTestId("send-collectible-btn-continue"); await expect(continueButton).toBeEnabled({ timeout: 10000 }); @@ -1197,11 +1214,7 @@ test("Collectible — Default button resets to recommended fee after saving cust await loginToTestAccount({ page, extensionId, context, stubOverrides }); await navigateToCollectibleSend(page); - - // Set destination and wait for simulation - await page.getByTestId("address-tile").click(); - await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); - await page.getByText("Continue").click({ force: true }); + // Destination already set — wait for simulation to complete const continueButton = page.getByTestId("send-collectible-btn-continue"); await expect(continueButton).toBeEnabled({ timeout: 10000 }); diff --git a/extension/e2e-tests/sendCollectible.test.ts b/extension/e2e-tests/sendCollectible.test.ts index ebbb74fe79..0b4fc8d2a0 100644 --- a/extension/e2e-tests/sendCollectible.test.ts +++ b/extension/e2e-tests/sendCollectible.test.ts @@ -13,6 +13,7 @@ test("Send collectible with metadata", async ({ extensionId, context, }) => { + test.slow(); const stubOverrides = async () => { await stubSimulateSendCollectible(page); }; @@ -27,14 +28,15 @@ test("Send collectible with metadata", async ({ await loginToTestAccount({ page, extensionId, context, stubOverrides }); await page.getByTestId("nav-link-send").click(); - 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-edit-dest-asset").click(); - await page.getByTestId("account-tab-collectibles").click(); + await expect(page.getByTestId("token-list")).toBeVisible(); + await page.getByText("Stellar Frog 1").click(); + await page + .getByTestId("send-to-input") + .fill("GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"); + await page.getByText("Continue").click({ force: true }); + await expect(page.getByTestId("SelectedCollectible")).toBeVisible(); await expect( page.getByTestId("SelectedCollectible__base-info__row__name__value"), @@ -54,12 +56,6 @@ test("Send collectible with metadata", async ({ "https://nftcalendar.io/storage/uploads/events/2023/5/NeToOQbYtaJILHMnkigEAsA6ckKYe2GAA4ppAOSp.jpg", ); - await page.getByTestId("address-tile").click(); - await page - .getByTestId("send-to-input") - .fill("GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"); - await page.getByText("Continue").click({ force: true }); - await page.getByText("Review Send").scrollIntoViewIfNeeded(); await page.getByText("Review Send").click({ force: true }); @@ -95,6 +91,7 @@ test("Send collectible without metadata", async ({ extensionId, context, }) => { + test.slow(); const stubOverrides = async () => { await stubCollectiblesUnsuccessfulMetadata(page); await stubSimulateSendCollectible(page); @@ -110,13 +107,17 @@ test("Send collectible without metadata", async ({ await loginToTestAccount({ page, extensionId, context, stubOverrides }); 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-edit-dest-asset").click(); - await page.getByTestId("account-tab-collectibles").click(); - await page.getByText("Stellar Frogs").click(); + await expect(page.getByTestId("token-list")).toBeVisible(); + + await page + .locator(".CollectiblesList__collection") + .filter({ hasText: "Stellar Frogs" }) + .click(); + + await page + .getByTestId("send-to-input") + .fill("GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"); + await page.getByText("Continue").click({ force: true }); await expect(page.getByTestId("SelectedCollectible")).toBeVisible(); await expect( @@ -131,15 +132,11 @@ test("Send collectible without metadata", async ({ page.getByTestId("SelectedCollectible__base-info__row__tokenId__value"), ).toHaveText("3"); await expect( - page.getByTestId("account-collectible-placeholder"), + page + .getByTestId("SelectedCollectible") + .getByTestId("account-collectible-placeholder"), ).toBeVisible(); - await page.getByTestId("address-tile").click(); - await page - .getByTestId("send-to-input") - .fill("GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"); - await page.getByText("Continue").click({ force: true }); - await page.getByText("Review Send").scrollIntoViewIfNeeded(); await page.getByText("Review Send").click(); @@ -176,6 +173,7 @@ test("Send collectible to M address when contract doesn't support muxed is disab extensionId, context, }) => { + test.slow(); const stubOverrides = async () => { await stubSimulateSendCollectible(page); }; @@ -190,19 +188,16 @@ test("Send collectible to M address when contract doesn't support muxed is disab await loginToTestAccount({ page, extensionId, context, stubOverrides }); await page.getByTestId("nav-link-send").click({ force: true }); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await expect(page.getByTestId("token-list")).toBeVisible(); - 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(); - - // Try to send to M address (muxed address) - await page.getByTestId("address-tile").click(); + // Send to M address (muxed address) await page.getByTestId("send-to-input").fill(TEST_M_ADDRESS); await page.getByText("Continue").click({ force: true }); + await expect(page.getByTestId("SelectedCollectible")).toBeVisible(); + // Wait for contract check to complete - warning banner should appear await expect( page.getByText( @@ -235,19 +230,16 @@ test("Send collectible with Soroban mux support to M address disables memo", asy await loginToTestAccount({ page, extensionId, context, stubOverrides }); await page.getByTestId("nav-link-send").click({ force: true }); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await expect(page.getByTestId("token-list")).toBeVisible(); - 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(); - // Send to M address (muxed address) - await page.getByTestId("address-tile").click(); await page.getByTestId("send-to-input").fill(TEST_M_ADDRESS); await page.getByText("Continue").click({ force: true }); + await expect(page.getByTestId("SelectedCollectible")).toBeVisible(); + // Verify NO warning banner is shown (contract supports muxed) await expect( page.getByText( @@ -288,6 +280,7 @@ test("Send collectible without Soroban mux support to G address disables memo", extensionId, context, }) => { + test.slow(); // Stub contract spec with muxed support = false await stubContractSpec( page, @@ -298,19 +291,18 @@ test("Send collectible without Soroban mux support to G address disables memo", await loginToTestAccount({ page, extensionId, context }); await page.getByTestId("nav-link-send").click({ force: true }); - 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("token-list")).toBeVisible(); - await expect(page.getByTestId("SelectedCollectible")).toBeVisible(); + await page.getByText("Stellar Frog 1").click(); // Send to G address (regular address, not muxed) - await page.getByTestId("address-tile").click(); await page .getByTestId("send-to-input") .fill("GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"); await page.getByText("Continue").click({ force: true }); + await expect(page.getByTestId("SelectedCollectible")).toBeVisible(); + // Wait for the memo button const memoButton = page.getByTestId("send-amount-btn-memo"); await expect(memoButton).toBeEnabled({ timeout: 10000 }); @@ -344,6 +336,7 @@ test("Send collectible with Soroban mux support to G address allows memo", async extensionId, context, }) => { + test.slow(); const stubOverrides = async () => { await stubSimulateSendCollectible(page); }; @@ -357,21 +350,18 @@ test("Send collectible with Soroban mux support to G address allows memo", async await loginToTestAccount({ page, extensionId, context, stubOverrides }); await page.getByTestId("nav-link-send").click(); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await expect(page.getByTestId("token-list")).toBeVisible(); - 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(); - // Send to G address (regular address, not muxed) - await page.getByTestId("address-tile").click(); await page .getByTestId("send-to-input") .fill("GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"); await page.getByText("Continue").click({ force: true }); + await expect(page.getByTestId("SelectedCollectible")).toBeVisible(); + // Wait for the memo button const memoButton = page.getByTestId("send-amount-btn-memo"); await expect(memoButton).toBeEnabled({ timeout: 10000 }); diff --git a/extension/e2e-tests/sendPayment.test.ts b/extension/e2e-tests/sendPayment.test.ts index 91153aae97..fba9849543 100644 --- a/extension/e2e-tests/sendPayment.test.ts +++ b/extension/e2e-tests/sendPayment.test.ts @@ -1,5 +1,6 @@ import { test, expect } from "./test-fixtures"; -import { login, loginToTestAccount } from "./helpers/login"; +import { BrowserContext, Page } from "@playwright/test"; +import { login, loginToTestAccount, switchToMainnet } from "./helpers/login"; import { TEST_TOKEN_ADDRESS } from "./helpers/test-token"; import { stubAccountBalancesE2e, @@ -18,14 +19,66 @@ const UNFUNDED_DESTINATION = const FUNDED_DESTINATION = "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"; +function visibleTokenList(page: Page) { + return page.locator('[data-testid="token-list"]:visible').first(); +} + +async function stubSendTokenPrices(context: BrowserContext) { + await context.route("**/token-prices", async (route) => { + const request = route.request(); + let tokenIds = [] as string[]; + + if (request.method() === "POST") { + try { + const body = await request.postDataJSON(); + tokenIds = body.tokens || []; + } catch { + tokenIds = []; + } + } + + const data: Record< + string, + { currentPrice: string; percentagePriceChange24h: string } + > = { + native: { + currentPrice: "0.4079853099738737", + percentagePriceChange24h: "1.022345803068746424", + }, + "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5": { + currentPrice: "1.0000000000000000", + percentagePriceChange24h: "0.0000000000000000", + }, + }; + + for (const id of tokenIds) { + if (!data[id]) { + data[id] = { + currentPrice: "0.4079853099738737", + percentagePriceChange24h: "1.022345803068746424", + }; + } + } + + await route.fulfill({ json: { data } }); + }); +} + +async function clickVisibleBackButton(page: Page) { + await page.locator('[data-testid="BackButton"]:visible').first().click(); +} + test("Swap doesn't throw error when account is unfunded", async ({ page, extensionId, }) => { + test.slow(); await login({ page, extensionId }); await page.getByTestId("nav-link-swap").click(); - await expect(page.getByTestId("AppHeaderPageTitle")).toContainText("Swap"); + await expect(page.getByTestId("swap-src-asset-tile")).toBeVisible({ + timeout: 15000, + }); }); test("Swap shows correct balances for assets", async ({ page, @@ -175,12 +228,12 @@ test("Swap shows correct balances for assets", async ({ await loginToTestAccount({ page, extensionId, context, stubOverrides }); await page.getByTestId("nav-link-swap").click(); - await expect(page.getByTestId("AppHeaderPageTitle")).toContainText("Swap"); + await expect(page.getByTestId("swap-src-asset-tile")).toBeVisible({ + timeout: 15000, + }); // Click on source asset tile to see asset list await page.getByTestId("swap-src-asset-tile").click(); - await expect(page.getByTestId("AppHeaderPageTitle")).toContainText( - "Swap from", - ); + await expect(page.getByText("Swap from")).toBeVisible(); await expect(page.getByText(/FOO/)).toBeVisible(); await expect(page.getByTestId("FOO-balance")).toContainText("100"); await expect(page.getByTestId("BAZ-balance")).toContainText("10"); @@ -196,10 +249,8 @@ test("Send doesn't throw error when account is unfunded", async ({ 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("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page .getByTestId("send-to-input") .fill("GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"); @@ -230,14 +281,11 @@ test("Send XLM below minimum to unfunded destination shows warning", async ({ }); await page.getByTestId("nav-link-send").click({ force: true }); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); - - // Select address to send to - await page.getByTestId("address-tile").click(); + await expect(page.getByTestId("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page.getByTestId("send-to-input").fill(UNFUNDED_DESTINATION); await page.getByText("Continue").click({ force: true }); - // Verify amount input is shown await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); // Enter amount less than 1 XLM @@ -283,14 +331,11 @@ test("Send XLM at minimum to unfunded destination proceeds without warning", asy }); await page.getByTestId("nav-link-send").click({ force: true }); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); - - // Select address to send to - await page.getByTestId("address-tile").click(); + await expect(page.getByTestId("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page.getByTestId("send-to-input").fill(UNFUNDED_DESTINATION); await page.getByText("Continue").click({ force: true }); - // Verify amount input is shown await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); // Enter exactly 1 XLM (minimum required) @@ -341,18 +386,17 @@ test("Send non-native asset to unfunded destination shows destination missing wa }); await page.getByTestId("nav-link-send").click({ force: true }); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); - - // Change asset to USDC - await page.getByTestId("send-amount-edit-dest-asset").click(); - await page.getByText("USDC").click(); - - // Select address to send to - await page.getByTestId("address-tile").click(); + // Select USDC directly from token picker + const usdcOption = page + .locator( + '[data-testid="SendRow-USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"], [data-testid="Select-assets-row-USDC"]', + ) + .first(); + await expect(usdcOption).toBeVisible({ timeout: 10000 }); + await usdcOption.click({ force: true }); await page.getByTestId("send-to-input").fill(UNFUNDED_DESTINATION); await page.getByText("Continue").click({ force: true }); - // Verify amount input is shown await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); // Enter USDC amount @@ -385,14 +429,11 @@ test("Send XLM to funded destination does not show unfunded warning", async ({ // Don't stub unfunded balances - the default stub will return isFunded: true await page.getByTestId("nav-link-send").click({ force: true }); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); - - // Select address to send to (using funded destination) - await page.getByTestId("address-tile").click(); + await expect(page.getByTestId("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); await page.getByText("Continue").click({ force: true }); - // Verify amount input is shown await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); // Enter amount @@ -472,9 +513,8 @@ test("Send doesn't throw error when creating muxed account", async ({ }); 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("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page.getByTestId("send-to-input").fill(MUXED_ACCOUNT_ADDRESS); await expect( @@ -556,9 +596,8 @@ test("Send can review formatted inputs", async ({ }); 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("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page.getByTestId("send-to-input").fill(MUXED_ACCOUNT_ADDRESS); await expect( @@ -631,14 +670,14 @@ test.fixme("Send SAC to C address", async ({ page, extensionId, context }) => { }); await page.getByText("Done").click({ force: true }); - // send SAC to C address + // send SAC to C address — follow new linear flow: token picker → destination → amount await page.getByTestId("nav-link-send").click({ force: true }); + // Step 1: token picker — select USDC + await page.getByTestId("Select-assets-row-USDC").click({ force: true }); + // Step 2: destination await page.getByTestId("send-to-input").fill(TEST_TOKEN_ADDRESS); await page.getByText("Continue").click({ force: true }); - await page.getByTestId("send-amount-asset-select").click({ force: true }); - await page.getByTestId("Select-assets-row-USDC").click({ force: true }); - await expect(page.getByText("Send USDC")).toBeVisible(); await page.getByTestId("SendAmountSetMax").click({ force: true }); @@ -686,14 +725,20 @@ test("SendPayment persists amount and asset when navigating to choose address", await loginToTestAccount({ page, extensionId, context }); await page.getByTestId("nav-link-send").click({ force: true }); + await expect(page.getByTestId("token-list")).toBeVisible(); + await page.getByTestId("SendRow-native").click(); + // Fill an address to proceed to AMOUNT + 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("100"); await expect(page.getByTestId("send-amount-amount-input")).toHaveValue("100"); + // Navigate to address picker from AMOUNT and back await page.getByTestId("address-tile").click(); await expect(page.getByTestId("send-to-input")).toBeVisible(); - await page.getByTestId("BackButton").click(); + await clickVisibleBackButton(page); await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); await expect(page.getByTestId("send-amount-amount-input")).toHaveValue("100"); @@ -715,14 +760,17 @@ test("SendPayment resets amount when user selects new asset", async ({ stubOverrides, }); - await page.getByTestId("nav-link-send").click({ force: true }); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await goToTokenAmountStepFromHomeSend(page); await page.getByTestId("send-amount-amount-input").fill("50"); await expect(page.getByTestId("send-amount-amount-input")).toHaveValue("50"); - await page.locator(".SendAmount__EditDestAsset").click(); - await page.getByText("USDC").first().click({ force: true }); + // Change token via the token tile on the AMOUNT screen + await page.getByTestId("send-amount-edit-dest-asset").click(); + await visibleTokenList(page) + .getByText("USDC", { exact: true }) + .first() + .click({ force: true }); await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); await expect(page.getByTestId("send-amount-amount-input")).toHaveValue("0"); @@ -745,28 +793,42 @@ test("SendPayment resets state when navigating back to account", async ({ }); await page.getByTestId("nav-link-send").click({ force: true }); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); - - await page.locator(".SendAmount__EditDestAsset").click(); - await page.getByText("USDC").first().click({ force: true }); - await page.getByTestId("send-amount-amount-input").fill("100"); - - await page.getByTestId("address-tile").click(); + await expect(visibleTokenList(page)).toBeVisible(); + await page.getByTestId("SendRow-native").click(); await page .getByTestId("send-to-input") .fill("GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"); await page.getByText("Continue").click({ force: true }); - await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); - await page.getByTestId("BackButton").click(); + + // Change to USDC and enter amount + await page.getByTestId("send-amount-edit-dest-asset").click(); + await visibleTokenList(page) + .getByText("USDC", { exact: true }) + .first() + .click({ force: true }); + await page.getByTestId("send-amount-amount-input").fill("100"); + + // Press BackButton (X) to exit the flow + await clickVisibleBackButton(page); await expect(page.getByTestId("account-view")).toBeVisible(); + // Re-enter send flow: should start fresh at token picker await page.getByTestId("nav-link-send").click({ force: true }); + await expect(visibleTokenList(page)).toBeVisible(); + // Select XLM and navigate to AMOUNT to verify reset state + await page.getByTestId("SendRow-native").click(); + await page + .getByTestId("send-to-input") + .fill("GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"); + await page.getByText("Continue").click({ force: true }); await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); await expect(page.getByTestId("send-amount-amount-input")).toHaveValue("0"); - // Verify XLM is selected (more reliable than checking for "0 XLM" text) - await expect(page.locator(".SendAmount__EditDestAsset")).toContainText("XLM"); + // Verify XLM is selected (not USDC from the previous session) + await expect(page.getByTestId("send-amount-edit-dest-asset")).toContainText( + "XLM", + ); }); test("Swap persists amount when navigating to choose source asset", async ({ @@ -778,7 +840,9 @@ test("Swap persists amount when navigating to choose source asset", async ({ await loginToTestAccount({ page, extensionId, context }); await page.getByTestId("nav-link-swap").click(); - await expect(page.getByTestId("AppHeaderPageTitle")).toContainText("Swap"); + await expect(page.getByTestId("swap-src-asset-tile")).toBeVisible({ + timeout: 15000, + }); const amountInput = page.locator('input[type="text"]').first(); await amountInput.fill("100"); @@ -787,9 +851,11 @@ test("Swap persists amount when navigating to choose source asset", async ({ await page.getByTestId("swap-src-asset-tile").click({ force: true }); await expect(page.getByText("Swap from")).toBeVisible(); - await page.getByTestId("BackButton").click(); + await clickVisibleBackButton(page); - await expect(page.getByTestId("AppHeaderPageTitle")).toContainText("Swap"); + await expect(page.getByTestId("swap-src-asset-tile")).toBeVisible({ + timeout: 15000, + }); await expect(amountInput).toHaveValue("100"); }); @@ -810,7 +876,9 @@ test("Swap resets amount when user selects new source asset", async ({ }); await page.getByTestId("nav-link-swap").click(); - await expect(page.getByTestId("AppHeaderPageTitle")).toContainText("Swap"); + await expect(page.getByTestId("swap-src-asset-tile")).toBeVisible({ + timeout: 15000, + }); const amountInput = page.locator('input[type="text"]').first(); await amountInput.fill("50"); @@ -819,7 +887,9 @@ test("Swap resets amount when user selects new source asset", async ({ await page.getByTestId("swap-src-asset-tile").click({ force: true }); await page.getByText("USDC").first().click({ force: true }); - await expect(page.getByTestId("AppHeaderPageTitle")).toContainText("Swap"); + await expect(page.getByTestId("swap-src-asset-tile")).toBeVisible({ + timeout: 15000, + }); await expect(amountInput).toHaveValue("0"); }); @@ -840,7 +910,9 @@ test("Swap preserves amount when selecting destination asset", async ({ }); await page.getByTestId("nav-link-swap").click(); - await expect(page.getByTestId("AppHeaderPageTitle")).toContainText("Swap"); + await expect(page.getByTestId("swap-src-asset-tile")).toBeVisible({ + timeout: 15000, + }); const amountInput = page.locator('input[type="text"]').first(); await amountInput.fill("100"); @@ -849,7 +921,9 @@ test("Swap preserves amount when selecting destination asset", async ({ await page.getByTestId("swap-dst-asset-tile").click({ force: true }); await page.getByText("USDC").first().click({ force: true }); - await expect(page.getByTestId("AppHeaderPageTitle")).toContainText("Swap"); + await expect(page.getByTestId("swap-src-asset-tile")).toBeVisible({ + timeout: 15000, + }); await expect(amountInput).toHaveValue("100"); }); @@ -870,7 +944,7 @@ test("Swap resets state when navigating back to account", async ({ }); await page.getByTestId("nav-link-swap").click(); - await expect(page.getByTestId("AppHeaderPageTitle")).toContainText("Swap"); + await expect(page.getByTestId("swap-src-asset-tile")).toBeVisible(); const amountInput = page.locator('input[type="text"]').first(); await amountInput.fill("100"); @@ -881,13 +955,13 @@ test("Swap resets state when navigating back to account", async ({ await page.getByTestId("swap-dst-asset-tile").click({ force: true }); await page.getByText("XLM").first().click({ force: true }); - await expect(page.getByTestId("AppHeaderPageTitle")).toContainText("Swap"); - await page.getByTestId("BackButton").click(); + await expect(page.getByTestId("swap-src-asset-tile")).toBeVisible(); + await clickVisibleBackButton(page); await expect(page.getByTestId("account-view")).toBeVisible(); await page.getByTestId("nav-link-swap").click(); - await expect(page.getByTestId("AppHeaderPageTitle")).toContainText("Swap"); + await expect(page.getByTestId("swap-src-asset-tile")).toBeVisible(); const newAmountInput = page.locator('input[type="text"]').first(); await expect(newAmountInput).toHaveValue("0"); @@ -895,6 +969,109 @@ test("Swap resets state when navigating back to account", async ({ await expect(page.getByTestId("swap-src-asset-tile")).toContainText("XLM"); }); +test("Send flow starts at token picker and proceeds to amount screen", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + await loginToTestAccount({ page, extensionId, context }); + + await page.getByTestId("nav-link-send").click({ force: true }); + + // Step 1: token picker + await expect(visibleTokenList(page)).toBeVisible(); + await expect(page.getByTestId("send-amount-amount-input")).toHaveCount(0); + + // Step 2: select XLM → DESTINATION + await page.getByTestId("SendRow-native").click(); + await expect(page.getByTestId("send-to-input")).toBeVisible(); + await expect(page.getByTestId("send-amount-amount-input")).toHaveCount(0); + + // Step 3: fill address and continue → AMOUNT + 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(); +}); + +test("Send flow from asset detail starts at destination step", 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(); + + // Asset detail pre-selects asset via ?asset= param → starts at DESTINATION + await expect(page.getByTestId("send-to-input")).toBeVisible(); + // Token picker must NOT be visible (asset was pre-selected) + await expect(page.getByTestId("token-list")).toHaveCount(0); + // Amount screen must NOT be visible yet + await expect(page.getByTestId("send-amount-amount-input")).toHaveCount(0); +}); + +test("Send flow change recipient from amount screen dismisses back", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + await loginToTestAccount({ page, extensionId, context }); + + // Navigate to AMOUNT screen + await page.getByTestId("nav-link-send").click({ force: true }); + await expect(visibleTokenList(page)).toBeVisible(); + await page.getByTestId("SendRow-native").click(); + 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(); + + // Click address tile to change recipient — DESTINATION slides in from bottom + await page.getByTestId("address-tile").click(); + await expect(page.getByTestId("send-to-input")).toBeVisible(); + + // Back dismisses back to AMOUNT + await clickVisibleBackButton(page); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); +}); + +test("Send flow change token from amount screen dismisses back", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + const stubOverrides = async () => { + await stubAccountBalancesWithUSDC(page); + }; + await loginToTestAccount({ page, extensionId, context, stubOverrides }); + await switchToMainnet(page); + + // Navigate to AMOUNT screen + await page.getByTestId("nav-link-send").click({ force: true }); + await expect(visibleTokenList(page)).toBeVisible(); + await page.getByTestId("SendRow-native").click(); + 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(); + + // Click token tile to change asset — token picker slides in from bottom + await page.getByTestId("send-amount-edit-dest-asset").click(); + // Should now show token list (no tabs, inline list) + await expect(visibleTokenList(page)).toBeVisible(); + + // Back dismisses back to AMOUNT + await clickVisibleBackButton(page); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); +}); + test.afterAll(async ({ page, extensionId, context }) => { if ( test.info().status !== test.info().expectedStatus && @@ -935,26 +1112,19 @@ test("Send token payment from Asset Detail", async ({ 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.123"); - - await page.getByTestId("address-tile").click(); + // Asset detail navigates with ?asset= param, so we land at DESTINATION (not token picker) + await expect(page.getByTestId("send-to-input")).toBeVisible(); await page .getByTestId("send-to-input") .fill("GDF32CQINROD3E2LMCGZUDVMWTXCJFR5SBYVRJ7WAAIAS3P7DCVWZEFY"); await page.getByText("Continue").click(); await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await page.getByTestId("send-amount-amount-input").fill("0.123"); await expect(page.getByTestId("send-amount-amount-input")).toHaveValue( "0.123", ); - // confirm that input width stays proportional to amount length - await expect(page.getByTestId("send-amount-amount-input")).toHaveCSS( - "width", - "102px", - ); - const reviewSendButton = page.getByTestId("send-amount-btn-continue"); await expect(reviewSendButton).toBeEnabled({ timeout: 10000 }); await reviewSendButton.click({ force: true }); @@ -987,16 +1157,14 @@ test("Send XLM payment from Asset Detail", async ({ await page.getByText("XLM").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.01"); - - await page.getByTestId("address-tile").click(); + await expect(page.getByTestId("send-to-input")).toBeVisible(); await page .getByTestId("send-to-input") .fill("GDF32CQINROD3E2LMCGZUDVMWTXCJFR5SBYVRJ7WAAIAS3P7DCVWZEFY"); await page.getByText("Continue").click(); await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); + await page.getByTestId("send-amount-amount-input").fill("0.01"); await expect(page.getByTestId("send-amount-amount-input")).toHaveValue( "0.01", ); @@ -1021,3 +1189,345 @@ test.beforeEach(async ({ page }) => { (window as any).IS_PLAYWRIGHT = "true"; }); }); + +// ============================================================================ +// Navigation & Multi-Step Flow Tests +// ============================================================================ +// Tests for send flow navigation, state management, and complex user journeys + +async function goToTokenAmountStepFromHomeSend(page: Page) { + await page.getByTestId("nav-link-send").click({ force: true }); + + const tokenList = page.locator('[data-testid="token-list"]:visible').first(); + const destinationInput = page.getByTestId("send-to-input"); + + await Promise.race([ + tokenList.waitFor({ state: "visible", timeout: 10000 }).catch(() => null), + destinationInput + .first() + .waitFor({ state: "visible", timeout: 10000 }) + .catch(() => null), + ]); + + await expect(page).toHaveURL(/\/account\/sendPayment/); + + if (await tokenList.isVisible()) { + await page.getByTestId("SendRow-native").first().click(); + } + + await expect(destinationInput).toBeVisible({ timeout: 10000 }); + + await destinationInput.fill(FUNDED_DESTINATION); + await page.getByText("Continue").click({ force: true }); + await expect(page.getByTestId("send-amount-amount-input")).toBeVisible(); +} + +async function goToAssetDetail(page: Page) { + await page.getByText("E2E").first().click({ force: true }); + await expect(page.getByTestId("asset-detail-send-button")).toBeVisible(); +} + +async function goToCollectibleDetail(page: Page) { + await page.getByTestId("account-tab-collectibles").click(); + await page.getByTestId("account-collectible-image").first().click(); + await expect(page.getByTestId("CollectibleDetail")).toBeVisible(); +} + +async function goToCollectibleReviewStep(page: Page) { + // Collectible send may open at destination first depending entry path. + if ((await page.getByTestId("send-to-input").count()) > 0) { + await page.getByTestId("send-to-input").fill(FUNDED_DESTINATION); + await page.getByText("Continue").click({ force: true }); + } + + await expect(page.getByTestId("SelectedCollectible")).toBeVisible(); +} + +test("Send flow navigation: home to amount to back returns to account home", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + await loginToTestAccount({ page, extensionId, context }); + + // Initiate send from home account screen + await goToTokenAmountStepFromHomeSend(page); + + // Close send flow from amount step + await clickVisibleBackButton(page); + + // Verify return to account home (not token picker) + await expect(page.getByTestId("account-view")).toBeVisible(); + await expect(page.getByTestId("token-list")).toHaveCount(0); +}); + +test("Send flow navigation: collectible selection closes to home account", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + await loginToTestAccount({ page, extensionId, context }); + + // Open send flow from home + await page.getByTestId("nav-link-send").click({ force: true }); + await expect(page.getByTestId("token-list")).toBeVisible(); + await expect(page).toHaveURL(/\/account\/sendPayment/); + + // Select a collectible and navigate to details + await page.getByText("Stellar Frog 1").first().click({ force: true }); + await goToCollectibleReviewStep(page); + + // Close from collectible send flow + await clickVisibleBackButton(page); + + // Verify return to account home + await expect(page.getByTestId("account-view")).toBeVisible(); + await expect(page.getByTestId("token-list")).toHaveCount(0); +}); + +test("Send flow navigation: initiate from token detail closes back to token detail", 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 detail and initiate send + await goToAssetDetail(page); + await page.getByTestId("asset-detail-send-button").click(); + await expect(page.getByTestId("send-to-input")).toBeVisible(); + await expect(page).toHaveURL(/\/account\/sendPayment/); + + // Enter recipient and proceed to amount + 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(); + + // Close send flow and verify return to token detail + await clickVisibleBackButton(page); + await expect(page).toHaveURL(/tab=tokens&asset_detail=/); + await expect(page.getByTestId("asset-detail-send-button")).toBeVisible(); + await expect( + page.getByRole("heading", { name: "E2E", exact: true }), + ).toBeVisible(); +}); + +test("Send flow navigation: initiate from collectible detail closes back to collectible detail", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + await loginToTestAccount({ page, extensionId, context }); + + // Navigate to collectible and initiate send + await goToCollectibleDetail(page); + await page.getByTestId("CollectibleDetail__footer__buttons__send").click(); + + // Proceed through collectible send flow + await goToCollectibleReviewStep(page); + await expect(page).toHaveURL(/\/account\/sendPayment/); + + // Close and verify return to collectible detail + await clickVisibleBackButton(page); + + await expect(page).toHaveURL(/tab=collectibles&collection_detail=/); + await expect(page.getByTestId("CollectibleDetail")).toBeVisible(); + await expect( + page.getByTestId("CollectibleDetail__footer__buttons__send"), + ).toBeVisible(); +}); + +// ============================================================================ +// Send Flow Workflows +// ============================================================================ +// Comprehensive integration tests combining navigation, input handling, and submission + +test("Send workflow: navigate, close, re-enter, input, and submit transaction", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + await loginToTestAccount({ page, extensionId, context }); + + // First send attempt: navigate to amount, enter value, then close + await goToTokenAmountStepFromHomeSend(page); + await page.getByTestId("send-amount-amount-input").fill("5.5"); + await expect(page.getByTestId("send-amount-amount-input")).toHaveValue("5.5"); + + // Close send flow + await clickVisibleBackButton(page); + await expect(page.getByTestId("account-view")).toBeVisible(); + + // Re-enter send flow - clear previous input + await goToTokenAmountStepFromHomeSend(page); + + // Enter new valid amount (field will auto-clear on new entry) + await page.getByTestId("send-amount-amount-input").fill("1.234567"); + await expect(page.getByTestId("send-amount-amount-input")).toHaveValue( + "1.234567", + ); + + // Proceed to review and submission + const reviewButton = page.getByTestId("send-amount-btn-continue"); + await expect(reviewButton).toBeEnabled({ timeout: 10000 }); + await reviewButton.click({ force: true }); + + // Verify reached review screen + await expect(page.getByText("You are sending")).toBeVisible(); + await expect(page.getByTestId("SubmitAction")).toBeVisible({ + timeout: 15000, + }); + await expect(page.getByTestId("SubmitAction")).toBeEnabled(); +}); + +test("Send workflow: input handling with amounts, formatting, and value boundaries", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + await loginToTestAccount({ page, extensionId, context }); + + await goToTokenAmountStepFromHomeSend(page); + + // Test 1: High value with thousand separators + await page.getByTestId("send-amount-amount-input").fill("99999999"); + await expect(page.getByTestId("send-amount-amount-input")).toHaveValue( + "99,999,999", + ); + + // Test 2: Non-numeric characters are filtered + await page.getByTestId("send-amount-amount-input").fill("abc123.45xyz"); + await expect(page.getByTestId("send-amount-amount-input")).toHaveValue( + "123.45", + ); + + // Test 3: Negative sign is stripped + await page.getByTestId("send-amount-amount-input").fill("-50"); + await expect(page.getByTestId("send-amount-amount-input")).toHaveValue("50"); + + // Test 4: Valid decimal at boundary (7 decimals - Stellar max precision) + await page.getByTestId("send-amount-amount-input").fill("0.0000001"); + await expect(page.getByTestId("send-amount-amount-input")).toHaveValue( + "0.0000001", + ); + await page.waitForTimeout(500); + + // Test 5: Zero amount disables continue button + await page.getByTestId("send-amount-amount-input").fill("0"); + await page.waitForTimeout(500); + let continueBtn = page.getByTestId("send-amount-btn-continue"); + await expect(continueBtn).toBeDisabled(); + + // Test 6: Valid amount enables continue and reaches review + await page.getByTestId("send-amount-amount-input").fill("2.5"); + await expect(page.getByTestId("send-amount-amount-input")).toHaveValue("2.5"); + continueBtn = page.getByTestId("send-amount-btn-continue"); + await expect(continueBtn).toBeEnabled({ timeout: 10000 }); + await continueBtn.click({ force: true }); + + // Verify transaction review screen + await expect(page.getByText("You are sending")).toBeVisible(); +}); + +test("Send workflow: 25% amount is preserved across fiat toggle and review", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + await stubSendTokenPrices(context); + const stubOverrides = async () => { + await stubAccountBalancesWithUSDC(page); + }; + await loginToTestAccount({ page, extensionId, context, stubOverrides }); + await switchToMainnet(page); + + await goToTokenAmountStepFromHomeSend(page); + + // Set amount via percentage button and capture exact value shown in token mode. + await page.getByRole("button", { name: "25%" }).click({ force: true }); + const amountInput = page.getByTestId("send-amount-amount-input"); + await expect(amountInput).toBeVisible(); + const amountBeforeToggle = await amountInput.inputValue(); + await expect(amountBeforeToggle).not.toBe("0"); + + // Toggle to fiat and back to token. + const toggleButton = page.locator(".SendAmount__amount-price button").first(); + await expect(toggleButton).toHaveCount(1, { timeout: 15000 }); + await expect(toggleButton).toBeVisible({ timeout: 10000 }); + await toggleButton.click({ force: true }); + await page.waitForTimeout(500); + await toggleButton.click({ force: true }); + await page.waitForTimeout(500); + + // Exact token input should remain unchanged. + await expect(amountInput).toHaveValue(amountBeforeToggle); + + // Review modal should use the same exact token value. + const continueBtn = page.getByTestId("send-amount-btn-continue"); + await expect(continueBtn).toBeEnabled({ timeout: 10000 }); + await continueBtn.click({ force: true }); + + await expect(page.getByText("You are sending")).toBeVisible(); + await expect(page.getByTestId("review-tx-send-amount")).toContainText( + `${amountBeforeToggle.replace(/,/g, "")} XLM`, + ); +}); + +test("Send workflow: typed token amount is preserved across fiat toggle and review", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + await stubSendTokenPrices(context); + const stubOverrides = async () => { + await stubAccountBalancesWithUSDC(page); + }; + await loginToTestAccount({ page, extensionId, context, stubOverrides }); + await switchToMainnet(page); + + await goToTokenAmountStepFromHomeSend(page); + + const amountInput = page.getByTestId("send-amount-amount-input"); + await expect(amountInput).toBeVisible(); + + await amountInput.fill("11"); + await expect(amountInput).toHaveValue("11"); + + // Toggle to fiat and back. + const toggleButton = page.locator(".SendAmount__amount-price button").first(); + await expect(toggleButton).toHaveCount(1, { timeout: 15000 }); + await expect(toggleButton).toBeVisible({ timeout: 10000 }); + await toggleButton.click({ force: true }); + await page.waitForTimeout(500); + await toggleButton.click({ force: true }); + await page.waitForTimeout(500); + + // Exact typed token amount should be preserved. + await expect(amountInput).toHaveValue("11"); + + // Review modal should receive the preserved exact token amount. + const continueBtn = page.getByTestId("send-amount-btn-continue"); + await expect(continueBtn).toBeEnabled({ timeout: 10000 }); + await continueBtn.click({ force: true }); + + await expect(page.getByText("You are sending")).toBeVisible(); + await expect(page.getByTestId("review-tx-send-amount")).toContainText( + "11 XLM", + ); + await expect(page.getByTestId("SubmitAction")).toBeVisible({ + timeout: 15000, + }); +}); diff --git a/extension/e2e-tests/test-fixtures.ts b/extension/e2e-tests/test-fixtures.ts index 835ddbed45..547b9ac362 100644 --- a/extension/e2e-tests/test-fixtures.ts +++ b/extension/e2e-tests/test-fixtures.ts @@ -14,9 +14,11 @@ export const test = base.extend<{ extensionId: string; page: Page; language: string; + viewportSize: { width: number; height: number } | null; }>({ + viewportSize: null, language: "en", - context: async ({}, use) => { + context: async ({ viewportSize }, use) => { const pathToExtension = path.join(__dirname, "../build"); const context = await chromium.launchPersistentContext("", { headless: false, @@ -25,6 +27,7 @@ export const test = base.extend<{ `--disable-extensions-except=${pathToExtension}`, `--load-extension=${pathToExtension}`, ], + ...(viewportSize ? { viewport: viewportSize } : {}), }); // Mark every page (including popups opened by the extension) as a diff --git a/extension/src/popup/components/InternalTransaction/CollectiblesList/index.tsx b/extension/src/popup/components/InternalTransaction/CollectiblesList/index.tsx index 1d31a1a099..519a09050e 100644 --- a/extension/src/popup/components/InternalTransaction/CollectiblesList/index.tsx +++ b/extension/src/popup/components/InternalTransaction/CollectiblesList/index.tsx @@ -12,7 +12,7 @@ import { getUserCollections } from "popup/helpers/collectibles"; import "./styles.scss"; -/* UI for displaying a vertical list of clickable collectibles */ +/* UI for displaying a vertical list of clickable collectibles grouped by collection */ export const CollectiblesList = ({ collectibles, @@ -42,47 +42,61 @@ export const CollectiblesList = ({ ); } - const flattenedCollections = userCollectibles.flatMap((collection) => { - if (collection.collection) { - return collection.collection.collectibles; - } - return []; - }); - return (
- {flattenedCollections.map((collection) => { - const title = - collection.metadata?.name || `${collection.collectionName}`; + {userCollectibles.map((collection) => { + if (!collection.collection) return null; + const items = collection.collection.collectibles; + const collectionName = items[0]?.collectionName || ""; + const collectionAddress = collection.collection.address; return (
- onClickCollectible({ - collectionName: collection.collectionName, - collectionAddress: collection.collectionAddress, - tokenId: collection.tokenId, - name: getCollectibleName( - collection.metadata?.name, - collection.tokenId, - ), - image: collection.metadata?.image || "", - }) - } + key={`${collectionAddress}-group`} + className="CollectiblesList__collection-group" > -
- -
-
{title}
-
- #{collection.tokenId} +
+ + {collectionName} + +
+ + {items.length} +
+ {items.map((collectible) => { + const title = + collectible.metadata?.name || collectible.collectionName; + return ( +
+ onClickCollectible({ + collectionName: collectible.collectionName, + collectionAddress: collectible.collectionAddress, + tokenId: collectible.tokenId, + name: getCollectibleName( + collectible.metadata?.name, + collectible.tokenId, + ), + image: collectible.metadata?.image || "", + }) + } + > +
+ +
+
+ {title} +
+
+ ); + })}
); })} diff --git a/extension/src/popup/components/InternalTransaction/CollectiblesList/styles.scss b/extension/src/popup/components/InternalTransaction/CollectiblesList/styles.scss index f1c6679b36..02a4f3977e 100644 --- a/extension/src/popup/components/InternalTransaction/CollectiblesList/styles.scss +++ b/extension/src/popup/components/InternalTransaction/CollectiblesList/styles.scss @@ -19,6 +19,37 @@ gap: pxToRem(24px); } + &__collection-group { + display: flex; + flex-direction: column; + gap: pxToRem(8px); + } + + &__section-header { + display: flex; + align-items: center; + gap: pxToRem(12px); + + &__name { + font-size: pxToRem(14px); + line-height: pxToRem(20px); + color: var(--sds-clr-gray-11); + white-space: nowrap; + } + + &__divider { + flex: 1; + height: pxToRem(1px); + background-color: var(--sds-clr-gray-06); + } + + &__count { + font-size: pxToRem(14px); + line-height: pxToRem(20px); + color: var(--sds-clr-gray-11); + } + } + &__collection { display: flex; align-items: center; @@ -42,20 +73,5 @@ font-size: pxToRem(14px); line-height: pxToRem(20px); } - - &__token-id { - border-radius: pxToRem(100px); - background-color: var(--sds-clr-gray-03); - border: 1px solid var(--sds-clr-gray-06); - display: flex; - align-items: center; - justify-content: center; - margin-left: auto; - font-size: pxToRem(12px); - line-height: pxToRem(18px); - font-weight: var(--sds-fw-heavy); - color: var(--sds-clr-gray-11); - padding: pxToRem(2px) pxToRem(6px); - } } } diff --git a/extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/useSubmitTxData.tsx b/extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/useSubmitTxData.tsx index 690e0df3f4..ac48dc24f3 100644 --- a/extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/useSubmitTxData.tsx +++ b/extension/src/popup/components/InternalTransaction/SubmitTransaction/hooks/useSubmitTxData.tsx @@ -17,6 +17,7 @@ import { emitMetric } from "helpers/metrics"; import { METRIC_NAMES } from "popup/constants/metricsNames"; import { getAssetFromCanonical, isMainnet } from "helpers/stellar"; import { AssetIcons } from "@shared/api/types"; +import { allAccountsSelector } from "popup/ducks/accountServices"; interface SubmitTxData { status: "success" | "error"; @@ -41,6 +42,7 @@ function useSubmitTxData({ initialState, ); const submission = useSelector(transactionSubmissionSelector); + const allAccounts = useSelector(allAccountsSelector); const { fetchData: fetchBalances } = useGetBalances({ showHidden: false, includeIcons: false, @@ -88,9 +90,15 @@ function useSubmitTxData({ if (submitFreighterTransaction.fulfilled.match(submitResp)) { if (!isSwap) { - await reduxDispatch( - addRecentAddress({ address: federationAddress || destination }), + const isSelfOwnedDestination = (allAccounts ?? []).some( + (account) => account.publicKey === destination, ); + + if (!isSelfOwnedDestination) { + await reduxDispatch( + addRecentAddress({ address: federationAddress || destination }), + ); + } } emitMetric(METRIC_NAMES.sendPaymentSuccess, { sourceAsset: sourceAsset.code, diff --git a/extension/src/popup/components/InternalTransaction/SubmitTransaction/index.tsx b/extension/src/popup/components/InternalTransaction/SubmitTransaction/index.tsx index 61cb6530a5..d3d02d6b29 100644 --- a/extension/src/popup/components/InternalTransaction/SubmitTransaction/index.tsx +++ b/extension/src/popup/components/InternalTransaction/SubmitTransaction/index.tsx @@ -69,6 +69,7 @@ export const SendingTransaction = ({ amount, asset, destination, + recipientName, destinationAsset, destinationAmount, }, @@ -79,6 +80,8 @@ export const SendingTransaction = ({ const dstAsset = destinationAsset ? getAssetFromCanonical(destinationAsset) : null; + const destinationDisplayLabel = + recipientName || truncatedPublicKey(destination); const { state: submissionState, fetchData } = useSubmitTxData({ publicKey, @@ -303,7 +306,7 @@ export const SendingTransaction = ({ {`${t("to")} `} - {truncatedPublicKey(destination)} + {destinationDisplayLabel} )} @@ -335,7 +338,7 @@ export const SendingTransaction = ({ className="SendingTransaction__Summary__Description__Label" data-testid="sending-transaction-summary-description-label-destination-address" > - {truncatedPublicKey(destination)} + {destinationDisplayLabel} )} diff --git a/extension/src/popup/components/InternalTransaction/TokenList/index.tsx b/extension/src/popup/components/InternalTransaction/TokenList/index.tsx index dedf84425a..ec46737495 100644 --- a/extension/src/popup/components/InternalTransaction/TokenList/index.tsx +++ b/extension/src/popup/components/InternalTransaction/TokenList/index.tsx @@ -39,6 +39,7 @@ export const TokenList = ({ className={classnames("TokenList__Assets", { "TokenList__Assets--no-header": !isShowingHeader, })} + data-testid="token-list" > {!tokens.length ? (
diff --git a/extension/src/popup/components/SelectionTile/__tests__/index.test.tsx b/extension/src/popup/components/SelectionTile/__tests__/index.test.tsx index bc2508b53f..5f21c36e9b 100644 --- a/extension/src/popup/components/SelectionTile/__tests__/index.test.tsx +++ b/extension/src/popup/components/SelectionTile/__tests__/index.test.tsx @@ -39,6 +39,19 @@ describe("SelectionTile", () => { fireEvent.click(screen.getByText("Primary Text")); expect(mockOnClick).toHaveBeenCalledTimes(1); }); + + it("calls onClick when chevron button is clicked", () => { + render( + Icon
} + primaryText="Primary Text" + onClick={mockOnClick} + />, + ); + + fireEvent.click(screen.getByRole("button")); + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); }); describe("without secondary text", () => { diff --git a/extension/src/popup/components/SelectionTile/index.tsx b/extension/src/popup/components/SelectionTile/index.tsx index ff5d76ab09..ee0c986069 100644 --- a/extension/src/popup/components/SelectionTile/index.tsx +++ b/extension/src/popup/components/SelectionTile/index.tsx @@ -7,6 +7,7 @@ interface SelectionTileProps { icon: React.ReactNode; primaryText: string; secondaryText?: string; + title?: string; onClick: () => void; isEmpty?: boolean; shouldUseIconWrapper?: boolean; @@ -17,16 +18,24 @@ export const SelectionTile = ({ icon, primaryText, secondaryText, + title, onClick, isEmpty = false, shouldUseIconWrapper = true, testId, }: SelectionTileProps) => { + const handleChevronClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onClick(); + }; + return (
{shouldUseIconWrapper ? ( @@ -41,7 +50,13 @@ export const SelectionTile = ({ )}
-
diff --git a/extension/src/popup/components/account/AccountAssets/index.tsx b/extension/src/popup/components/account/AccountAssets/index.tsx index 8d0a13fa41..cd34addb85 100644 --- a/extension/src/popup/components/account/AccountAssets/index.tsx +++ b/extension/src/popup/components/account/AccountAssets/index.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState, memo } from "react"; import { useSelector } from "react-redux"; +import { useLocation, useNavigate } from "react-router-dom"; import isEmpty from "lodash/isEmpty"; import { Asset, Horizon } from "stellar-sdk"; import BigNumber from "bignumber.js"; @@ -30,6 +31,7 @@ import ImageMissingIcon from "popup/assets/image-missing.svg?react"; import IconSoroban from "popup/assets/icon-soroban.svg?react"; import { getPriceDeltaColor } from "popup/helpers/balance"; import { AccountHistoryData } from "popup/views/Account/hooks/useGetAccountHistoryData"; +import { ROUTES } from "popup/constants/routes"; import "./styles.scss"; import { AssetDetail } from "../AssetDetail"; @@ -195,16 +197,48 @@ export const AccountAssets = ({ assetPrices, historyData, }: AccountAssetsProps) => { + const navigate = useNavigate(); + const location = useLocation(); const [assetIcons, setAssetIcons] = useState(inputAssetIcons); const networkDetails = useSelector(settingsNetworkDetailsSelector); const [hasIconFetchRetried, setHasIconFetchRetried] = useState(false); const isAssetSuspicious = useIsAssetSuspicious(); const [selectedAsset, setSelectedAsset] = useState(""); + const clearAssetDetailQueryParams = () => { + const params = new URLSearchParams(location.search); + if (!params.has("asset_detail") && !params.has("return_to")) { + return; + } + + params.delete("asset_detail"); + params.delete("return_to"); + params.delete("return_asset"); + params.delete("return_collection_address"); + params.delete("return_collectible_token_id"); + + navigate( + { + pathname: ROUTES.account, + search: params.toString() ? `?${params.toString()}` : "", + }, + { replace: true }, + ); + }; + useEffect(() => { setAssetIcons(inputAssetIcons); }, [inputAssetIcons]); + useEffect(() => { + const params = new URLSearchParams(location.search); + const assetDetail = params.get("asset_detail"); + + if (assetDetail) { + setSelectedAsset(assetDetail); + } + }, [location.search]); + const retryAssetIconFetch = async ({ key, code, @@ -292,7 +326,12 @@ export const AccountAssets = ({ return ( !open && setSelectedAsset("")} + onOpenChange={(open) => { + if (!open) { + setSelectedAsset(""); + clearAssetDetailQueryParams(); + } + }} key={canonicalAsset} >
setSelectedAsset("")} + handleClose={() => { + setSelectedAsset(""); + clearAssetDetailQueryParams(); + }} /> diff --git a/extension/src/popup/components/account/AccountCollectibles/index.tsx b/extension/src/popup/components/account/AccountCollectibles/index.tsx index 9c9f365584..47cd8d7a8f 100644 --- a/extension/src/popup/components/account/AccountCollectibles/index.tsx +++ b/extension/src/popup/components/account/AccountCollectibles/index.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { Icon } from "@stellar/design-system"; +import { useLocation, useNavigate } from "react-router-dom"; import { Collection } from "@shared/api/types/types"; import { @@ -9,6 +10,7 @@ import { SheetContent, SheetTitle, } from "popup/basics/shadcn/Sheet"; +import { ROUTES } from "popup/constants/routes"; import { CollectibleDetail, SelectedCollectible } from "../CollectibleDetail"; import { CollectibleInfoImage } from "../CollectibleInfo"; @@ -25,11 +27,49 @@ const CollectionsList = ({ isCollectibleHidden: (collectionAddress: string, tokenId: string) => boolean; onCloseCollectible: () => void; }) => { + const navigate = useNavigate(); + const location = useLocation(); const [isDetailOpen, setIsDetailOpen] = useState(false); const [detailData, setDetailData] = useState( null, ); + const clearCollectibleDetailQueryParams = () => { + const params = new URLSearchParams(location.search); + if (!params.has("collection_detail") && !params.has("return_to")) { + return; + } + + params.delete("collection_detail"); + params.delete("collectible_token_id"); + params.delete("return_to"); + params.delete("return_asset"); + params.delete("return_collection_address"); + params.delete("return_collectible_token_id"); + + navigate( + { + pathname: ROUTES.account, + search: params.toString() ? `?${params.toString()}` : "", + }, + { replace: true }, + ); + }; + + React.useEffect(() => { + const params = new URLSearchParams(location.search); + const collectionAddress = params.get("collection_detail"); + const tokenId = params.get("collectible_token_id"); + + if (collectionAddress && tokenId) { + setDetailData({ + collectionAddress, + tokenId, + }); + setIsDetailOpen(true); + } + }, [location.search]); + const handleOpenCollectible = (collectible: SelectedCollectible) => { setDetailData(collectible); setIsDetailOpen(true); @@ -37,6 +77,7 @@ const CollectionsList = ({ const handleCloseCollectible = () => { setIsDetailOpen(false); + clearCollectibleDetailQueryParams(); onCloseCollectible(); }; diff --git a/extension/src/popup/components/account/AssetDetail/index.tsx b/extension/src/popup/components/account/AssetDetail/index.tsx index c61c306eb3..c299e2cc06 100644 --- a/extension/src/popup/components/account/AssetDetail/index.tsx +++ b/extension/src/popup/components/account/AssetDetail/index.tsx @@ -407,7 +407,7 @@ export const AssetDetail = ({ isRounded isFullWidth onClick={() => { - const queryParams = `?asset=${encodeURIComponent(selectedAsset)}`; + const queryParams = `?asset=${encodeURIComponent(selectedAsset)}&return_to=asset_detail&return_asset=${encodeURIComponent(selectedAsset)}`; navigateTo(ROUTES.sendPayment, navigate, queryParams); }} > diff --git a/extension/src/popup/components/account/CollectibleDetail/index.tsx b/extension/src/popup/components/account/CollectibleDetail/index.tsx index b2647ccca0..6e48d54b01 100644 --- a/extension/src/popup/components/account/CollectibleDetail/index.tsx +++ b/extension/src/popup/components/account/CollectibleDetail/index.tsx @@ -94,7 +94,7 @@ export const CollectibleDetail = ({ const handleSendCollectible = () => { // add the collectible data to the query params. They will be used to pre-populate the collectible data in the send flow. - const queryParams = `?collection_address=${encodeURIComponent(selectedCollectible.collectionAddress)}&collectible_token_id=${encodeURIComponent(selectedCollectible.tokenId)}`; + const queryParams = `?collection_address=${encodeURIComponent(selectedCollectible.collectionAddress)}&collectible_token_id=${encodeURIComponent(selectedCollectible.tokenId)}&return_to=collectible_detail&return_collection_address=${encodeURIComponent(selectedCollectible.collectionAddress)}&return_collectible_token_id=${encodeURIComponent(selectedCollectible.tokenId)}`; navigateTo(ROUTES.sendPayment, navigate, queryParams); }; diff --git a/extension/src/popup/components/send/AddressTile/index.tsx b/extension/src/popup/components/send/AddressTile/index.tsx index a20391f90f..57d6b2d4d0 100644 --- a/extension/src/popup/components/send/AddressTile/index.tsx +++ b/extension/src/popup/components/send/AddressTile/index.tsx @@ -11,12 +11,14 @@ import "./styles.scss"; interface AddressTileProps { address: string; federationAddress?: string; + recipientName?: string; onClick: () => void; } export const AddressTile = ({ address, federationAddress, + recipientName, onClick, }: AddressTileProps) => { const { t } = useTranslation(); @@ -26,10 +28,19 @@ export const AddressTile = ({ } primaryText={ - federationAddress + recipientName || + (federationAddress ? truncatedFedAddress(federationAddress) - : truncatedPublicKey(address) + : truncatedPublicKey(address)) } + secondaryText={ + recipientName + ? federationAddress + ? truncatedFedAddress(federationAddress) + : truncatedPublicKey(address) + : undefined + } + title={address} onClick={onClick} testId="address-tile" /> diff --git a/extension/src/popup/components/send/SendAmount/index.tsx b/extension/src/popup/components/send/SendAmount/index.tsx index b0c752d106..82b82282b7 100644 --- a/extension/src/popup/components/send/SendAmount/index.tsx +++ b/extension/src/popup/components/send/SendAmount/index.tsx @@ -10,11 +10,7 @@ import { LoadingBackground } from "popup/basics/LoadingBackground"; import { View } from "popup/basics/layout/View"; import { METRIC_NAMES } from "popup/constants/metricsNames"; import { AppDispatch } from "popup/App"; -import { - getAssetFromCanonical, - isMainnet, - isMuxedAccount, -} from "helpers/stellar"; +import { getAssetFromCanonical, isMuxedAccount } from "helpers/stellar"; import { NetworkCongestion } from "popup/helpers/useNetworkFees"; import { emitMetric } from "helpers/metrics"; import { trackSendFeeBreakdownOpened } from "popup/metrics/send"; @@ -62,9 +58,9 @@ import { SelectedCollectible } from "popup/components/sendCollectible/SelectedCo import { AppDataType } from "helpers/hooks/useGetAppData"; import { useGetSendAmountData } from "./hooks/useSendAmountData"; import { SimulateTxData, SimulateResult } from "./hooks/useSimulateTxData"; -import { InputWidthContext } from "popup/views/Send/contexts/inputWidthContext"; import { SlideupModal } from "popup/components/SlideupModal"; import { MemoEditingContext } from "popup/constants/send-payment"; +import { InputWidthContext } from "popup/views/Send/contexts/inputWidthContext"; import { checkIsMuxedSupported, getMemoDisabledState, @@ -75,6 +71,52 @@ import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import "../styles.scss"; const DEFAULT_INPUT_WIDTH = 25; +const PERCENTAGE_OPTIONS = [ + ["25%", 25], + ["50%", 50], + ["75%", 75], +] as const; + +const normalizeNumericString = (value: string) => { + const cleaned = cleanAmount(value); + let hasDecimal = false; + let normalized = ""; + + for (const char of cleaned) { + if (char === ".") { + if (hasDecimal) { + continue; + } + hasDecimal = true; + } + normalized += char; + } + + return normalized; +}; + +const getValidBigNumber = (value: string) => { + const cleanedValue = normalizeNumericString(value); + + if (!cleanedValue || cleanedValue === ".") { + return null; + } + + let numericValue: BigNumber; + try { + numericValue = new BigNumber(cleanedValue); + } catch { + return null; + } + + return numericValue.isNaN() ? null : numericValue; +}; + +const isValidPositiveAmount = (value: string) => { + const numericValue = getValidBigNumber(value); + + return Boolean(numericValue && numericValue.gt(0)); +}; // 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 @@ -111,7 +153,6 @@ export const SendAmount = ({ const { t } = useTranslation(); const location = useLocation(); const dispatch = useDispatch(); - const runAfterUpdate = useRunAfterUpdate(); const { transactionData } = useSelector(transactionSubmissionSelector); const networkDetails = useSelector(settingsNetworkDetailsSelector); const { @@ -121,6 +162,7 @@ export const SendAmount = ({ destination, destinationAsset, federationAddress, + recipientName, isToken, transactionFee, isCollectible, @@ -176,9 +218,16 @@ export const SendAmount = ({ }, destination, ); - const cryptoSpanRef = 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 cryptoSpanRef = useRef(null); const fiatSpanRef = useRef(null); + const cryptoInputRef = useRef(null); + const usdInputRef = useRef(null); + const runAfterUpdate = useRunAfterUpdate(); const { inputWidthCrypto, setInputWidthCrypto, @@ -186,12 +235,6 @@ export const SendAmount = ({ setInputWidthFiat, } = React.useContext(InputWidthContext); - 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); @@ -200,6 +243,9 @@ export const SendAmount = ({ const [contractSupportsMuxed, setContractSupportsMuxed] = React.useState< boolean | null >(null); + // Mirror mobile behavior: preserve the amount from the field the user + // actually edited; only convert when switching away from that source field. + const [editedInputType, setEditedInputType] = useState("crypto"); // Get contract ID for custom tokens - must be before conditional returns const contractId = React.useMemo( @@ -231,6 +277,8 @@ export const SendAmount = ({ // Tokens with Soroban mux support allow memo for G addresses, but memo is encoded in M addresses // Must be before conditional returns React.useEffect(() => { + let isMounted = true; + const checkContract = async () => { if ( (!isToken && !isCollectible) || @@ -247,7 +295,9 @@ export const SendAmount = ({ contractId, networkDetails, }); - setContractSupportsMuxed(supportsMuxed); + if (isMounted) { + setContractSupportsMuxed(supportsMuxed); + } } catch (error) { // On error, assume no support for safety captureException(error, { @@ -255,11 +305,17 @@ export const SendAmount = ({ message: "Error checking contract muxed support", }, }); - setContractSupportsMuxed(false); + if (isMounted) { + setContractSupportsMuxed(false); + } } }; checkContract(); + + return () => { + isMounted = false; + }; }, [isToken, isCollectible, destination, contractId, networkDetails]); // Get memo disabled state using the helper @@ -290,8 +346,14 @@ export const SendAmount = ({ React.useState(null); const handlePaymentContinue = async () => { - const amount = inputType === "crypto" ? formik.values.amount : priceValue!; - dispatch(saveAmount(cleanAmount(amount))); + const nextAmount = + inputType === "crypto" ? formik.values.amount : effectiveTokenAmount; + + if (!isValidPositiveAmount(nextAmount)) { + return; + } + + dispatch(saveAmount(normalizeNumericString(nextAmount))); await handleContinue(); }; @@ -328,13 +390,21 @@ export const SendAmount = ({ }; const validate = (values: { amount: string }) => { - const amount = inputType === "crypto" ? values.amount : priceValue!; - const val = cleanAmount(amount); + const valueToValidate = + inputType === "crypto" ? values.amount : effectiveTokenAmount; + const cleanedValue = normalizeNumericString(valueToValidate); - if (val.indexOf(".") !== -1 && val.split(".")[1].length > 7) { + if ( + cleanedValue.indexOf(".") !== -1 && + cleanedValue.split(".")[1].length > 7 + ) { return { amount: AMOUNT_ERROR.DEC_MAX }; } - if (new BigNumber(val).gt(new BigNumber(TX_SEND_MAX))) { + if ( + cleanedValue && + cleanedValue !== "." && + new BigNumber(cleanedValue).gt(new BigNumber(TX_SEND_MAX)) + ) { return { amount: AMOUNT_ERROR.SEND_MAX }; } return {}; @@ -353,6 +423,7 @@ export const SendAmount = ({ setInputWidthCrypto(cryptoSpanRef.current.offsetWidth + 2); } }, [formik.values.amount, setInputWidthCrypto]); + useLayoutEffect(() => { if (fiatSpanRef.current) { setInputWidthFiat(fiatSpanRef.current.offsetWidth + 4); @@ -365,18 +436,6 @@ export const SendAmount = ({ sendAmountData.state === RequestState.IDLE || sendAmountData.state === RequestState.LOADING; - useEffect(() => { - if (cryptoInputRef.current) { - cryptoInputRef.current.focus(); - cryptoInputRef.current.select(); - } - - if (usdInputRef.current) { - usdInputRef.current.focus(); - usdInputRef.current.select(); - } - }, []); - useEffect(() => { const getData = async () => { await fetchData(); @@ -385,6 +444,16 @@ export const SendAmount = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + formik.setValues({ + amount, + amountUsd, + asset, + destinationAsset, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [asset, destinationAsset]); + // 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. @@ -419,15 +488,37 @@ export const SendAmount = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [destination, asset, isToken, isCollectible, simulationState.state]); + // If the user was in fiat mode and current asset no longer has a USD price, + // force back to crypto mode so the input is still operable. + useEffect(() => { + if ( + inputType === "fiat" && + sendAmountData.state === RequestState.SUCCESS && + sendAmountData.data?.type === AppDataType.RESOLVED + ) { + const currentAssetPrice = + sendAmountData.data.tokenPrices?.[asset]?.currentPrice; + if (!currentAssetPrice) { + setInputType("crypto"); + } + } + }, [inputType, sendAmountData.state, sendAmountData.data, asset]); + const getAmountFontSize = () => { - const length = formik.values.amount.length; - if (length <= 9) { - return ""; + const currentValue = + inputType === "fiat" ? formik.values.amountUsd : formik.values.amount; + const digitsLength = currentValue.replace(/[^0-9]/g, "").length; + + if (digitsLength <= 6) { + return "lg"; } - if (length <= 15) { + if (digitsLength <= 10) { return "med"; } - return "small"; + if (digitsLength <= 13) { + return "small"; + } + return "xsmall"; }; if (isLoading) { @@ -471,32 +562,32 @@ export const SendAmount = ({ : getBalanceByAsset(srcAsset, sendData.userBalances.balances); const prices = sendData.tokenPrices; const assetPrice = prices[asset] && prices[asset].currentPrice; - const xlmPrice = prices["native"]?.currentPrice; const assetDecimals = getAssetDecimals(asset, sendData.userBalances, isToken); + const amountBigNumber = getValidBigNumber(formik.values.amount); + const amountUsdBigNumber = getValidBigNumber(formik.values.amountUsd); const priceValue = assetPrice - ? new BigNumber(cleanAmount(formik.values.amountUsd)) - .dividedBy(new BigNumber(assetPrice)) - .decimalPlaces(assetDecimals) - .toString() + ? amountUsdBigNumber + ? amountUsdBigNumber + .dividedBy(new BigNumber(assetPrice)) + .decimalPlaces(assetDecimals) + .toString() + : null : null; const priceValueUsd = assetPrice - ? `${formatAmount( - roundUsdValue( - new BigNumber(assetPrice) - .multipliedBy(new BigNumber(cleanAmount(formik.values.amount))) - .toString(), - ), - )}` - : null; - const recommendedFeeUsd = xlmPrice - ? `$${formatAmount( - roundUsdValue( - new BigNumber(xlmPrice).multipliedBy(new BigNumber(fee)).toString(), - ), - )}` + ? amountBigNumber + ? `${formatAmount( + roundUsdValue( + new BigNumber(assetPrice).multipliedBy(amountBigNumber).toString(), + ), + )}` + : null : null; - const supportsUsd = - isMainnet(sendAmountData.data?.networkDetails!) && assetPrice; + const effectiveTokenAmount = + inputType === "fiat" && editedInputType === "crypto" + ? normalizeNumericString(formik.values.amount) + : (priceValue ?? ""); + const supportsUsd = !!assetPrice; + const availableBalance = getAvailableBalance({ assetCanonical: asset, balances: sendData.userBalances.balances, @@ -506,7 +597,7 @@ export const SendAmount = ({ assetBalance && "decimals" in assetBalance ? availableBalance : formatAmount(availableBalance); - const srcTitle = srcAsset.code; + const goBackAction = () => { dispatch(saveAsset("native")); dispatch(saveIsToken(false)); @@ -517,9 +608,6 @@ export const SendAmount = ({ dispatch(saveTransactionFee("")); dispatch(saveManualTransactionFee(null)); goBack(); - if (isCollectible) { - goToChooseAssetAction(); - } }; const goToChooseAssetAction = () => { // Changing the asset may switch between Soroban and classic (or a different @@ -535,20 +623,56 @@ export const SendAmount = ({ const isAmountTooHigh = (inputType === "crypto" && - new BigNumber(cleanAmount(formik.values.amount)).gt( - new BigNumber(availableBalance), - )) || + Boolean(amountBigNumber?.gt(new BigNumber(availableBalance)))) || (inputType === "fiat" && - new BigNumber(cleanAmount(priceValue!)).gt( - new BigNumber(availableBalance), + Boolean( + getValidBigNumber(effectiveTokenAmount)?.gt( + new BigNumber(availableBalance), + ), )); + const isAmountInputValid = + inputType === "crypto" + ? isValidPositiveAmount(formik.values.amount) + : isValidPositiveAmount(formik.values.amountUsd) && + isValidPositiveAmount(effectiveTokenAmount); + + const handlePercentage = (pct: number) => { + if (pct === 100) { + emitMetric(METRIC_NAMES.sendPaymentSetMax); + } + + const fraction = new BigNumber(pct).dividedBy(100); + if (inputType === "fiat" && assetPrice) { + const pctUsd = formatAmount( + roundUsdValue( + new BigNumber(assetPrice) + .multipliedBy(new BigNumber(cleanAmount(availableBalance))) + .multipliedBy(fraction) + .toString(), + ), + ); + formik.setFieldValue("amountUsd", pctUsd); + dispatch(saveAmountUsd(pctUsd)); + setEditedInputType("fiat"); + } else { + const pctAmount = new BigNumber(cleanAmount(availableBalance)) + .multipliedBy(fraction) + .decimalPlaces(assetDecimals) + .toString(); + formik.setFieldValue("amount", pctAmount); + dispatch(saveAmount(pctAmount)); + setEditedInputType("crypto"); + } + }; + return ( {t("Send")}} hasBackButton customBackAction={goBackAction} + customBackIcon={} />
-
+
)} @@ -704,145 +822,203 @@ export const SendAmount = ({ ) : (
-
-
- {inputType === "crypto" && ( - <> - - {formik.values.amount || "0"} - - { - const input = e.target; - const { amount: newAmount, newCursor } = - formatAmountPreserveCursor( - e.target.value, - formik.values.amount, - getAssetDecimals( - asset, - sendData.userBalances, - isToken, - ), - e.target.selectionStart || 1, - ); - formik.setFieldValue("amount", newAmount); - dispatch(saveAmount(newAmount)); - runAfterUpdate(() => { - input.selectionStart = newCursor; - input.selectionEnd = newCursor; - }); - }} - autoFocus - autoComplete="off" - /> -
- {parsedSourceAsset.code} -
- - )} - {inputType === "fiat" && ( - <> -
- $ -
- - {formik.values.amountUsd || "0"} - - { - const input = e.target; - const { amount: newAmount, newCursor } = - formatAmountPreserveCursor( - e.target.value, - formik.values.amountUsd, - 2, - e.target.selectionStart || 1, - ); - formik.setFieldValue("amountUsd", newAmount); - dispatch(saveAmountUsd(newAmount)); - runAfterUpdate(() => { - input.selectionStart = newCursor; - input.selectionEnd = newCursor; - }); - }} - autoFocus - autoComplete="off" - /> - - )} + {/* Recipient at TOP */} + + + {/* Amount card: matches mobile's rounded card container */} +
+ {/* Sending label */} +
+ {t("Sending")}
-
- {supportsUsd && ( -
- {inputType === "crypto" - ? `$${priceValueUsd}` - : `${priceValue} ${parsedSourceAsset.code}`} - + + + {parsedSourceAsset.code} + + +
- )} -
+ + {/* Secondary row: USD equivalent and available balance */} +
+
+ {supportsUsd + ? inputType === "crypto" + ? `$${priceValueUsd || "0.00"}` + : `${formatAmount(effectiveTokenAmount || "0")} ${parsedSourceAsset.code}` + : null} + {supportsUsd && ( + + )} +
+
+ {displayTotal} {parsedSourceAsset.code} +
+
+ + {/* Error state */} {isAmountTooHigh && ( - <> +
{t( @@ -852,73 +1028,37 @@ export const SendAmount = ({ }, )} - +
)}
-
- + ))} + + {t("Max")} +
-
-
- -
-
- {srcTitle} -
-
- {displayTotal} -
-
-
- -
-
)} diff --git a/extension/src/popup/components/send/SendDestinationAsset/index.tsx b/extension/src/popup/components/send/SendDestinationAsset/index.tsx index 9ea8aaad03..f740351ae8 100644 --- a/extension/src/popup/components/send/SendDestinationAsset/index.tsx +++ b/extension/src/popup/components/send/SendDestinationAsset/index.tsx @@ -1,6 +1,6 @@ import React, { useEffect } from "react"; import { useDispatch } from "react-redux"; -import { Navigate } from "react-router-dom"; +import { Navigate, useLocation } from "react-router-dom"; import { Icon, Loader, Notification } from "@stellar/design-system"; import { useTranslation } from "react-i18next"; @@ -13,6 +13,8 @@ import { saveIsToken, saveIsCollectible, saveCollectibleData, + saveManualTransactionFee, + saveTransactionFee, } from "popup/ducks/transactionSubmission"; import { View } from "popup/basics/layout/View"; import { TokenList } from "popup/components/InternalTransaction/TokenList"; @@ -22,9 +24,6 @@ import { AppDataType } from "helpers/hooks/useGetAppData"; import { openTab } from "popup/helpers/navigate"; import { newTabHref } from "helpers/urls"; import { reRouteOnboarding } from "popup/helpers/route"; -import { TabButtons } from "popup/components/account/AccountTabs"; -import { useActiveTab } from "popup/components/account/AccountTabs/hooks/useActiveTab"; -import { TabsList } from "popup/views/Account/contexts/activeTabContext"; import { useGetDestAssetData } from "./hooks/useGetDestAssetData"; import "./styles.scss"; @@ -32,20 +31,21 @@ import "./styles.scss"; interface SendDestinationAssetProps { goBack: () => void; goToNext: () => void; + showCloseIcon?: boolean; } export const SendDestinationAsset = ({ goBack, goToNext, + showCloseIcon = false, }: SendDestinationAssetProps) => { const { t } = useTranslation(); const dispatch = useDispatch(); + const location = useLocation(); const { state: destAssetDataState, fetchData } = useGetDestAssetData({ showHidden: false, includeIcons: true, }); - const { activeTab } = useActiveTab(); - const isLoading = destAssetDataState.state === RequestState.IDLE || destAssetDataState.state === RequestState.LOADING; @@ -105,12 +105,11 @@ export const SendDestinationAsset = ({ const tokenPrices = destAssetDataState.data.tokenPrices || {}; const balances = destAssetDataState.data.balances; - const isTokensTab = activeTab === TabsList.TOKENS; - const isCollectiblesTab = activeTab === TabsList.COLLECTIBLES; - const resetAmountForm = () => { dispatch(saveAmount("0")); dispatch(saveAmountUsd("0.00")); + dispatch(saveTransactionFee("")); + dispatch(saveManualTransactionFee(null)); }; return ( @@ -119,53 +118,53 @@ export const SendDestinationAsset = ({ title={{t("Send")}} hasBackButton customBackAction={goBack} - customBackIcon={} + {...(showCloseIcon && { customBackIcon: })} />
-
- -
- {isTokensTab && ( - { - dispatch(saveIsCollectible(false)); - dispatch(saveAsset(canonical)); - dispatch(saveIsToken(isContract)); - resetAmountForm(); - goToNext(); - }} - isShowingHeader={false} - /> - )} - {isCollectiblesTab && ( - { - dispatch(saveIsCollectible(true)); - dispatch( - saveCollectibleData({ - collectionAddress, - tokenId: Number(tokenId), - name, - collectionName, - image, - }), - ); - resetAmountForm(); - goToNext(); - }} - /> + { + dispatch(saveIsCollectible(false)); + dispatch(saveAsset(canonical)); + dispatch(saveIsToken(isContract)); + resetAmountForm(); + goToNext(); + }} + isShowingHeader={false} + /> + {destAssetDataState.data.collectibles.collections.length > 0 && ( +
+
+ {t("Collectibles")} +
+ { + dispatch(saveIsCollectible(true)); + dispatch( + saveCollectibleData({ + collectionAddress, + tokenId: Number(tokenId), + name, + collectionName, + image, + }), + ); + resetAmountForm(); + goToNext(); + }} + /> +
)}
diff --git a/extension/src/popup/components/send/SendDestinationAsset/styles.scss b/extension/src/popup/components/send/SendDestinationAsset/styles.scss index 8307387de5..7f5bde34d1 100644 --- a/extension/src/popup/components/send/SendDestinationAsset/styles.scss +++ b/extension/src/popup/components/send/SendDestinationAsset/styles.scss @@ -4,10 +4,15 @@ display: flex; flex-direction: column; - &__tab-buttons { - display: flex; + &__collectibles-section { + margin-top: pxToRem(16); + } + + &__collectibles-heading { font-size: pxToRem(14); line-height: pxToRem(20); - gap: pxToRem(16); + font-weight: 600; + padding: pxToRem(8) 0; + color: var(--sds-clr-gray-11); } } diff --git a/extension/src/popup/components/send/SendTo/index.tsx b/extension/src/popup/components/send/SendTo/index.tsx index fa30f589e9..8d9f3bd5f2 100644 --- a/extension/src/popup/components/send/SendTo/index.tsx +++ b/extension/src/popup/components/send/SendTo/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import { Asset, StrKey } from "stellar-sdk"; import { useFormik } from "formik"; @@ -28,10 +28,15 @@ import { isContractId } from "popup/helpers/soroban"; import { METRIC_NAMES } from "popup/constants/metricsNames"; import { STELLAR_DOCS_CREATE_ACCOUNT_URL } from "popup/constants/externalLinks"; import { View } from "popup/basics/layout/View"; +import { + allAccountsSelector, + publicKeySelector, +} from "popup/ducks/accountServices"; import { saveDestination, saveDestinationAsset, saveFederationAddress, + saveRecipientName, transactionDataSelector, } from "popup/ducks/transactionSubmission"; @@ -47,6 +52,27 @@ import { reRouteOnboarding } from "popup/helpers/route"; import "../styles.scss"; const baseReserve = new BigNumber(1); +const MAX_VISIBLE_RECENT_ADDRESSES = 10; +const DESTINATION_DEBOUNCE_MS = 400; + +type ResolvedSuggestionData = { + type: AppDataType.RESOLVED; + validatedAddress: string; + fedAddress: string; + destinationBalances?: { isFunded: boolean }; + recentAddresses: string[]; +}; + +const isResolvedSuggestionData = ( + data: unknown, +): data is ResolvedSuggestionData => + Boolean( + data && + typeof data === "object" && + "type" in data && + (data as { type?: AppDataType }).type === AppDataType.RESOLVED && + "validatedAddress" in data, + ); export const shouldAccountDoesntExistWarning = ( isFunded: boolean, @@ -111,15 +137,25 @@ export const SendTo = ({ const { destination, federationAddress } = useSelector( transactionDataSelector, ); + const allAccounts = useSelector(allAccountsSelector); + const activePublicKey = useSelector(publicKeySelector); const { state: sendDataState, fetchData } = useSendToData(); + const [debouncedDestination, setDebouncedDestination] = useState( + federationAddress || destination || "", + ); + const otherAccounts = (allAccounts ?? []).filter( + ({ publicKey }) => publicKey !== activePublicKey, + ); const handleContinue = ( validatedDestination: string, validatedFedAdress?: string, + recipientName = "", ) => { dispatch(saveDestination(validatedDestination)); dispatch(saveDestinationAsset("")); dispatch(saveFederationAddress(validatedFedAdress || "")); + dispatch(saveRecipientName(recipientName)); goToNext(); }; @@ -128,7 +164,7 @@ export const SendTo = ({ onSubmit: () => { if ( sendDataState.state === RequestState.SUCCESS && - sendDataState.data.type == AppDataType.RESOLVED + sendDataState.data.type === AppDataType.RESOLVED ) { handleContinue( sendDataState.data.validatedAddress, @@ -162,11 +198,17 @@ export const SendTo = ({ }; useEffect(() => { - const getData = async () => { - const errors = await formik.validateForm(); + const timeoutId = setTimeout(async () => { + setDebouncedDestination(formik.values.destination); + const errors = await formik.validateForm(formik.values); await fetchData(formik.values.destination, errors); + }, DESTINATION_DEBOUNCE_MS); + + return () => { + clearTimeout(timeoutId); }; - getData(); + // fetchData and formik.validateForm are stable refs — omitting intentionally + // to avoid re-triggering the debounce when only those refs change identity. // eslint-disable-next-line react-hooks/exhaustive-deps }, [formik.values.destination]); @@ -174,6 +216,33 @@ export const SendTo = ({ const isLoading = sendDataState.state === RequestState.IDLE || sendDataState.state === RequestState.LOADING; + const isFetching = sendDataState.state === RequestState.LOADING; + const isSearchSettled = formik.values.destination === debouncedDestination; + const resolvedSendData = isResolvedSuggestionData(sendDataState.data) + ? sendDataState.data + : null; + + // Track whether any successful fetch has completed (used for initial spinner). + const hasLoadedOnceRef = useRef(false); + if (resolvedSendData) { + hasLoadedOnceRef.current = true; + } + + // Cache recent addresses independently — only update when the fetch returns + // real data. The validation-error path returns recentAddresses: [] which must + // not clear a previously populated list. + const cachedRecentAddressesRef = useRef([]); + if (resolvedSendData?.recentAddresses.length) { + cachedRecentAddressesRef.current = resolvedSendData.recentAddresses; + } + + // Only replace the whole suggestions area with a spinner on the very first load. + const isInitialLoad = isLoading && !hasLoadedOnceRef.current; + + const visibleRecentAddresses = cachedRecentAddressesRef.current.slice( + 0, + MAX_VISIBLE_RECENT_ADDRESSES, + ); if (sendDataState.data?.type === AppDataType.REROUTE) { if (sendDataState.data.shouldOpenTab) { @@ -199,11 +268,7 @@ export const SendTo = ({ return ( - } - /> +
- {isLoading ? ( + {/* Suggestions area — gated on fetch state */} + {isInitialLoad ? (
@@ -234,86 +300,130 @@ export const SendTo = ({ } /> ) : ( -
- {formik.values.destination === "" ? ( - <> - {sendDataState.data.recentAddresses.length > 0 && ( + debouncedDestination !== "" && + isSearchSettled && ( +
+ {formik.isValid && resolvedSendData ? ( + <> + {resolvedSendData.destinationBalances && + !resolvedSendData.destinationBalances.isFunded && ( + + )}
- - {t("Recents")} + + {t("Suggestions")}
- )} -
-
    - {sendDataState.data.recentAddresses.map((address) => ( -
  • - -
  • - ))} -
-
- - ) : ( -
- {formik.isValid ? ( - <> - {sendDataState.data.destinationBalances && - !sendDataState.data.destinationBalances.isFunded && ( - - )} -
- - Suggestions + + + ) : isFetching ? null : ( + + )} +
+ ) + )} + {/* Recents and My Accounts are always visible */} + {visibleRecentAddresses.length > 0 && ( +
+ + {t("Recents")} +
+ )} +
+
    + {visibleRecentAddresses.map((address) => ( +
  • + +
  • + ))} +
+
+ {otherAccounts.length > 0 && ( + <> +
+ + {t("My Accounts")} +
+
+
    + {otherAccounts.map((account) => ( +
  • +
- - ) : ( - - )} -
- )} -
+ + + ))} + +
+ )}
- {!isLoading && formik.values.destination && formik.isValid ? ( + {!isLoading && + isSearchSettled && + formik.values.destination && + formik.isValid ? (