diff --git a/__tests__/components/screens/SignTransactionDetails/components/Operations.test.tsx b/__tests__/components/screens/SignTransactionDetails/components/Operations.test.tsx index 921fef5be..e402aeed8 100644 --- a/__tests__/components/screens/SignTransactionDetails/components/Operations.test.tsx +++ b/__tests__/components/screens/SignTransactionDetails/components/Operations.test.tsx @@ -7,11 +7,13 @@ import { Networks, Operation, OperationRecord, + StrKey, TransactionBuilder, xdr, } from "@stellar/stellar-sdk"; import { render } from "@testing-library/react-native"; import Operations from "components/screens/SignTransactionDetails/components/Operations"; +import { truncateAddress } from "helpers/stellar"; import React from "react"; // Render i18n keys verbatim so assertions target the value rows, not labels. @@ -22,7 +24,18 @@ jest.mock("react-i18next", () => ({ jest.mock("hooks/useColors", () => ({ __esModule: true, default: () => ({ - themeColors: { text: { secondary: "#a0a0a0" }, gray: { 9: "#8f8f8f" } }, + themeColors: { + text: { secondary: "#a0a0a0" }, + // Badge reads variant-specific palettes from themeColors.[11] + // (and gray[12] for its primary variant) when resolving text color — + // include what the tertiary/cleared & deleted badges under test need + // so the component doesn't throw while resolving. + gray: { 9: "#8f8f8f", 11: "#8f8f8f", 12: "#707070" }, + lilac: { 11: "#aa00aa" }, + lime: { 11: "#00aa00" }, + amber: { 11: "#ffa000" }, + red: { 11: "#ff0000" }, + }, }), })); @@ -47,6 +60,9 @@ const SOURCE = "GDVEU3DD4KOFECV66VIHWEZOYX4ZKR3WV27L464SIIPOU2IUI3JCZA57"; const WBTC_ISSUER = "GD6ROJBYLKQMOW3E7N4M2YBPUHMZD7PL65VRHRMO24BOVSBV5H3BQRSL"; const WBTC = new Asset("WBTC", WBTC_ISSUER); const XLM = Asset.native(); +const USDC_ISSUER = StrKey.encodeEd25519PublicKey(Buffer.alloc(32, 9)); +const USDC = new Asset("USDC", USDC_ISSUER); +const SIGNER_PUBLIC_KEY = StrKey.encodeEd25519PublicKey(Buffer.alloc(32, 5)); const FIND = { timeout: 3000 }; // react-i18next is mocked to echo the key, so labels render as their key path. const label = (key: string) => `signTransactionDetails.operations.${key}`; @@ -163,3 +179,229 @@ describe("SignTransactionDetails > Operations: offer amount denomination & price expect(await findByText("10.1234567 XLM", {}, FIND)).toBeTruthy(); }); }); + +describe("SignTransactionDetails > Operations: setOptions presence checks & master-key warning", () => { + it("masterWeight: 0 renders the row, its value, and the master-key warning (not an empty operation)", async () => { + const ops = operationsFor(Operation.setOptions({ masterWeight: 0 })); + + const { findByText, getByTestId } = render( + , + ); + + expect(await findByText(label("masterWeight"), {}, FIND)).toBeTruthy(); + expect(await findByText("0", {}, FIND)).toBeTruthy(); + expect(getByTestId("MasterKeyDisableWarning")).toBeTruthy(); + }); + + it("CASE-2 takeover: masterWeight + all thresholds set to 0 alongside a new signer all render, plus the warning", async () => { + const ops = operationsFor( + Operation.setOptions({ + masterWeight: 0, + lowThreshold: 0, + medThreshold: 0, + highThreshold: 0, + signer: { ed25519PublicKey: SIGNER_PUBLIC_KEY, weight: 1 }, + }), + ); + + const { findByText, findAllByText, getByTestId } = render( + , + ); + + expect(await findByText(label("masterWeight"), {}, FIND)).toBeTruthy(); + expect(await findByText(label("lowThreshold"), {}, FIND)).toBeTruthy(); + expect(await findByText(label("mediumThreshold"), {}, FIND)).toBeTruthy(); + expect(await findByText(label("highThreshold"), {}, FIND)).toBeTruthy(); + const zeros = await findAllByText("0", {}, FIND); + expect(zeros.length).toBe(4); + // KeyValueSigner renders via KeyVal.tsx's own i18next `t` (not the + // react-i18next mock this file uses), so its label doesn't echo the key + // path here -- assert on the truncated signer address it renders instead. + expect( + await findByText(truncateAddress(SIGNER_PUBLIC_KEY), {}, FIND), + ).toBeTruthy(); + expect(getByTestId("MasterKeyDisableWarning")).toBeTruthy(); + }); + + it("non-zero masterWeight/highThreshold render their values and do NOT show the master-key warning", async () => { + const ops = operationsFor( + Operation.setOptions({ masterWeight: 2, highThreshold: 3 }), + ); + + const { findByText, queryByTestId } = render( + , + ); + + expect(await findByText("2", {}, FIND)).toBeTruthy(); + expect(await findByText("3", {}, FIND)).toBeTruthy(); + expect(queryByTestId("MasterKeyDisableWarning")).toBeNull(); + }); + + it("homeDomain: '' renders the row with a Cleared badge", async () => { + const ops = operationsFor(Operation.setOptions({ homeDomain: "" })); + + const { findByText } = render(); + + expect(await findByText(label("homeDomain"), {}, FIND)).toBeTruthy(); + expect(await findByText(label("cleared"), {}, FIND)).toBeTruthy(); + }); + + it("setFlags decodes a combined bitmask (REQUIRED | IMMUTABLE)", async () => { + const ops = operationsFor( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Operation.setOptions({ setFlags: 5 as any }), + ); + + const { findByText } = render(); + + expect(await findByText(label("setFlags"), {}, FIND)).toBeTruthy(); + expect( + await findByText( + "Authorization Required, Authorization Immutable", + {}, + FIND, + ), + ).toBeTruthy(); + }); + + it("clearFlags decodes a combined bitmask (REQUIRED | REVOCABLE)", async () => { + const ops = operationsFor( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Operation.setOptions({ clearFlags: 3 as any }), + ); + + const { findByText } = render(); + + expect(await findByText(label("clearFlags"), {}, FIND)).toBeTruthy(); + expect( + await findByText( + "Authorization Required, Authorization Revocable", + {}, + FIND, + ), + ).toBeTruthy(); + }); +}); + +describe("SignTransactionDetails > Operations: manageData presence checks", () => { + it("value: null (deletion) renders the Value row with a Deleted badge", async () => { + const ops = operationsFor( + Operation.manageData({ name: "k", value: null }), + ); + + const { findByText } = render(); + + expect(await findByText(label("value"), {}, FIND)).toBeTruthy(); + expect(await findByText(label("deleted"), {}, FIND)).toBeTruthy(); + }); + + it("value: '' (empty, not a deletion) still renders the Value row", async () => { + const ops = operationsFor(Operation.manageData({ name: "k", value: "" })); + + const { findByText, queryByText } = render( + , + ); + + expect(await findByText(label("value"), {}, FIND)).toBeTruthy(); + expect(queryByText(label("deleted"))).toBeNull(); + }); +}); + +describe("SignTransactionDetails > Operations: setTrustLineFlags presence checks", () => { + it("flags.authorized: false renders the row as Disabled (not hidden)", async () => { + const ops = operationsFor( + Operation.setTrustLineFlags({ + trustor: SOURCE, + asset: WBTC, + flags: { authorized: false }, + }), + ); + + const { findByText } = render(); + + expect( + await findByText(label("flags.authorized"), {}, FIND), + ).toBeTruthy(); + expect(await findByText(label("disabled"), {}, FIND)).toBeTruthy(); + }); + + it("flags.authorized: true renders the row as Enabled", async () => { + const ops = operationsFor( + Operation.setTrustLineFlags({ + trustor: SOURCE, + asset: WBTC, + flags: { authorized: true }, + }), + ); + + const { findByText } = render(); + + expect( + await findByText(label("flags.authorized"), {}, FIND), + ).toBeTruthy(); + expect(await findByText(label("enabled"), {}, FIND)).toBeTruthy(); + }); +}); + +describe("SignTransactionDetails > Operations: asset issuer disclosure", () => { + it("payment of a non-native asset renders the token issuer", async () => { + const ops = operationsFor( + Operation.payment({ + destination: SOURCE, + asset: USDC, + amount: "10", + }), + ); + + const { findByText } = render(); + + expect(await findByText(label("tokenIssuer"), {}, FIND)).toBeTruthy(); + }); + + it("payment of native XLM does NOT render a token issuer row", async () => { + const ops = operationsFor( + Operation.payment({ + destination: SOURCE, + asset: XLM, + amount: "10", + }), + ); + + const { findByText, queryByText } = render( + , + ); + + expect(await findByText(label("tokenCode"), {}, FIND)).toBeTruthy(); + expect(queryByText(label("tokenIssuer"))).toBeNull(); + }); + + it("manageSellOffer with a non-native buying asset renders the token issuer", async () => { + const ops = operationsFor( + Operation.manageSellOffer({ + selling: XLM, + buying: USDC, + amount: "1", + price: "1", + offerId: "0", + }), + ); + + const { findByText } = render(); + + expect(await findByText(label("tokenIssuer"), {}, FIND)).toBeTruthy(); + }); + + it("revokeTrustlineSponsorship renders the token issuer for a non-native asset", async () => { + const ops = operationsFor( + Operation.revokeTrustlineSponsorship({ + account: SOURCE, + asset: WBTC, + }), + ); + + const { findByText } = render(); + + expect(await findByText(label("tokenCode"), {}, FIND)).toBeTruthy(); + expect(await findByText(label("tokenIssuer"), {}, FIND)).toBeTruthy(); + }); +}); diff --git a/src/components/screens/SignTransactionDetails/components/Operations.tsx b/src/components/screens/SignTransactionDetails/components/Operations.tsx index 29b0d92dc..19919a1f8 100644 --- a/src/components/screens/SignTransactionDetails/components/Operations.tsx +++ b/src/components/screens/SignTransactionDetails/components/Operations.tsx @@ -13,6 +13,8 @@ import { KeyValueInvokeHostFnArgs, } from "components/screens/SignTransactionDetails/components/KeyVal"; import Avatar from "components/sds/Avatar"; +import { Badge } from "components/sds/Badge"; +import { Banner } from "components/sds/Banner"; import Icon from "components/sds/Icon"; import { Text } from "components/sds/Typography"; import { @@ -52,6 +54,18 @@ const formatOfferPriceRatio = ( baseCode: string, ) => `${formatTokenForDisplay(price)} ${quoteCode} / ${baseCode}`; +const MasterKeyDisableWarning = () => { + const { t } = useAppTranslation(); + return ( + + ); +}; + const RenderOperationByType = ({ operation, }: { @@ -64,6 +78,21 @@ const RenderOperationByType = ({ const { copyToClipboard } = useClipboard(); const { themeColors } = useColors(); + const issuerListItem = (issuer: string): ListItemProps => ({ + title: t("signTransactionDetails.operations.tokenIssuer"), + trailingContent: ( + + copyToClipboard(issuer)} + /> + {truncateAddress(issuer)} + + ), + titleColor: themeColors.text.secondary, + }); + const authorizationMap: AuthorizationMap = { "1": "Authorization Required", @@ -170,6 +199,7 @@ const RenderOperationByType = ({ trailingContent: {asset.code}, titleColor: themeColors.text.secondary, }, + ...(asset.issuer ? [issuerListItem(asset.issuer)] : []), { title: t("signTransactionDetails.operations.amount"), trailingContent: ( @@ -191,6 +221,7 @@ const RenderOperationByType = ({ trailingContent: {sendAsset.code}, titleColor: themeColors.text.secondary, }, + ...(sendAsset.issuer ? [issuerListItem(sendAsset.issuer)] : []), { title: t("signTransactionDetails.operations.sendMax"), trailingContent: ( @@ -213,6 +244,7 @@ const RenderOperationByType = ({ trailingContent: {destAsset.code}, titleColor: themeColors.text.secondary, }, + ...(destAsset.issuer ? [issuerListItem(destAsset.issuer)] : []), { title: t("signTransactionDetails.operations.destinationAmount"), trailingContent: ( @@ -240,6 +272,7 @@ const RenderOperationByType = ({ trailingContent: {sendAsset.code}, titleColor: themeColors.text.secondary, }, + ...(sendAsset.issuer ? [issuerListItem(sendAsset.issuer)] : []), { title: t("signTransactionDetails.operations.sendAmount"), trailingContent: ( @@ -262,6 +295,7 @@ const RenderOperationByType = ({ trailingContent: {destAsset.code}, titleColor: themeColors.text.secondary, }, + ...(destAsset.issuer ? [issuerListItem(destAsset.issuer)] : []), { title: t("signTransactionDetails.operations.destinationMinimum"), trailingContent: ( @@ -288,6 +322,7 @@ const RenderOperationByType = ({ trailingContent: {selling.code}, titleColor: themeColors.text.secondary, }, + ...(selling.issuer ? [issuerListItem(selling.issuer)] : []), { title: t("signTransactionDetails.operations.sellingAmount"), trailingContent: ( @@ -300,6 +335,7 @@ const RenderOperationByType = ({ trailingContent: {buying.code}, titleColor: themeColors.text.secondary, }, + ...(buying.issuer ? [issuerListItem(buying.issuer)] : []), { title: t("signTransactionDetails.operations.price"), trailingContent: ( @@ -327,6 +363,7 @@ const RenderOperationByType = ({ trailingContent: {selling.code}, titleColor: themeColors.text.secondary, }, + ...(selling.issuer ? [issuerListItem(selling.issuer)] : []), { title: t("signTransactionDetails.operations.sellingAmount"), trailingContent: ( @@ -339,6 +376,7 @@ const RenderOperationByType = ({ trailingContent: {buying.code}, titleColor: themeColors.text.secondary, }, + ...(buying.issuer ? [issuerListItem(buying.issuer)] : []), { title: t("signTransactionDetails.operations.price"), trailingContent: ( @@ -366,6 +404,7 @@ const RenderOperationByType = ({ trailingContent: {buying.code}, titleColor: themeColors.text.secondary, }, + ...(buying.issuer ? [issuerListItem(buying.issuer)] : []), { title: t("signTransactionDetails.operations.buyingAmount"), trailingContent: ( @@ -378,6 +417,7 @@ const RenderOperationByType = ({ trailingContent: {selling.code}, titleColor: themeColors.text.secondary, }, + ...(selling.issuer ? [issuerListItem(selling.issuer)] : []), { title: t("signTransactionDetails.operations.price"), trailingContent: ( @@ -404,6 +444,28 @@ const RenderOperationByType = ({ signer, } = operation; + const decodeAuthorizationFlags = (bits: number): string => { + const labels: string[] = []; + let remaining = bits; + Object.entries(authorizationMap).forEach(([bit, label]) => { + const value = Number(bit); + /* eslint-disable no-bitwise */ + if ((bits & value) !== 0) { + labels.push(label); + remaining &= ~value; + } + /* eslint-enable no-bitwise */ + }); + if (remaining !== 0) { + labels.push( + t("signTransactionDetails.operations.unknownFlags", { + bits: remaining, + }), + ); + } + return labels.join(", "); + }; + const items: ListItemProps[] = []; if (inflationDest) { @@ -423,15 +485,27 @@ const RenderOperationByType = ({ }); } - if (homeDomain) { + if (homeDomain !== undefined) { items.push({ title: t("signTransactionDetails.operations.homeDomain"), - trailingContent: {homeDomain}, + trailingContent: + homeDomain === "" ? ( + + {t("signTransactionDetails.operations.cleared")} + + ) : ( + {homeDomain} + ), titleColor: themeColors.text.secondary, }); } - if (highThreshold) { + // The SDK's XDR-optional accessors (masterWeight/thresholds/flags) + // return `null` -- not `undefined` -- when the field is absent from + // the operation, unlike homeDomain above. Guard against both so an + // ordinary setOptions that only touches one field doesn't render the + // rest as a literal "null" (or throw on `null.toString()`). + if (highThreshold !== undefined && highThreshold !== null) { items.push({ title: t("signTransactionDetails.operations.highThreshold"), trailingContent: {highThreshold.toString()}, @@ -439,7 +513,7 @@ const RenderOperationByType = ({ }); } - if (medThreshold) { + if (medThreshold !== undefined && medThreshold !== null) { items.push({ title: t("signTransactionDetails.operations.mediumThreshold"), trailingContent: {medThreshold.toString()}, @@ -447,7 +521,7 @@ const RenderOperationByType = ({ }); } - if (lowThreshold) { + if (lowThreshold !== undefined && lowThreshold !== null) { items.push({ title: t("signTransactionDetails.operations.lowThreshold"), trailingContent: {lowThreshold.toString()}, @@ -455,7 +529,7 @@ const RenderOperationByType = ({ }); } - if (masterWeight) { + if (masterWeight !== undefined && masterWeight !== null) { items.push({ title: t("signTransactionDetails.operations.masterWeight"), trailingContent: {masterWeight.toString()}, @@ -463,28 +537,23 @@ const RenderOperationByType = ({ }); } - if (setFlags) { - items.push({ - title: t("signTransactionDetails.operations.setFlags"), - trailingContent: {authorizationMap[setFlags.toString()]}, - titleColor: themeColors.text.secondary, - }); - } - - if (clearFlags) { - items.push({ - title: t("signTransactionDetails.operations.clearFlags"), - trailingContent: ( - {authorizationMap[clearFlags.toString()]} - ), - titleColor: themeColors.text.secondary, - }); - } - return ( {signer && } {items.length > 0 && } + {setFlags !== undefined && setFlags !== null && ( + + )} + {clearFlags !== undefined && clearFlags !== null && ( + + )} + {masterWeight === 0 && } ); } @@ -595,24 +664,27 @@ const RenderOperationByType = ({ } case "manageData": { const { name, value } = operation; - - const items: ListItemProps[] = [ - { - title: t("signTransactionDetails.operations.name"), - trailingContent: {name}, - titleColor: themeColors.text.secondary, - }, - ]; - - if (value) { - items.push({ - title: t("signTransactionDetails.operations.value"), - trailingContent: {value.toString()}, - titleColor: themeColors.text.secondary, - }); - } - - return ; + const isDeletingEntry = value === undefined || value === null; + return ( + + + + {t("signTransactionDetails.operations.deleted")} + + ) : ( + value.toString() + ) + } + /> + + ); } case "bumpSequence": { const { bumpTo } = operation; @@ -636,6 +708,7 @@ const RenderOperationByType = ({ trailingContent: {asset.code}, titleColor: themeColors.text.secondary, }, + ...(asset.issuer ? [issuerListItem(asset.issuer)] : []), { title: t("signTransactionDetails.operations.amount"), trailingContent: ( @@ -698,6 +771,7 @@ const RenderOperationByType = ({ trailingContent: {asset.code}, titleColor: themeColors.text.secondary, }, + ...(asset.issuer ? [issuerListItem(asset.issuer)] : []), { title: t("signTransactionDetails.operations.amount"), trailingContent: ( @@ -751,32 +825,49 @@ const RenderOperationByType = ({ trailingContent: {asset.code}, titleColor: themeColors.text.secondary, }, + ...(asset.issuer ? [issuerListItem(asset.issuer)] : []), ]; - if (flags.authorized) { + if (flags.authorized !== undefined) { items.push({ title: t("signTransactionDetails.operations.flags.authorized"), - trailingContent: {String(flags.authorized)}, + trailingContent: ( + + {flags.authorized + ? t("signTransactionDetails.operations.enabled") + : t("signTransactionDetails.operations.disabled")} + + ), titleColor: themeColors.text.secondary, }); } - if (flags.authorizedToMaintainLiabilities) { + if (flags.authorizedToMaintainLiabilities !== undefined) { items.push({ title: t( "signTransactionDetails.operations.flags.authorizedToMaintainLiabilities", ), trailingContent: ( - {String(flags.authorizedToMaintainLiabilities)} + + {flags.authorizedToMaintainLiabilities + ? t("signTransactionDetails.operations.enabled") + : t("signTransactionDetails.operations.disabled")} + ), titleColor: themeColors.text.secondary, }); } - if (flags.clawbackEnabled) { + if (flags.clawbackEnabled !== undefined) { items.push({ title: t("signTransactionDetails.operations.flags.clawbackEnabled"), - trailingContent: {String(flags.clawbackEnabled)}, + trailingContent: ( + + {flags.clawbackEnabled + ? t("signTransactionDetails.operations.enabled") + : t("signTransactionDetails.operations.disabled")} + + ), titleColor: themeColors.text.secondary, }); } @@ -961,6 +1052,11 @@ const RenderOperationByType = ({ trailingContent: {asset.code}, titleColor: themeColors.text.secondary, }); + // Disclose the issuer so the revoked trustline's asset is + // unambiguous, matching every other asset row on this screen. + if (asset.issuer) { + items.push(issuerListItem(asset.issuer)); + } } return ; diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 89df5d691..2186d189f 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -1249,7 +1249,13 @@ "executableType": "Executable Type", "accountId": "Account ID", "salt": "Salt", - "executableWasmHash": "Executable WASM Hash" + "executableWasmHash": "Executable WASM Hash", + "masterKeyWarningMessage": "This transaction disables your account's master key. You may permanently lose access to this account unless another signer with sufficient weight is added.", + "cleared": "Cleared", + "deleted": "Deleted", + "enabled": "Enabled", + "disabled": "Disabled", + "unknownFlags": "Unknown ({{bits}})" }, "claimPredicates": { "unconditional": "Unconditional", diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index 5291d6478..165476260 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -1249,7 +1249,13 @@ "executableType": "Tipo de executável", "accountId": "ID da conta", "salt": "Salt", - "executableWasmHash": "Hash WASM executável" + "executableWasmHash": "Hash WASM executável", + "masterKeyWarningMessage": "Esta transação desativa a chave mestra da sua conta. Você pode perder permanentemente o acesso a esta conta, a menos que outro assinante com peso suficiente seja adicionado.", + "cleared": "Limpo", + "deleted": "Excluído", + "enabled": "Habilitado", + "disabled": "Desabilitado", + "unknownFlags": "Desconhecido ({{bits}})" }, "claimPredicates": { "unconditional": "Incondicional",