diff --git a/src/components/SignerSelector/index.tsx b/src/components/SignerSelector/index.tsx index 56c5dc55a..8d23e81a5 100644 --- a/src/components/SignerSelector/index.tsx +++ b/src/components/SignerSelector/index.tsx @@ -5,20 +5,37 @@ import { JSX, useCallback, useLayoutEffect, useRef } from "react"; import { useStore } from "@/store/useStore"; import { localStorageSavedKeypairs } from "@/helpers/localStorageSavedKeypairs"; +import { localStorageSavedContracts } from "@/helpers/localStorageSavedContracts"; import { shortenStellarAddress } from "@/helpers/shortenStellarAddress"; import { truncateString } from "@/helpers/truncateString"; import { InputSideElement } from "@/components/InputSideElement"; -import { SavedKeypair } from "@/types/types"; +import { SavedContract, SavedKeypair } from "@/types/types"; import "./styles.scss"; type SignerMode = "public" | "secret"; +// "wallet" and "keypair" resolve to a public/secret key, while "contract" +// resolves to a contract address. +type SignerOptionKind = "wallet" | "keypair" | "contract"; + +type WalletItem = { publicKey: string }; +type SignerOptionItem = WalletItem | SavedKeypair | SavedContract; + +type SignerOptionGroup = { + label: string; + kind: SignerOptionKind; + items: SignerOptionItem[]; +}; + type ButtonProps = { mode: SignerMode; onClick: () => void; + // Smart contract addresses are only valid in some contexts (e.g. the + // JsonSchema `address` field), so they are opt-in. + includeContracts?: boolean; }; type DropdownProps = { @@ -26,6 +43,7 @@ type DropdownProps = { isOpen: boolean; onClose: () => void; mode: SignerMode; + includeContracts?: boolean; }; interface SignerSelectorComponent { @@ -44,7 +62,11 @@ const getTitle = ({ mode }: { mode: SignerMode }) => { } }; -const SignerSelectorButton = ({ mode, onClick }: ButtonProps): JSX.Element => { +const SignerSelectorButton = ({ + mode, + onClick, + includeContracts = false, +}: ButtonProps): JSX.Element => { const { walletKit, network } = useStore(); const { publicKey: walletKitPubKey } = walletKit || {}; @@ -54,13 +76,22 @@ const SignerSelectorButton = ({ mode, onClick }: ButtonProps): JSX.Element => { (keypair) => keypair.network.id === network.id, ); + // Contracts only apply in public mode (they have no secret key to sign with). + const showContracts = includeContracts && mode === "public"; + const currentNetworkContracts = showContracts + ? localStorageSavedContracts + .get() + .filter((contract) => contract.network.id === network.id) + : []; + const hasKeypairs = currentNetworkKeypairs.length > 0; + const hasContracts = currentNetworkContracts.length > 0; const hasWallet = !!walletKitPubKey; const title = getTitle({ mode }); // No sources available - if (!hasKeypairs && !hasWallet) { + if (!hasKeypairs && !hasWallet && !hasContracts) { return <>; } @@ -70,7 +101,7 @@ const SignerSelectorButton = ({ mode, onClick }: ButtonProps): JSX.Element => { } // Public Signer mode with only wallet - show direct button - if (mode === "public" && !hasKeypairs && hasWallet) { + if (mode === "public" && !hasKeypairs && !hasContracts && hasWallet) { return ( Get connected wallet address @@ -90,6 +121,7 @@ const SignerSelectorDropdown = ({ isOpen, onClose, mode, + includeContracts = false, }: DropdownProps): JSX.Element => { const { walletKit, network } = useStore(); const { publicKey: walletKitPubKey } = walletKit || {}; @@ -98,6 +130,14 @@ const SignerSelectorDropdown = ({ const currentNetworkKeypairs = savedLocalKeypairs.filter( (keypair) => keypair.network.id === network.id, ); + + // Contracts only apply in public mode (they have no secret key to sign with). + const showContracts = includeContracts && mode === "public"; + const currentNetworkContracts = showContracts + ? localStorageSavedContracts + .get() + .filter((contract) => contract.network.id === network.id) + : []; const dropdownRef = useRef(null); const handleClickOutside = useCallback( @@ -124,28 +164,37 @@ const SignerSelectorDropdown = ({ }; }, [isOpen, handleClickOutside]); - const getAvailableKeypairs = () => { - const availableAddress = []; + const getAvailableOptions = (): SignerOptionGroup[] => { + const availableOptions: SignerOptionGroup[] = []; if (walletKitPubKey && mode === "public") { - const saved = { + availableOptions.push({ label: "Connected Wallet", + kind: "wallet", items: [{ publicKey: walletKitPubKey }], - }; - availableAddress.push(saved); + }); } if (currentNetworkKeypairs.length > 0) { - const saved = { + availableOptions.push({ label: "Saved keypairs", + kind: "keypair", items: currentNetworkKeypairs, - }; - availableAddress.push(saved); + }); + } + + if (currentNetworkContracts.length > 0) { + availableOptions.push({ + label: "Saved contracts", + kind: "contract", + items: currentNetworkContracts, + }); } - return availableAddress; + + return availableOptions; }; - const signers = getAvailableKeypairs(); + const options = getAvailableOptions(); if (!isOpen) { return <>; @@ -153,11 +202,12 @@ const SignerSelectorDropdown = ({ return (
- {signers.map((address, index) => { + {options.map((option, index) => { return ( ( +const getLabel = (label: string, columnLabel: string | null) => (
{label}
- {isSavedKeypair ? ( + {columnLabel ? (
- Public key + {columnLabel}
) : null}
@@ -183,55 +233,89 @@ const getLabel = (label: string, isSavedKeypair: boolean) => ( const OptionItem = ({ label, + kind, items, onChange, onClose, mode, }: { label: string; - items: Array<{ publicKey: string }> | SavedKeypair[]; + kind: SignerOptionKind; + items: SignerOptionItem[]; onChange: (val: string) => void; onClose: () => void; mode: SignerMode; }) => { - const isSavedKeypair = items.every((item) => "secretKey" in item); + // The address a given item resolves to in the input field. + const getAddress = (item: SignerOptionItem) => { + if (kind === "contract") { + return (item as SavedContract).contractId; + } + + if (kind === "keypair" && mode === "secret") { + return (item as SavedKeypair).secretKey; + } + + return (item as WalletItem).publicKey; + }; - const renderKey = (item: SavedKeypair) => { + // Tag shown in the right column of the group label. + const getColumnLabel = () => { + switch (kind) { + case "keypair": + return "Public key"; + case "contract": + return "Contract ID"; + default: + return null; + } + }; + + const renderNamedItem = (name: string, address: string) => { return (
-
[{truncateString(item.name, 55)}]
+
[{truncateString(name, 55)}]
- {shortenStellarAddress(item.publicKey)} + {shortenStellarAddress(address)}
); }; + const renderItem = (item: SignerOptionItem) => { + if (kind === "keypair") { + const keypair = item as SavedKeypair; + return renderNamedItem(keypair.name, keypair.publicKey); + } + + if (kind === "contract") { + const contract = item as SavedContract; + return renderNamedItem(contract.name, contract.contractId); + } + + return shortenStellarAddress((item as WalletItem).publicKey); + }; + return (
- {getLabel(label, isSavedKeypair)} + {getLabel(label, getColumnLabel())} {items.map((item, index) => { + const address = getAddress(item); + return (
{ - const value = isSavedKeypair - ? mode === "secret" - ? (item as SavedKeypair).secretKey - : item.publicKey - : item.publicKey; - onChange(value); + onChange(address); onClose(); }} > - {isSavedKeypair - ? renderKey(item as SavedKeypair) - : shortenStellarAddress(item.publicKey)} + {renderItem(item)}
); })} diff --git a/src/components/SmartContractJsonSchema/renderPrimitivesType.tsx b/src/components/SmartContractJsonSchema/renderPrimitivesType.tsx index ad85417f6..4a37d4fbb 100644 --- a/src/components/SmartContractJsonSchema/renderPrimitivesType.tsx +++ b/src/components/SmartContractJsonSchema/renderPrimitivesType.tsx @@ -34,12 +34,14 @@ const AddressInputWithSignerSelector = ( rightElement={ setIsSelectorOpen(!isSelectorOpen)} /> } /> { onValueSelect(val); setIsSelectorOpen(false); diff --git a/tests/e2e/signerSelector.test.ts b/tests/e2e/signerSelector.test.ts index b047ee3c7..e3091e7c0 100644 --- a/tests/e2e/signerSelector.test.ts +++ b/tests/e2e/signerSelector.test.ts @@ -1,5 +1,6 @@ import { baseURL } from "../../playwright.config"; import { test, expect, Page, Browser } from "@playwright/test"; +import { STELLAR_EXPERT_API } from "@/constants/settings"; import { MOCK_LOCAL_STORAGE, @@ -7,7 +8,16 @@ import { SAVED_ACCOUNT_1_SECRET, SAVED_ACCOUNT_2, SAVED_ACCOUNT_2_SECRET, + SAVED_CONTRACT_1, + SAVED_CONTRACT_2, } from "./mock/localStorage"; +import { + MOCK_SAC_CONTRACT_ID, + MOCK_SAC_CONTRACT_TYPE_RESPONSE, +} from "./mock/smartContracts"; +import { mockRpcRequest } from "./mock/helpers"; +import stellarAssetSpec from "./mock/stellarAssetSpec.json"; +import { shortenStellarAddress } from "@/helpers/shortenStellarAddress"; // Helper functions async function setupPageContext(browser: Browser, url: string): Promise { @@ -19,6 +29,38 @@ async function setupPageContext(browser: Browser, url: string): Promise { return page; } +// Registers the mocks needed to load a SAC contract (real contract.Spec, no +// wasm decoding) so the Invoke contract tab renders its function cards. +async function mockSacContract(page: Page) { + await mockRpcRequest({ + page, + rpcMethod: "getLedgerEntries", + bodyJsonResponse: MOCK_SAC_CONTRACT_TYPE_RESPONSE, + }); + + await page.route( + "https://raw.githubusercontent.com/stellar/stellar-asset-contract-spec/refs/heads/main/stellar-asset-spec.json", + async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(stellarAssetSpec), + }); + }, + ); + + await page.route( + `${STELLAR_EXPERT_API}/testnet/contract/${MOCK_SAC_CONTRACT_ID}`, + async (route) => { + await route.fulfill({ + status: 404, + contentType: "application/json", + body: "{}", + }); + }, + ); +} + async function validateSignerSelectorOptions(page: Page) { const signerSelectorOptions = page.getByTestId("signer-selector-options"); @@ -147,6 +189,66 @@ test.describe("Signer Selector", () => { ).toBeVisible(); }); }); + test.describe("in Address field on Invoke Contract Page", () => { + let pageContext: Page; + + test.beforeAll(async ({ browser }) => { + const browserContext = await browser.newContext({ + storageState: MOCK_LOCAL_STORAGE, + }); + pageContext = await browserContext.newPage(); + + await mockSacContract(pageContext); + + await pageContext.goto(`${baseURL}/smart-contracts/contract-explorer`); + }); + + test("'Get address' dropdown shows both saved keypairs and saved contracts", async () => { + // Load the SAC contract and open the Invoke contract tab. + await pageContext.getByLabel("Contract ID").fill(MOCK_SAC_CONTRACT_ID); + await pageContext.getByRole("button", { name: "Load contract" }).click(); + await pageContext.getByTestId("contract-invoke").click(); + + const invokeContainer = pageContext.getByTestId( + "invoke-contract-container", + ); + await expect(invokeContainer).toBeVisible(); + + // `balance` takes a single `id: address` argument, so its card has the + // address input with the "Get address" selector. + const balanceTitle = invokeContainer.getByText("balance", { + exact: true, + }); + await expect(balanceTitle).toBeVisible(); + const balanceCard = balanceTitle.locator( + 'xpath=ancestor::*[contains(@class, "Card")][1]', + ); + + await balanceCard.getByText("Get address").click(); + + // Two groups render: saved keypairs and saved contracts. + const groups = balanceCard.getByTestId("signer-selector-options"); + const labels = groups.locator(".SignerSelector__dropdown__item__label"); + await expect(labels.filter({ hasText: "Saved keypairs" })).toBeVisible(); + await expect(labels.filter({ hasText: "Saved contracts" })).toBeVisible(); + + const values = groups.locator(".SignerSelector__dropdown__item__value"); + await expect( + values.filter({ hasText: shortenStellarAddress(SAVED_ACCOUNT_1) }), + ).toBeVisible(); + await expect( + values.filter({ hasText: shortenStellarAddress(SAVED_CONTRACT_1) }), + ).toBeVisible(); + + // Selecting a contract fills the address input with its contract ID. + await values + .filter({ hasText: shortenStellarAddress(SAVED_CONTRACT_2) }) + .click(); + await expect(balanceCard.locator("input").first()).toHaveValue( + SAVED_CONTRACT_2, + ); + }); + }); }); // =============================================================================