Skip to content

fix(sign-tx): show asset issuer for value-bearing operations#2882

Merged
piyalbasu merged 1 commit into
fix/sign-tx-show-all-operation-fieldsfrom
fix/sign-tx-asset-issuer-display
Jun 30, 2026
Merged

fix(sign-tx): show asset issuer for value-bearing operations#2882
piyalbasu merged 1 commit into
fix/sign-tx-show-all-operation-fieldsfrom
fix/sign-tx-asset-issuer-display

Conversation

@piyalbasu

Copy link
Copy Markdown
Contributor

TL;DR

On Stellar, a non-native asset is identified by both its code and its issuer — two different assets can share the same code (e.g. a real USDC and a look-alike USDC from a different issuer). The transaction signing screen showed only the asset code for operations that move or trade value (payments, path payments, DEX offers, claimable balances), so two assets with the same code looked identical at the moment you decide whether to sign.

This PR shows the issuer alongside the code for every non-native asset in those operations, so what you see matches what you sign — consistent with the trustline and path-hop screens, which already show the issuer. Native XLM has no issuer and is unchanged. Presentational only.

Stacked on #2829 (it edits the same file). Targets fix/sign-tx-show-all-operation-fields; will retarget to master once #2829 merges. Tracking: internal wallet-eng-monorepo#13.

Implementation details (for agents)

What changed — two files, presentational only; the issuer was already present on every parsed op object and is unchanged in the XDR.

  • extension/src/popup/components/signTransaction/Operations/index.tsx
    • New file-local helper KeyValueAssetIssuer({ issuer?: string }) that renders an "Asset Issuer" row using the existing copy-value pattern, or null when there is no issuer (native XLM → no row):
      flaggedKeys,
      isMemoRequired,
      }: {
      destination: string;
      flaggedKeys: FlaggedKeys;
      isMemoRequired: boolean;
      }) => {
      const flaggedTags = flaggedKeys[destination]?.tags || [];
      const isDestMemoRequired = flaggedTags.includes(
      TRANSACTION_WARNING.memoRequired,
      );
      return (
      <>
      {isMemoRequired ? (
      <MemoRequiredWarning isDestMemoRequired={isDestMemoRequired} />
    • The helper's body is the same <CopyValue value={issuer} displayValue={truncateString(issuer)} /> pattern that changeTrust already uses for its issuer row (see KeyVal's KeyValueLine), so the new rows render and copy identically to what's already shipped.
    • An issuer row is inserted after each non-native asset's code row in the seven value-bearing ops — payment, pathPaymentStrictReceive, pathPaymentStrictSend, createPassiveSellOffer, manageSellOffer, manageBuyOffer, createClaimableBalance (12 rows total; path-payment and offer ops carry two assets each). clawback, the trust-flag ops, and liquidity-pool ops are intentionally excluded (issuer's own asset, or no (code, issuer) exposed on the op).
  • extension/src/popup/components/__tests__/Operations.test.tsx — three regression tests:
    describe("value-bearing operations show the asset issuer", () => {
    const renderOp = (op: Operation) =>
    render(
    <Wrapper
    routes={[ROUTES.signTransaction]}
    state={{
    auth: {
    error: null,
    applicationState: APPLICATION_STATE.PASSWORD_CREATED,
    TEST_PUBLIC_KEY,
    allAccounts: mockAccounts,
    hasPrivateKey: true,
    },
    settings: {
    networkDetails: TESTNET_NETWORK_DETAILS,
    networksList: DEFAULT_NETWORKS,
    isSorobanPublicEnabled: true,
    isRpcHealthy: true,
    },
    }}
    >
    <Operations
    operations={[op]}
    flaggedKeys={{}}
    isMemoRequired={false}
    />
    </Wrapper>,
    );
    const valueOf = (label: string) =>
    screen
    .getByText(label)
    .parentNode?.querySelector("[data-testid='OperationKeyVal__value']");
    // Regression test for HackerOne #3768317: the signing UI must show the
    // issuer for value-bearing assets so a counterfeit USDC:<attacker> is
    // distinguishable from a non-native asset using the same code.
    it("renders the issuer for the non-native asset in a manageSellOffer", async () => {
    const op = {
    offerId: "0",
    selling: Asset.native(),
    buying: new Asset("USDC", TEST_PUBLIC_KEY),
    amount: "5000",
    price: "1",
    type: "manageSellOffer",
    } as Operation.ManageSellOffer;
    renderOp(op);
    await waitFor(() => screen.getAllByTestId("OperationKeyVal"));
    expect(valueOf("Selling")).toHaveTextContent("XLM");
    expect(valueOf("Buying")).toHaveTextContent("USDC");
    // Native XLM has no issuer, so exactly one issuer row is rendered, and it
    // carries the (truncated, copyable) issuer of the non-native buying asset.
    const issuerRows = screen.getAllByText("Asset Issuer");
    expect(issuerRows).toHaveLength(1);
    expect(valueOf("Asset Issuer")).toHaveTextContent("GBTY…JZOF");
    });
    it("renders the issuer for a payment of a non-native asset", async () => {
    const op = {
    destination: TEST_PUBLIC_KEY,
    asset: new Asset("USDC", TEST_PUBLIC_KEY),
    amount: "100",
    type: "payment",
    } as Operation.Payment;
    renderOp(op);
    await waitFor(() => screen.getAllByTestId("OperationKeyVal"));
    expect(valueOf("Asset Code")).toHaveTextContent("USDC");
    expect(valueOf("Asset Issuer")).toHaveTextContent("GBTY…JZOF");
    });
    it("does not render an issuer row for a native (XLM) payment", async () => {
    const op = {
    destination: TEST_PUBLIC_KEY,
    asset: Asset.native(),
    amount: "100",
    type: "payment",
    } as Operation.Payment;
    renderOp(op);
    await waitFor(() => screen.getAllByTestId("OperationKeyVal"));
    expect(valueOf("Asset Code")).toHaveTextContent("XLM");
    expect(screen.queryByText("Asset Issuer")).toBeNull();
    });
    });
    });
    • non-native asset in a manageSellOffer renders exactly one issuer row (native selling side renders none),
    • non-native payment renders the issuer row,
    • native (XLM) payment renders no issuer row.

Verification

  • Full Operations.test.tsx suite green (6/6, output reviewed).
  • prettier --check clean on both files.
  • No new TypeScript errors introduced (the pre-existing Operation-union diagnostics on this stack are unchanged base→head and resolve at current master).

Follow-ups / out of scope

  • Resolving (code, issuer) to a verified, human-readable label (e.g. USDC · centre.io) and warning on look-alike collisions — separate defense-in-depth work.
  • Mobile port of the same gap — tracked separately (wallet-eng-monorepo#5).
  • This branch is based on Show all operation fields in the transaction signing details view #2829's branch (older master). Two pre-existing stack conditions — a jest.config.js esModules gap and raw-tsc union-narrowing noise — are not introduced here and clear when the stack reaches master. On retarget, re-confirm the 12 insertion points still sit after the correct code rows, since Show all operation fields in the transaction signing details view #2829 restructured these case blocks.

On Stellar a non-native asset is identified by (code, issuer), not by code
alone, so an attacker-issued USDC:<attacker> and legitimate Circle USDC both
render as "USDC". The signing UI showed only the asset code for value-bearing
operations, hiding the issuer at the point the user decides whether to sign,
while changeTrust and the path-hop list already show it.

Render the issuer alongside the code for every non-native asset in payment,
path payments, DEX offers, and createClaimableBalance, reusing the existing
CopyValue/KeyValueLine pattern. Native XLM has no issuer and renders no row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@github-actions

Copy link
Copy Markdown
Contributor

PR Preview build is ready: https://github.com/stellar/freighter/releases/tag/untagged-48978532d4ae95c07bb1 (SDF collaborators only — install instructions in the release description)

@piyalbasu

Copy link
Copy Markdown
Contributor Author

Manual verification — Transaction Details now shows the asset issuer

Screenshots of the Transaction Details view (the per-operation breakdown) for every value-bearing operation, captured by driving the built extension through the playground signTransaction flow on Test Net. Each non-native asset now renders an Asset Issuer row (truncated, copyable G…) directly under its code; native XLM renders no row.

The key demonstration is #1 vs #3: same asset code USDC, two different issuers — exactly the counterfeit-vs-legitimate distinction that was invisible before this change.

Issuer legend (truncation shown in UI = first4…last4):

  • legit USDCGBPL…B6D7
  • look-alike USDCGBK2…IP4W
  • EURTGAR2…YDW6

payment — non-native vs native vs look-alike

1. Payment of non-native USDC → issuer GBPL…B6D7
payment non-native USDC

2. Payment of native XLM → no Asset Issuer row (control)
payment native XLM

3. Payment of look-alike USDC → issuer GBK2…IP4Wsame code as #1, different issuer
payment look-alike USDC

path payments — two assets, two issuer rows

4. pathPaymentStrictSend (USDC → EURT)
pathPaymentStrictSend

5. pathPaymentStrictReceive (USDC → EURT)
pathPaymentStrictReceive

offers

6. manageSellOffer — sell XLM (native, no row) / buy USDC (one issuer row)
manageSellOffer

7. manageBuyOffer (USDC / EURT)
manageBuyOffer

8. createPassiveSellOffer (USDC / EURT)
createPassiveSellOffer

claimable balance

9. createClaimableBalance of non-native USDC → issuer GBPL…B6D7
createClaimableBalance

How these were captured
  • Built the extension from this branch and drove it with Playwright through https://play.freighter.app/.../playground/signTransaction on Test Net, opening Transaction details and screenshotting the DetailsBody element (captures rows below the popup fold). Account balances and Blockaid were stubbed so the render is deterministic.
  • One transaction per value-bearing op; assets use fixed, valid issuers so the truncated display is reproducible (legend above).
  • Images are hosted on branch evidence/2882-asset-issuer (pinned by commit SHA) to keep this PR's diff clean — safe to delete after review.
  • Covers all 7 value-bearing ops (12 issuer rows): payment, pathPaymentStrictReceive, pathPaymentStrictSend, createPassiveSellOffer, manageSellOffer, manageBuyOffer, createClaimableBalance. clawback / trust-flag / liquidity-pool ops are intentionally out of scope.

@piyalbasu piyalbasu merged commit 30f381c into fix/sign-tx-show-all-operation-fields Jun 30, 2026
5 checks passed
@piyalbasu piyalbasu deleted the fix/sign-tx-asset-issuer-display branch June 30, 2026 21:28
piyalbasu added a commit that referenced this pull request Jul 13, 2026
…r rows)

Integrates the remote branch's asset-issuer work that landed after this
worktree was branched. Resolutions:
- Operations/index.tsx payment case: keep both #2882's KeyValueAssetIssuer row
  and master's amount-with-code / "Token Code" label.
- Operations.test.tsx: keep both new describe suites (mobile-parity labels and
  the asset-issuer security regression tests). The #2882 payment issuer tests
  now assert the "Token Code" label master renamed it to; the issuer row is
  unaffected by that rename.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants