Repository navigation
feat(history): show muxed contract (W) destinations - #1035
Conversation
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Cover the Protocol 30 SDK surface the wallet depends on: the SEP-23 muxed contract (W) strkey vector, ScAddress arm 5 round-trips, the CAP-87 ML-DSA cost types (86-94) and the CAP-88 millisecond StellarValue arms. All four fail against the 17.0.1 SDK. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Pin which transaction memos fold into a muxed (M...) address on stellar-sdk 17.4.0. Decimal uint64 ids fold; hex, padded, empty and out-of-range values do not. The tests run against the real SDK, with only services/backend mocked. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
fast-text-encoding, the app's global TextDecoder, throws when it is asked
for { fatal: true }. SDK 17.4.0 asks for it in its bounded-fetch adapter, so
federation addresses did not resolve and stellar.toml asset icons did not
load on device. The SDK also asks for it in XdrString.toStringStrict(), so
scValToNative returned raw bytes instead of strings for symbols and strings.
Wrap the global TextDecoder so a UTF-8 decoder built with { fatal: true }
validates its input with a WHATWG-conformant check and throws a TypeError on
malformed bytes. Every other decoder is built and used as before, and the
wrapper does nothing where TextDecoder already supports fatal, as on Node
and in Jest.
The polyfill requires fast-text-encoding itself and installs Buffer first,
as shim.js does later, so the decoder keeps decoding through Buffer.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A TextDecoder built with { fatal: true } and the default ignoreBOM now drops
one leading U+FEFF from its output, as the native decoder does.
fast-text-encoding never strips it, so a stellar.toml or federation response
that starts with a BOM failed to parse on device: smol-toml and JSON.parse
both reject the BOM.
The SDK's bounded fetch builds its decoder with the default ignoreBOM, so it
gets the stripping. XdrString.toStringStrict() and the WASM name parser pass
ignoreBOM: true and keep the BOM. A strict decoder now reports the ignoreBOM
option it honors. Non-fatal decoders are unchanged and still keep a BOM.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A TextDecoder built with { fatal: true } now reads only the window of a
DataView or non-Uint8Array typed array that byteOffset and byteLength
describe, as the native decoder does. It used to validate and decode the
whole backing buffer, as fast-text-encoding does. Every caller in the app
passes a Uint8Array, which is unchanged, so this brings the strict path in
line with the native decoder rather than changing what the app decodes.
Non-fatal decoders still hand their input straight to the base.
The UTF8_LABEL comment now says which labels the strict path treats as
UTF-8, and that the Buffer-backed base accepts only utf-8 and utf8. The
tests gain a view-window check against the native decoder, a parity check for
decode() with no input on a non-fatal decoder, and a JSDoc on the hex helper.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The decodeUtf8At comment said React Native's TextDecoder throws on fatal, which stopped being true with the strict TextDecoder polyfill. It now says why the function exists: the escape renderer needs one code point and its width at an index, which a decoder of a whole buffer cannot give. The strkey scrubbing tests add a letter before, a letter after and one more base32 character after a W key, so the leading anchor, the trailing anchor and the exact length are each pinned on their own. The memo folding tests split the non-folding memos by reason: not a canonical decimal id, and not a uint64. The SDK compatibility suite's header and top-level title now cover SDK 17 and Protocols 27 and 30. No production code changes. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
stellarSdkV15.test.ts has covered more than SDK 15 since it gained the Protocol 27 (CAP-71) block and the Protocol 30 block, so it becomes stellarSdkCompat.test.ts, with a version-free top-level describe. It also gains a block for the Horizon submission errors of the SDK 16+ HTTP client. SDK 16 replaced axios with fetch, and 16.1.0 / 17.0.0 made submitTransaction reject with TransactionFailedError or BadResponseError. The app reads those errors through isHorizonError: response.status for the 504 retry and the log-severity split, and response.data.extras.result_codes in the transaction builder and useManageTokens. Every app test built that error by hand, so nothing pinned it to the real SDK. The new tests drive the real Horizon.Server against a stubbed fetch for a 400 with result codes, a 429 without them, and a 504. If the SDK stops exposing response.data, the first two fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Android Emulator preview APK is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-c13189405d9fef601f7c |
|
iOS Simulator preview build is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-0a8979cee6068de01d8d |
Horizon reports a transfer to a muxed account (M…) like one to a muxed contract (W…): the base G… in `to` and the mux id in `destination_muxed_id`. getDebitDestination now rebuilds the M with createMuxedAccount, as the extension does, and an id that won't encode is logged and shows the G. Also pin that getAttrsFromSorobanHorizonOp decodes a fee-bumped Soroban envelope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
17.4.0 is now older than the 7-day npmMinimalAgeGate, so the temporary npmPreapprovedPackages entry is no longer needed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
Approval should wait for Protocol 30 and Mercury backend availability so iOS and Android can be validated against real Horizon and Mercury W-address balance-change and envelope records.
No open issues 🎉
What changed in this PR
Updates history rendering to support muxed contract (W…) and account (M…) destinations, while making unreadable Soroban records fall back safely instead of breaking history or token details.
Changes:
- Reconstructs muxed destinations from Horizon balance-change data for debit rows.
- Makes Soroban decoding resilient by logging failures and returning
null. - Adds coverage for muxed destinations, malformed IDs, fee-bumped transactions, generic fallback rows, and detail-sheet rendering.
| File | Description |
|---|---|
src/helpers/soroban.ts |
Adds guarded Soroban invocation decoding with graceful failure handling. |
src/helpers/assetBalanceChanges.ts |
Reconstructs W… and M… debit destinations from balance changes. |
src/components/screens/HistoryScreen/types.ts |
Adds the optional destination_muxed_id balance-change field. |
__tests__/helpers/history.test.ts |
Verifies undecodable Soroban operations are skipped during token filtering. |
__tests__/helpers/assetBalanceChanges.test.ts |
Tests muxed destination reconstruction, invalid IDs, credits, and debits. |
__tests__/components/screens/HistoryScreen/TransactionDetailsBottomSheetCustomContent.test.tsx |
Verifies W-address display without memo or mux-ID rows. |
__tests__/components/screens/HistoryScreen/mappers/soroban.test.ts |
Covers Horizon, Mercury, fee-bumped, direct, generic, and undecodable Soroban history records. |
Review effort: Lite
…eros The scrubber tests checked a W with one extra character but not one with a character missing: a 68-character run must stay untouched, since only a 69-character M or W is a key. buildMuxedContract's comment says it drops leading zeros from the id; nothing asserted it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AGENTS.md and the dependencies guide still said Node 20, but package.json's engines field asks for >= 22.12.0 and every workflow runs Node 22. Prettier, run by the pre-commit hook, also re-pads the Best Practices table in AGENTS.md, which wasn't formatted before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A missing transaction_attr is now checked and logged explicitly, and the try only wraps TransactionBuilder.fromXdr. getTokenInvocationArgs runs outside it, so a regression there surfaces instead of being logged as an unreadable record. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TL;DR
History now shows a token transfer to a muxed contract address (
W…) as sent to thatW, as the extension does in stellar/freighter#3049. Horizon reports the base contract and the mux id separately, so rows built from its balance changes now rebuild theWfrom the two. A transfer to a muxed account (M…) gets the same treatment and shows theM, where it showed the baseG. There's no separate mux-id row and no memo row.It also stops one unreadable Soroban record from breaking history. Today that row silently disappears, and a token's details screen throws while rendering. Now the record shows as a generic contract row, and token details loads.
Closes stellar/wallet-eng-monorepo#109
Changed lines
Source code is the app code under
src/. Tests are the unit tests.Screenshots and Videos
After testnet's Protocol 30 upgrade: real sends of 0.000001 XLM to
WA7Q…LLEM. A transfer's sheet shows theW.Before the upgrade, these showed that testnet history was unchanged.
Implementation details (for agents)
This PR is based on
cg-p30-sdk-bump, which is #1034 (stellar/wallet-eng-monorepo#100: SDK 17.4.0 and the sharedWhelpers), and merges after it.What changed
WorMfor display.AssetBalanceChangegainsdestination_muxed_id?: string(types.ts), the same field as the SDK's HorizonBalanceChange.getDebitDestinationhandles a debit whose id is set:buildMuxedContract(to, id)whentois a contract, andcreateMuxedAccount(to, id)when it's aG, as feat(history): show muxed contract (W) destinations freighter#3049 does. Anything else returnstoas is.CorG. A throw there would otherwise drop the whole diff.toandfrom.freighter-mobile/src/helpers/assetBalanceChanges.ts
Lines 18 to 63 in 219ee9f
freighter-mobile/src/helpers/assetBalanceChanges.ts
Lines 80 to 81 in 219ee9f
getAttrsFromSorobanHorizonOpreturnsnullfor a record it can't read, instead of throwing.nulltransaction_attris checked first. It logs"Soroban record has no transaction_attr"and returnsnull.TransactionBuilder.fromXdr) sits in atry. A decode failure logs"Failed to decode Soroban operation"and returnsnull.nullas "not a token call".getTokenInvocationArgsruns outside thetry. Nothing in it throws for an envelope that decodes, so a regression there would surface instead of being logged as a bad record.freighter-mobile/src/helpers/soroban.ts
Lines 789 to 839 in a0e22a4
No new rows, strings or components.
Where the
Wcomes fromWtransferto aWto, which SDK 17.4.0 decodes as theWtransferto aWtransaction_attr.args.to, once stellar/freighter-backend#329 is deployedW, or a transfer whose envelope can't be decodedCinasset_balance_changes[].to, plusdestination_muxed_idgetDebitDestinationA transfer to an
Mtakes the same paths. A direct transfer's row already showed theMfrom the envelope; a row built from balance changes now rebuilds it from the baseGand the id.Where each one shows:
transferDetails.to(mapper).assetDiffs[].destination(generic, default), and so does the detail sheet's counterparty (sheet).shouldShowMemo), so it stays hidden for both contract layouts with no change.Robustness fix, before and after
Before, a Soroban record the app couldn't read made
getAttrsFromSorobanHorizonOpthrow. That could be XDR newer than the app's SDK, or a record with no envelope. (A record with notransaction_attrat all still drops from History, because the row mapper reads it first; freighter-backend always sends one.)mapHistoryItemDatarejected, andHistoryItemcatches the rejection and renders nothing (catch, empty render).useMemo, so the throw escaped the render. This applied to XLM, a classic asset or a Soroban token. The path isuseGetHistoryData→getFilteredHistoryData→filterOperationsByToken→getIsSupportedSorobanOp→getAttrsFromSorobanHorizonOp.After, the function logs once per call and returns
null.Parity with stellar/freighter#3049
destination_muxed_idon the balance-change typeWfor a contractto, anMfor aGisSameAccount)to/from, unchanged; Horizon reports the base address thereWMpayeeMMWin a Horizon recordWWfrom the envelope; balance changes drive only generic contract rowsdestination_muxed_id(defensive only: Horizon always sends a decimal u64)CorGand keeps the row and its amountnull, reported to Sentry; the guard wraps the envelope decode and the argument readnull, logged (sent to Sentry in release builds); the guard wraps only the envelope decode, and a missingtransaction_attris checked before Mercury's pre-decoded shape is readFeeBumpTransactionreadsoperationsthrough to the inner transaction; now pinned by a testCorrections to the issue
Wcarries the id, and the memo row stays hidden, as forM. This matches feat(history): show muxed contract (W) destinations freighter#3049./account-history. There's no v2 or wallet-backend history path that would readtoMuxedId.mappers/payment.test.ts. That suite covers classic payments only.helpers.test.tsalready covers the memo rule for contract rows, so it gets no new case.Gaccounts, so there's noCaccount to attribute credits to. Matching stays on the raw addresses, unchanged.Tests added (+36 tests, +1 suite)
mappers/soroban.test.ts:getAttrsFromSorobanHorizonOp.nullwithout logging for a call that isn't a token transfer or mint.nulland logs exactly once for four unreadable shapes: XDR that doesn't decode and no envelope (as a decode failure), and notransaction_attrand anulltransaction_attr(as a missingtransaction_attr).nullwithout logging for a non-Soroban record in Mercury's classic shape (no envelope), so classic operations in a token-filtered history are never logged.mapHistoryItemData:Wis a debit row whose recipient is theW, from both the Horizon envelope and Mercury'sargs.to.Wfrom the balance change.Mshows theMon its generic row.helpers/assetBalanceChanges.test.ts:"0","1234"and u64 max build theW.C."","abc","-1","0x10", 2^64) keep the change and show theC.G, id"1234"builds theM, and no id shows theG. The same five malformed ids keep the change and show theG; an invalid id logs once, an empty one doesn't.Cor aG.helpers/history.test.ts: the token filter skips an undecodable Soroban record instead of throwing.TransactionDetailsBottomSheetCustomContent.test.tsx: the sheet showsWA7Q...LLEM, no memo row and no separate mux-id row, in both the balance-change and the transfer layout.The tests fail without the changes they cover:
tomakes the generic-row cases receive the baseCorG.null.trymakes it reject with the XDR error.transaction_attrcheck makes both missing-transaction_attrcases throw aTypeError.Verification
yarn testata0e22a4c: 335 suites passed, 1 skipped; 4867 tests passed, 6 skipped.yarn lint:ts: clean.yarn checkstill exits 1 on lint debt already onmain: ESLint has 111 errors in 18 files and Prettier flags 9 files, none of them touched here.yarn lint:translations: no missing translations; no strings were added.219ee9f0(theMrebuild, which today's data can't reach: the account has noMpayee), on an iPhone 17 simulator (iOS 26.5) and a Pixel 10 Pro emulator (API 37).CCCG…KHKN(a native SACtransfer). For a generic contract row, two throwaway accounts made a native SACtransfer_fromthat credited the account 0.5 XLM.CCCG...KHKN, with no memo row.destination_muxed_id, and every envelope decodes. So these screens are whatmainshows.Wcheck, 2026-11-03, after the Protocol 30 upgrade, on a dev build of this branch at61045c3f, on a Pixel 10 Pro emulator (API 37), with backend v1 pointed at its staging environment (a local, uncommitted change to the app's STG URL). Backend v1's staging is private and only reachable locally through sshuttle; it was the only deployment with feat: protocol-30 muxed contract (W) address support freighter-backend#329 that day. Backend v1 has no public staging, so Always require memo on error + UI tweaks #329 goes public when it reaches production.WA7QYNF7…E2LLEM(baseCA7QYNF7…UWDA, mux id 1234), made from the app on feat(send): support muxed contract (W) destinations #1036's branch, show in History as "XLM · Sent · -0.000001 XLM", and each sheet shows 0.000001 XLM toWA7Q…LLEM.Wcomes from the record itself (the table's first two rows), andgetDebitDestinationdoesn't run on them. Horizon serves their balance changes as the base contract plusdestination_muxed_id: "1234", the shape it rebuilds.Follow-ups / out of scope
Wonce feat: protocol-30 muxed contract (W) address support freighter-backend#329 reaches production. On 2026-11-03 it was only on backend v1's staging, which is private; backend v1 has no public staging.Win Mercury mode shows as a generic contract row without theW, as it does today.🤖 Generated with Claude Code