Skip to content

fix(soroban): render signed SCMap entry on the signing screens - #3017

Merged
aristidesstaffieri merged 9 commits into
masterfrom
fix/soroban-signing-display-fidelity
Oct 20, 2026
Merged

aristidesstaffieri merged 9 commits into
masterfrom
fix/soroban-signing-display-fidelity

Conversation

@aristidesstaffieri

@aristidesstaffieri aristidesstaffieri commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Renders Soroban contract-call arguments on the signing screens from the signed SCVal rather than from a decoded native value, so the screen matches what is being signed. Three defects are fixed:

  • Map entries no longer disappear. Decoding a map to a JS object coerced every key to a string and let a later entry overwrite an earlier one, so an SCMap with N signed entries could render as one line — with no glyph and no warning. Maps now render from the signed entry list, so entry count is preserved by construction.
  • Key types stay distinguishable. u64(1) and string("1"), or symbol("a") and string("a"), previously rendered as identical keys. Quoting now carries the type: strings quoted, symbols and numbers bare, structured keys inline.
  • Binary text renders as labelled hex. SCString, SCSymbol, function names and CAP-85 executable tags are decoded strictly instead of leniently, so two distinct non-UTF-8 payloads can no longer collapse onto the same U+FFFD string.

All three hold at every nesting depth. Only a top-level argument was handled before: one level down a map collapsed, a non-UTF-8 string rendered as {"0":97,"1":108,…}, and bytes as {"type":"Buffer","data":[…]}.

Auth-entry invocation trees now carry xdr.ScVal args through to the renderer instead of decoding to natives and re-encoding, a round trip that silently re-typed map keys.

Affected surfaces: SignTransaction (Parameters and Auth Entries), SignAuthEntry, and the ReviewTransaction auth block.

Display format change — map and vector parameters render as a value literal rather than JSON:

{                          {                        {
  1: "from-u64",             a: "sym",                { id: 0 }: "v0",
  "1": "from-string"         "a": "str"               { id: 1 }: "v1"
}                          }                        }
 u64 vs string key          symbol vs string key     struct keys

Test plan

  • yarn test:ci — 1929 passing, including 24 new cases: entry-count preservation for structured and mixed-type map keys, address- and u32-keyed controls, nested containers, and strict decoding of non-UTF-8 strings, symbols, executable tags and function names
  • yarn build:extension
  • Manual: non-UTF-8 string arguments, nested containers and a non-UTF-8 function name through the signing screen
  • Manual: struct-keyed, mixed-key and symbol-vs-string map arguments
  • CI green
  • Refresh Playwright screenshot baselines covering the sign flow

@github-actions

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

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

@aristidesstaffieri aristidesstaffieri changed the title fix(soroban): better rendering of SC map/vec values fix(soroban): render signed SCMap entry on the signing screens Sep 21, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Some distinct signed values remain ambiguous or incomplete in the approval UI.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 5 High severity · 1 Medium severity

Open (6)
What changed in this PR

Improves Soroban signing-screen rendering while preserving signed XDR value types.

Changes:

  • Adds byte-safe rendering for Soroban strings, symbols, and tags.
  • Renders maps and vectors without lossy native conversion.
  • Adds helper and UI regression tests.
File Description
extension/​src/​popup/​helpers/​soroban.ts Adds type-aware Soroban value rendering.
extension/​src/​popup/​helpers/​__tests__/​soroban.test.js Tests invocation fidelity and binary names.
extension/​src/​popup/​helpers/​__tests__/​scValByType.test.js Tests container and binary-value rendering.
extension/​src/​popup/​components/​signTransaction/​Operations/​KeyVal/​index.tsx Passes original SCVals to the renderer.
extension/​src/​popup/​components/​signTransaction/​Operations/​index.tsx Uses safer function-name rendering.
extension/​src/​popup/​components/​signTransaction/​Operations/​__tests__/​index.test.tsx Tests rendered contract-call parameters.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread extension/src/popup/helpers/soroban.ts Outdated
Comment thread extension/src/popup/helpers/soroban.ts Outdated
Comment thread extension/src/popup/helpers/soroban.ts Outdated
Comment thread extension/src/popup/helpers/soroban.ts
Comment thread extension/src/popup/helpers/soroban.ts Outdated
Comment thread extension/src/popup/helpers/soroban.ts Outdated
…oNative

  scValToNative decodes SCMaps through Object.fromEntries, which coerces
  every key via ToPropertyKey and applies last-write-wins, so a map with N
  signed entries could render as one on the approval screen — with no glyph
  and no warning. Render maps and vecs from the signed entry list instead,
  as a value literal where quoting carries the key type.

  Decode SCString/SCSymbol-backed fields (arguments, function names, CAP-85
  executable tags) strictly rather than leniently, so invalid UTF-8 renders
  as labelled hex instead of collapsing distinct payloads onto one U+FFFD
  string. Carry auth-tree args through as ScVal rather than round tripping
  them through nativeToScVal, which silently re-typed map keys.
  Hovering or clicking a contract-call argument now shows the SCVal arm it
  was signed as. A symbol-keyed struct renders the same as before -- naming
  the type inline would bury the data the signer is there to read -- so the
  type is one hover away instead.

  One tooltip is shared by every value on the screen and re-anchored as the
  pointer moves, rather than one per value being torn down and rebuilt, with
  the usual open/close/skip delays so moving along a map reads as continuous.

  Both the rendered text and the clipboard string are now built from one
  token stream, so they cannot drift apart. Verified byte-identical to the
  previous rendering across every arm, except four that returned a non-string
  React drops -- a signed `false`, `void`, a ledger-key instance and a
  non-wasm contract instance each reached the screen as an empty row.

  Also sets `white-space: pre-wrap` on `.ParameterValue`, so a rendered SCMap
  actually shows one signed entry per line. The rule that was meant to do
  this sat on `.Parameter`, a class no component renders.
…lete the instance arm

  Labelled hex (`string(0x616c696365ff)`) could be spelled out by valid text
  that happened to read the same, so two distinct signed payloads still shared
  one screen string. Control and bidi codepoints also reached the screen raw,
  letting one signed string reorder into another.

  Escape the wire bytes instead: backslash, C0/C1, DEL, bidi and zero-width
  marks become escapes, invalid bytes become `\xNN`, and everything else --
  including ordinary non-ASCII -- passes through untouched. The backslash is
  itself escaped, so the escapes are prefix-free and no two byte strings can
  render alike. This is the SDK's SEP-0051 `toJson()` alphabet widened to keep
  legible text legible; `toJson()` hex-escapes every byte above ASCII and would
  render `café` as `caf\xc3\xa9`.

  The displayed name is no longer a lookup key. `KeyValueInvokeHostFnArgs` takes
  `specFnName`, the raw signed name, and skips the contract-spec lookup when the
  bytes are not text.

  SCV_CONTRACT_INSTANCE now renders its storage map and the CAP-85 external
  reference's owner and tag, so two instances sharing an executable are told
  apart, and stays on one line as a map key.
@aristidesstaffieri
aristidesstaffieri force-pushed the fix/soroban-signing-display-fidelity branch from b91cb12 to df48eb4 Compare September 28, 2026 17:21
Comment thread extension/src/popup/helpers/soroban.ts Dismissed
@aristidesstaffieri
aristidesstaffieri marked this pull request as ready for review September 28, 2026 17:40
Copilot AI review requested due to automatic review settings September 28, 2026 17:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread extension/src/popup/helpers/soroban.ts Outdated
Comment thread extension/src/popup/helpers/soroban.ts
Comment thread extension/src/popup/components/signTransaction/Operations/KeyVal/index.tsx Outdated
…is displayed

  Escape Unicode's `Default_Ignorable_Code_Point` class rather than a
  hand-picked list of ranges, keeping the C1 controls and adding
  U+2028/U+2029, which draw as nothing but insert a line break.

  Copy contract-call arguments with `scValToDisplayValue` so the clipboard
  and the approval text come from one token stream. Delete `scValByType`,
  which had no other caller, and move its `default:` arm behaviour onto
  `scValToDisplayTokens`.
Copilot AI review requested due to automatic review settings September 28, 2026 18:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The standalone tooltip fallback and documentation remain incorrect, while visual regression coverage is still pending.

Review effort: Balanced
Findings: 1 Medium severity · 2 Low severity

Open (3)
Resolved since last review (3)

Comment thread docs/docs/guide/developingForSoroban.md Outdated
  `ScValDisplay` built its tokens from the context of the render it was in,
  so wrapping them in a provider afterwards handed a value to children that
  never read it. Render the tokens directly and state the provider
  requirement on the component.

  Say in the Soroban guide what `scValToNative` does to an SCMap and to
  non-UTF-8 text, rather than that it "may not work for all SC vals".
Copilot AI review requested due to automatic review settings September 28, 2026 19:41
aristidesstaffieri added a commit to stellar/freighter-mobile that referenced this pull request Sep 28, 2026
…l type

  Ports the remaining two commits of stellar/freighter#3017.

  - Escape XdrString-backed fields to one prefix-free byte string: valid text
    containing `\xff` rendered like the byte 0xFF, and bidi overrides reached
    the screen raw, letting one signed string reorder into another.
  - Decode strict UTF-8 by hand -- RN's TextDecoder polyfill throws on the
    `fatal` option, which Jest never sees because Node implements it.
  - Render arguments from a token stream, so the drawn and copied text cannot
    drift, and let the signer tap a scalar to see the arm it was signed as.
  - scValByType returns a string on every arm; `false`, `void`, a ledger-key
    instance and a non-wasm instance each reached the screen as an empty row.
    Instances now render their storage map and the CAP-85 owner and tag.
  - Key the contract-spec lookup off the raw signed function name, not the
    escaped display form.
aristidesstaffieri added a commit to stellar/freighter-mobile that referenced this pull request Sep 28, 2026
…l type

  Ports the remaining two commits of stellar/freighter#3017.

  - Escape XdrString-backed fields to one prefix-free byte string: valid text
    containing `\xff` rendered like the byte 0xFF, and bidi overrides reached
    the screen raw, letting one signed string reorder into another.
  - Decode strict UTF-8 by hand -- RN's TextDecoder polyfill throws on the
    `fatal` option, which Jest never sees because Node implements it.
  - Render arguments from a token stream, so the drawn and copied text cannot
    drift, and let the signer tap a scalar to see the arm it was signed as.
  - scValByType returns a string on every arm; `false`, `void`, a ledger-key
    instance and a non-wasm instance each reached the screen as an empty row.
    Instances now render their storage map and the CAP-85 owner and tag.
  - Key the contract-spec lookup off the raw signed function name, not the
    escaped display form.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@aristidesstaffieri
aristidesstaffieri requested a review from a team September 28, 2026 21:22
Comment thread extension/src/popup/helpers/soroban.ts Outdated
* Unicode's own class for codepoints that are meant to render as nothing:
* soft hyphens, zero-width marks, bidi controls, variation selectors and the
* tag block. Named rather than hand-listed so the boundary is one the standard
* maintains — the previous enumeration missed 4159 codepoints, U+2060 WORD

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this comment is maybe adding something about the previous Claude prompts?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, that sentence was comparing against a hand-listed range set that only ever existed earlier on this branch, so it has no referent on master. Trimmed it to the rationale alone. I also reworded two test comments on the branch that had the same problem.

Comment thread extension/src/popup/helpers/soroban.ts
Comment thread extension/src/popup/helpers/soroban.ts Outdated
punct("owner: "),
value(scvAddressToString(ref.executableOwner), executable.type),
],
[punct("tag: "), value(xdrStringToDisplay(ref.tag), executable.type)],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An executable tag can fake extra fields, including a second owner

Merge blocker. The signing screen can show an owner address that was never signed, and a tag can look exactly like a string.

TL;DR: Executable tags are shown on the signing screen without quotes or escaping of quotes, commas, colons or brackets. A contract-instance tag of x, owner: G…ATTACKER therefore renders as a second owner: field. A tag whose bytes are "hello" renders the same as the string "hello". The mobile PR (stellar/freighter-mobile#1012) has the same code and the same problem.

Steps to reproduce:

  1. Build a contract-call argument containing a contract instance whose executable is an external reference with tag x, owner: <any G address>.
  2. Open it on the signing screen. It shows:
contractInstance {
  executable: externalRef {
    owner: GCZY5…YWOP,
    tag: x, owner: GCHOM…GWZQ
  }
}

A map keyed by executable tag "hello" and by string "hello" shows two identical "hello": keys.


Detailed explanation (for agents)

Root cause: in a value literal, both sites render the tag with xdrStringToDisplay, which escapes backslashes and control characters but not " , : { } [ ]. Strings and symbols already go through the quoting xdrStringToLiteral, but these two sites don't.

Deterministic repro: call scValToDisplayValue on scvContractInstance with contractExecutableExternalRef({ executableOwner, tag: "x, owner: G…" }), and on scvMap with keys scvExecutableTag('"hello"') and scvString("hello"). Checked at 1033063932a0a4a2722dea3e71221010dbea7c28.

Suggested fix: render both sites with xdrStringToLiteral(…, "string") so the tag is quoted, with " escaped. If the tag needs to look different from a string, use a wrapper such as tag("…"). Make the same change on mobile.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added key quoting in 78c6a04

Comment thread extension/src/popup/helpers/soroban.ts Outdated
return quoted;
};

const scvAddressToString = (address: xdr.ScAddress) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scvAddressToString duplicates addressToString

Nit, not a merge blocker. The output is identical either way.

TL;DR: The new helper does exactly what the existing one in this file already does, so there are now two address helpers to keep in step. Calling the existing one directly matches what mobile #1012 does at the same two places.


Detailed explanation (for agents)

The account branch is the same line as in addressToString. The fallback calls addressToString, which re-checks the account case and then uses Address.fromScAddress(address).toString(). addressToString is unchanged from the base branch.

  • new:
    const scvAddressToString = (address: xdr.ScAddress) => {
    if (address.type === "scAddressTypeAccount") {
    return StrKey.encodeEd25519PublicKey(address.accountId.ed25519.toBytes());
    }
    return addressToString(address);
    };
  • existing:
    export const addressToString = (address: xdr.ScAddress) => {
    if (address.type === "scAddressTypeAccount") {
    return StrKey.encodeEd25519PublicKey(address.accountId.ed25519.toBytes());
    }
    return Address.fromScAddress(address).toString();
    };

Suggested fix: delete scvAddressToString and call addressToString at the two places that use it (external-ref owner and the scvAddress arm).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, it was a straight duplicate. Deleted it and both sites call addressToString now.

@piyalbasu piyalbasu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar 2 comments as on mobile and a couple of nits, but otherwise lgtm

@aristidesstaffieri
aristidesstaffieri merged commit b1e795c into master Oct 20, 2026
11 checks passed
@aristidesstaffieri
aristidesstaffieri deleted the fix/soroban-signing-display-fidelity branch October 20, 2026 16:45
aristidesstaffieri added a commit to stellar/freighter-mobile that referenced this pull request Oct 20, 2026
* fix(soroban): render signing-screen values from the SCVal, not scValToNative

  Decoding an SCMap to a JS object coerced every key through ToPropertyKey
  and applied last-write-wins, so a map with N signed entries could render
  as one line on the approval screen. Render maps and vecs from the signed
  entry list instead, as a value literal where quoting carries the key type.

  Decode SCString, SCSymbol, executable tags and function names strictly,
  falling back to the reversible SEP-51 escape form only when the bytes are
  not valid UTF-8. Two distinct binary payloads can no longer collapse onto
  one screen string, and valid non-ASCII text stays readable instead of
  being escaped byte by byte.

* fix(soroban): escape signing-screen text and reveal each value's SCVal type

  Ports the remaining two commits of stellar/freighter#3017.

  - Escape XdrString-backed fields to one prefix-free byte string: valid text
    containing `\xff` rendered like the byte 0xFF, and bidi overrides reached
    the screen raw, letting one signed string reorder into another.
  - Decode strict UTF-8 by hand -- RN's TextDecoder polyfill throws on the
    `fatal` option, which Jest never sees because Node implements it.
  - Render arguments from a token stream, so the drawn and copied text cannot
    drift, and let the signer tap a scalar to see the arm it was signed as.
  - scValByType returns a string on every arm; `false`, `void`, a ledger-key
    instance and a non-wasm instance each reached the screen as an empty row.
    Instances now render their storage map and the CAP-85 owner and tag.
  - Key the contract-spec lookup off the raw signed function name, not the
    escaped display form.

* fix(soroban): quote executable tags on the signing screen

  Port of stellar/freighter@78c6a04. An external reference's tag is now a
  quoted string literal and a standalone SCVal tag renders as tag("…"), so
  a deployer-chosen tag can neither forge an `owner:` field nor read as a
  string with the same characters.
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.

4 participants