diff --git a/extension/src/popup/components/signTransaction/Operations/__tests__/index.test.tsx b/extension/src/popup/components/signTransaction/Operations/__tests__/index.test.tsx new file mode 100644 index 0000000000..e8090d12a7 --- /dev/null +++ b/extension/src/popup/components/signTransaction/Operations/__tests__/index.test.tsx @@ -0,0 +1,230 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { Provider } from "react-redux"; +import { + Account, + Asset, + BASE_FEE, + Networks, + Operation, + StrKey, + TransactionBuilder, + xdr, +} from "stellar-sdk"; + +import { makeDummyStore } from "popup/__testHelpers__"; +import { Operations } from "../index"; + +// setOptions never triggers the asset scanner, but mock it so the component's +// effect can never reach the network in the test environment. +jest.mock("popup/helpers/blockaid", () => ({ + scanAsset: jest.fn().mockResolvedValue(undefined), +})); + +// Build a setOptions transaction with the bundled SDK, serialize it to XDR and +// decode it back — the exact path Freighter uses to obtain the operation object +// it renders on the signing-approval screen. A present-but-zero Uint32 field +// (e.g. masterWeight: 0) decodes to the JS number 0. +// Valid ed25519 strkeys minted from fixed bytes — avoids curve math (and the +// crypto RNG, which is unavailable in this test environment). +const SOURCE_KEY = StrKey.encodeEd25519PublicKey(Buffer.alloc(32, 1)); +const ADDED_SIGNER = StrKey.encodeEd25519PublicKey(Buffer.alloc(32, 7)); + +type SetOptionsOptions = Parameters[0]; + +const decodeOperation = (operation: xdr.Operation) => { + const account = new Account(SOURCE_KEY, "0"); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: Networks.TESTNET, + }) + .addOperation(operation) + .setTimeout(0) + .build(); + + return TransactionBuilder.fromXDR(tx.toXDR(), Networks.TESTNET) + .operations as Operation[]; +}; + +const decodeSetOptions = (options: SetOptionsOptions) => + decodeOperation(Operation.setOptions(options)); + +const renderOps = (operations: Operation[]) => + render( + + + , + ); + +// Read the value rendered next to a given operation-detail label. +const rowValue = (key: string) => { + const keyEl = screen + .getAllByTestId("OperationKeyVal__key") + .find((el) => el.textContent === key); + return keyEl?.parentElement + ?.querySelector('[data-testid="OperationKeyVal__value"]') + ?.textContent?.trim(); +}; + +const MASTER_KEY_WARNING = /disables your account's master key/i; + +describe("Operations — setOptions field visibility", () => { + it("decoder yields numeric 0 (falsy) for masterWeight/thresholds", () => { + const [op] = decodeSetOptions({ + masterWeight: 0, + lowThreshold: 0, + medThreshold: 0, + highThreshold: 0, + }) as any[]; + + expect(op.masterWeight).toBe(0); + expect(op.lowThreshold).toBe(0); + expect(op.medThreshold).toBe(0); + expect(op.highThreshold).toBe(0); + }); + + it("renders masterWeight 0, zeroed thresholds, and a signer change, with a master-key warning", () => { + renderOps( + decodeSetOptions({ + masterWeight: 0, + lowThreshold: 0, + medThreshold: 0, + highThreshold: 0, + signer: { ed25519PublicKey: ADDED_SIGNER, weight: 1 }, + }), + ); + + expect(screen.getByText("Set Options")).toBeInTheDocument(); + expect(screen.getByText("Signer")).toBeInTheDocument(); + expect(rowValue("Master Weight")).toBe("0"); + expect(rowValue("High Threshold")).toBe("0"); + expect(rowValue("Medium Threshold")).toBe("0"); + expect(rowValue("Low Threshold")).toBe("0"); + expect(screen.getByText(MASTER_KEY_WARNING)).toBeInTheDocument(); + }); + + it("renders masterWeight 0 on its own with the warning, never an empty operation", () => { + renderOps(decodeSetOptions({ masterWeight: 0 })); + + expect(rowValue("Master Weight")).toBe("0"); + expect(screen.getByText(MASTER_KEY_WARNING)).toBeInTheDocument(); + }); + + it("non-zero masterWeight/threshold render and do not warn", () => { + renderOps(decodeSetOptions({ masterWeight: 2, highThreshold: 3 })); + + expect(rowValue("Master Weight")).toBe("2"); + expect(rowValue("High Threshold")).toBe("3"); + expect(screen.queryByText(MASTER_KEY_WARNING)).not.toBeInTheDocument(); + }); + + it("HOME DOMAIN: clearing the home domain is surfaced, not hidden", () => { + renderOps(decodeSetOptions({ homeDomain: "" })); + + expect(rowValue("Home Domain")).toBe("(clearing home domain)"); + }); + + it("FLAGS: a single-bit setFlags decodes to its label", () => { + renderOps(decodeSetOptions({ setFlags: 1 })); + + expect(rowValue("Set Flags")).toBe("Authorization Required"); + }); + + it("FLAGS: a combined setFlags bitmask decodes every set bit, not a blank value", () => { + // REVOCABLE (2) | CLAWBACK (8) = 10. The SDK types setFlags as a single + // AuthFlag, but the wire format is a bitmask — cast to exercise that. + renderOps(decodeSetOptions({ setFlags: 10 as any })); + + expect(rowValue("Set Flags")).toBe( + "Authorization Revocable, Authorization Clawback Enabled", + ); + }); + + it("FLAGS: a known bit combined with an unrecognized bit surfaces both", () => { + // REQUIRED (1) | future-bit (16) = 17 — the unknown bit must not be hidden. + renderOps(decodeSetOptions({ setFlags: 17 as any })); + + // The mocked t() does not interpolate, so {{bits}} stays literal here. + expect(rowValue("Set Flags")).toBe( + "Authorization Required, Unknown ({{bits}})", + ); + }); + + it("FLAGS: a combined clearFlags bitmask decodes every set bit", () => { + // REQUIRED (1) | REVOCABLE (2) = 3 + renderOps(decodeSetOptions({ clearFlags: 3 as any })); + + expect(rowValue("Clear Flags")).toBe( + "Authorization Required, Authorization Revocable", + ); + }); +}); + +describe("Operations — manageData value visibility", () => { + it("renders a set value", () => { + renderOps( + decodeOperation(Operation.manageData({ name: "k", value: "hi" })), + ); + + expect(rowValue("Value")).toBe("hi"); + }); + + it("renders the Value row for an empty value rather than hiding it", () => { + renderOps(decodeOperation(Operation.manageData({ name: "k", value: "" }))); + + expect(screen.getByText("Value")).toBeInTheDocument(); + expect(rowValue("Value")).toBe(""); + }); + + it("surfaces a deletion when value is absent ", () => { + renderOps( + decodeOperation(Operation.manageData({ name: "k", value: null })), + ); + + expect(rowValue("Value")).toBe("(deleting entry)"); + }); +}); + +describe("Operations — setTrustLineFlags visibility", () => { + const TRUSTOR = StrKey.encodeEd25519PublicKey(Buffer.alloc(32, 3)); + const ASSET = new Asset( + "USDC", + StrKey.encodeEd25519PublicKey(Buffer.alloc(32, 9)), + ); + + const decodeSetTrustLineFlags = (flags: { + authorized?: boolean; + authorizedToMaintainLiabilities?: boolean; + clawbackEnabled?: boolean; + }) => + decodeOperation( + Operation.setTrustLineFlags({ trustor: TRUSTOR, asset: ASSET, flags }), + ); + + it("renders a flag being enabled", () => { + renderOps(decodeSetTrustLineFlags({ authorized: true })); + + expect(rowValue("Authorized")).toBe("Enabled"); + }); + + it("renders a flag being cleared (set to false), not hidden", () => { + renderOps(decodeSetTrustLineFlags({ authorized: false })); + + expect(rowValue("Authorized")).toBe("Disabled"); + }); + + it("does not render a flag that is left unchanged", () => { + renderOps(decodeSetTrustLineFlags({ clawbackEnabled: false })); + + expect( + screen + .getAllByTestId("OperationKeyVal__key") + .some((el) => el.textContent === "Authorized"), + ).toBe(false); + expect(rowValue("Clawback Enabled")).toBe("Disabled"); + }); +}); diff --git a/extension/src/popup/components/signTransaction/Operations/index.tsx b/extension/src/popup/components/signTransaction/Operations/index.tsx index 57d55dde33..96f1cc1495 100644 --- a/extension/src/popup/components/signTransaction/Operations/index.tsx +++ b/extension/src/popup/components/signTransaction/Operations/index.tsx @@ -52,6 +52,27 @@ const MemoRequiredWarning = ({ ) : null; }; +const MasterKeyDisableWarning = () => { + const { t } = useTranslation(); + + // Rendered as a full-width banner (not a KeyValueList row) so the message + // wraps instead of being truncated in the right-aligned value column. + return ( +
+
+ ); +}; + const DestinationWarning = ({ destination, flaggedKeys, @@ -96,6 +117,27 @@ export const Operations = ({ "8": "Authorization Clawback Enabled", }; + // Account flags are a bitmask, so a combined value (e.g. REVOCABLE | + // CLAWBACK = 10) is not a key in AuthorizationMapToDisplay. Decode each known + // bit individually so combined flags are never rendered as a blank value, and + // surface any remaining (unrecognized) bits so a future protocol flag isn't + // silently hidden when combined with a known one. + const decodeAuthorizationFlags = (bits: number) => { + const labels: string[] = []; + let remaining = bits; + Object.entries(AuthorizationMapToDisplay).forEach(([bit, label]) => { + const value = Number(bit); + if ((bits & value) !== 0) { + labels.push(t(label)); + remaining &= ~value; + } + }); + if (remaining !== 0) { + labels.push(t("Unknown ({{bits}})", { bits: remaining })); + } + return labels.join(", "); + }; + const RenderOpByType = ({ op }: { op: Operation }) => { const networkDetails = useSelector(settingsNetworkDetailsSelector); @@ -337,48 +379,49 @@ export const Operations = ({ operationValue={inflationDest} /> )} - {homeDomain && ( + {homeDomain !== undefined && ( )} - {highThreshold && ( + {highThreshold !== undefined && ( )} - {medThreshold && ( + {medThreshold !== undefined && ( )} - {lowThreshold && ( + {lowThreshold !== undefined && ( )} - {masterWeight && ( + {masterWeight !== undefined && ( )} - {setFlags && ( + {masterWeight === 0 && } + {setFlags !== undefined && ( )} - {clearFlags && ( + {clearFlags !== undefined && ( )} @@ -435,15 +478,19 @@ export const Operations = ({ case "manageData": { const { name, value } = op; + // A null/undefined value means the data entry is being deleted; an + // empty value decodes to a zero-length buffer. Always render the row so + // a deletion is never silently hidden from the approval screen. + const isDeletingEntry = value === undefined || value === null; return ( <> - {value && ( - - )} + ); } @@ -537,22 +584,34 @@ export const Operations = ({ operationKey={t("Asset Code")} operationValue={asset.code} /> - {flags.authorized && ( + {/* + A flag present in the decoded `flags` object is being changed: + `true` enables it, `false` *clears* it. Use a presence check so a + cleared flag is never hidden, and render the value explicitly — a + raw boolean is not rendered by React. + */} + {flags.authorized !== undefined && ( )} - {flags.authorizedToMaintainLiabilities && ( + {flags.authorizedToMaintainLiabilities !== undefined && ( )} - {flags.clawbackEnabled && ( + {flags.clawbackEnabled !== undefined && ( )} diff --git a/extension/src/popup/components/signTransaction/Operations/styles.scss b/extension/src/popup/components/signTransaction/Operations/styles.scss index 63c4d71a3a..efdb7d595a 100644 --- a/extension/src/popup/components/signTransaction/Operations/styles.scss +++ b/extension/src/popup/components/signTransaction/Operations/styles.scss @@ -135,4 +135,30 @@ flex-direction: column; } } + + &__warning { + display: flex; + align-items: flex-start; + gap: pxToRem(8px); + margin-top: pxToRem(4px); + padding: pxToRem(12px); + border-radius: pxToRem(12px); + line-height: 1.5rem; + color: var(--sds-clr-red-11); + background-color: var(--sds-clr-red-03); + border: 1px solid var(--sds-clr-red-06); + + svg { + flex-shrink: 0; + width: pxToRem(16px); + height: pxToRem(16px); + margin-top: pxToRem(4px); + } + + span { + min-width: 0; + white-space: normal; + overflow-wrap: anywhere; + } + } } diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 9dad7219f6..f3a09ce425 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -1,4 +1,6 @@ { + "(clearing home domain)": "(clearing home domain)", + "(deleting entry)": "(deleting entry)", "{{domain}} is not currently connected to Freighter": "{{domain}} is not currently connected to Freighter", "* All Stellar accounts must maintain a minimum balance of lumens.": "* All Stellar accounts must maintain a minimum balance of lumens.", "* payment methods may vary based on your location": "* payment methods may vary based on your location", @@ -74,6 +76,10 @@ "Asset not found": "Asset not found", "asset options": "asset options", "At the end of this process, Freighter will only display accounts related to the new backup phrase.": "At the end of this process, Freighter will only display accounts related to the new backup phrase.", + "Authorization Clawback Enabled": "Authorization Clawback Enabled", + "Authorization Immutable": "Authorization Immutable", + "Authorization Required": "Authorization Required", + "Authorization Revocable": "Authorization Revocable", "Authorizations": "Authorizations", "Authorize": "Authorize", "available": "available", @@ -621,6 +627,7 @@ "This site was flagged as malicious": "This site was flagged as malicious", "This token does not support muxed address (M-) as a target destination.": "This token does not support muxed address (M-) as a target destination.", "This transaction could not be completed.": "This transaction could not be completed.", + "This transaction disables your account's master key. You may permanently lose access to this account unless another signer with sufficient weight is added.": "This transaction disables your account's master key. You may permanently lose access to this account unless another signer with sufficient weight is added.", "This transaction does not appear safe for the following reasons": "This transaction does not appear safe for the following reasons", "This transaction does not appear safe for the following reasons.": "This transaction does not appear safe for the following reasons.", "This transaction is expected to fail": "This transaction is expected to fail", @@ -677,6 +684,7 @@ "Unable to scan transaction": "Unable to scan transaction", "Unable to sign out": "Unable to sign out", "Unexpected Error": "Unexpected Error", + "Unknown ({{bits}})": "Unknown ({{bits}})", "Unknown error occured": "Unknown error occured", "Unlock": "Unlock", "Unsupported signing method": "Unsupported signing method", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 90dd818068..9d3608b0de 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -1,4 +1,6 @@ { + "(clearing home domain)": "(limpando o Domínio Principal)", + "(deleting entry)": "(excluindo entrada)", "{{domain}} is not currently connected to Freighter": "{{domain}} não está atualmente conectado ao Freighter", "* All Stellar accounts must maintain a minimum balance of lumens.": "* Todas as contas Stellar devem manter um saldo mínimo de lumens.", "* payment methods may vary based on your location": "* Os métodos de pagamento podem variar com base na sua localização", @@ -74,6 +76,10 @@ "Asset not found": "Ativo não encontrado", "asset options": "opções de ativo", "At the end of this process, Freighter will only display accounts related to the new backup phrase.": "No final deste processo, o Freighter exibirá apenas contas relacionadas à nova frase de backup.", + "Authorization Clawback Enabled": "Recuperação de Autorização Habilitada (Clawback)", + "Authorization Immutable": "Autorização Imutável", + "Authorization Required": "Autorização Obrigatória", + "Authorization Revocable": "Autorização Revogável", "Authorizations": "Autorizações", "Authorize": "Autorizar", "available": "disponível", @@ -621,6 +627,7 @@ "This site was flagged as malicious": "Este site foi marcado como malicioso", "This token does not support muxed address (M-) as a target destination.": "Este token não suporta endereço muxed (M-) como destino.", "This transaction could not be completed.": "Esta transação não pôde ser concluída.", + "This transaction disables your account's master key. You may permanently lose access to this account unless another signer with sufficient weight is added.": "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.", "This transaction does not appear safe for the following reasons": "Esta transação não parece segura pelos seguintes motivos", "This transaction does not appear safe for the following reasons.": "Esta transação não parece segura pelos seguintes motivos.", "This transaction is expected to fail": "Esta transação deve falhar", @@ -677,6 +684,7 @@ "Unable to scan transaction": "Não foi possível verificar a transação", "Unable to sign out": "Não foi possível sair", "Unexpected Error": "Erro Inesperado", + "Unknown ({{bits}})": "Desconhecido ({{bits}})", "Unknown error occured": "Erro desconhecido ocorreu", "Unlock": "Desbloquear", "Unsupported signing method": "Método de assinatura não suportado",