Repository navigation
feat(send): support muxed contract (W) destinations - #1036
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>
A W is now a valid send destination. isValidStellarAddress accepts it, isSorobanTransaction treats a send to it as Soroban, getMemoDisabledState always disables the memo for it (the mux id is in the address), and determineMuxedDestination returns it unchanged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A W now goes through the token's SAC transfer, never Operation.payment, and is simulated like a C. buildPaymentTransaction and the transaction builder duck treat it as a contract destination, and getSacContractIdForBalance names the SAC that moves a balance. A W refuses a custom token (new muxedContractNotSupported copy), a memo that would otherwise be dropped, and a W whose base C is the token contract. buildSendCollectibleTransaction refuses a W before any network call. The duck simulates a W with an empty memo. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A W is now a contract destination in the recipient flow, exactly like a C. searchAddress and setDestinationAddress never call getAccount for a C or a W and record isDestinationFunded: false, the value a C already got because looking up a contract address finds no account. buildUnfundedContext flags a W as a contract destination, so the unfunded-destination warning and the unfunded notice stay hidden, and the processing screen treats a W as a contract recipient. The Blockaid helper's logic is unchanged, only its comments now name W. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A muxed contract (W) address carries its mux id, so a W transfer never carries a memo. TransactionSettingsBottomSheet now counts a W as a Soroban recipient, which shows the inclusion-fee label, and resolves a W send's contract to the selected asset's SAC instead of the recipient. A collectible's collection and a custom token's own contract still win, and a balance with no SAC leaves the contract unresolved rather than crashing the sheet. The memo field stays disabled, with its message, through getMemoDisabledState. SendReviewBottomSheet and TransactionDetailsBottomSheet hide the memo row for a W as they do for an M, even when a memo is still stored. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A muxed contract (W) address is only sent through an asset's Stellar Asset Contract, so a custom token or a collectible sent to one is blocked. The builder refuses these sends as a fail-closed backstop, but a refused build never reaches the review sheet where the M warning lives, so the block sits on the screen itself. TransactionAmountScreen (a custom token to a W) and SendCollectibleReview (a collectible to a W) show a "Muxed address not supported" banner above Continue/Review and disable the button. prepareTransaction returns before it builds, so the auto-simulation, a settings change and Continue all build nothing. Tapping the banner opens the explanation sheet. Native XLM and classic assets still go to a W, and the M flow is unchanged. MuxedAddressWarningBottomSheet takes a variant. "account", the default, keeps the M copy, and "contract" explains the W with the new transactionAmountScreen.errors.muxedContractWarning.description key, added in en and pt. The sheet's close button gets a testID. TransactionAmountScreen also clears a stale memo for a W recipient, as it already did for an M. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Testnet can't simulate a send to a muxed contract (W) address until it runs
Protocol 30 and the backend ships freighter-backend#329. A DEV-only debug
store override, stubMuxedContractSimulation, makes buildTransaction skip both
simulate endpoints when the final destination is a W, so the review sheet
opens with the device-built XDR, which can't be submitted. A C or G
destination still simulates, and the debug store's prod path keeps the stub
off. Home's debug sheet toggles it ("W Address Simulation Stub", en and pt).
Two Maestro flows run on testnet:
- SendXlmToMuxedContract turns the stub on and sends XLM to a W up to the
review sheet. It checks the memo is disabled in Settings and absent from
the review, then cancels. It never confirms.
- SendCustomTokenToMuxedContractBlocked adds a SEP-41 token and checks that
sending it to a W is blocked: the banner, a disabled Continue, the
explanation sheet, and no build.
They need testIDs: send-settings-button (useRightHeaderButton now takes a
testID), add-token-search-input, debug-stub-muxed-contract-simulation and
debug-sheet-close-button. On iOS, gorhom makes each sheet one accessibility
element by default, which hides its contents from XCUITest and VoiceOver.
So the debug, transaction settings, muxed address warning and add token
sheets pass accessible: false, as the review sheet already does.
ImportFundedWallet retries its "Allow Paste" tap, which the iOS 26.5 paste
prompt sometimes ignores right after it appears.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On an iPhone SE with the keyboard up, the amount screen's "Muxed address not supported" banner covered the percentage buttons. Nothing can be sent while a custom token goes to a muxed contract (W) address, so the buttons are hidden then. XLM to a W, and a custom token to a G or a C, keep them. The collectible send screen doesn't scroll, so on the SE the W banner pushed Review past the fold. While a W is the recipient, the collectible image shrinks from 240 to 160 pt on small screens (max-xs), and the banner and the disabled Review button both fit. Other recipients keep the layout. waitForKeyboardDismiss takes an optional timeoutMs. With it, the wait also ends once that much time has passed without a hide event, and the listener and the timer are released on either path. On Android it races keyboard-controller's dismiss against the same timeout. Without it, nothing changes. The amount screen's W banner passes 500 ms, so its explanation sheet can't stay shut if the hide event never comes. The stub comments now say what each build does: a prod-bundle build has the no-op debug store, so the stub is always off there, and a dev-bundle release build keeps any value an earlier debug build persisted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SendCustomTokenToMuxedContractBlocked's checks couldn't fail. Continue was checked with no amount, when it's disabled anyway, and the build-error check came last, long after any 5-second toast was gone. assertNotVisible also waits for a visible element to go away, so it can't catch a toast. The flow now types an amount within the account's balance (1) and checks that Continue stays disabled. Right after arriving, and again after typing, it waits a few seconds for a build or simulation error toast and fails if one shows up. It matches the texts those toasts carry: transactionService's "Failed to build payment transaction: ..." (here the reason would be transaction.errors.muxedContractNotSupported), the builder's "Failed to build transaction" and "Simulation returned no prepared transaction XDR", and the API client's "Request failed with status code ...". It waits for the screen to settle before tapping the banner, and the bounded retry is documented as a test-timing mitigation. The header now says the account must hold the custom token. run-e2e-tests.sh loads E2E_TEST_CUSTOM_TOKEN_CONTRACT and E2E_TEST_CUSTOM_TOKEN_CODE from .env, like the recovery phrases, and passes them to Maestro. .env.example, the e2e docs and the README's flow table document them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Android the open keyboard covers the bottom of the amount screen, where the "Muxed address not supported" banner and Continue sit. It covers Continue on any send, which is why the other send flows hide it before tapping Review. SendCustomTokenToMuxedContractBlocked asserted the banner, and then the disabled Continue, while the keyboard was still up, so on a Pixel emulator it failed at the banner check. The flow now hides the keyboard on Android before each of those checks. iOS runs the same steps as before, with the keyboard up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The collectible send screen doesn't scroll. With a muxed contract (W) recipient, the 160 pt image from the earlier fix still left no room on a 360x640 dp Android screen: the banner touched the details list, and Review was pushed to the screen edge, half under the navigation handle. On an iPhone SE the list and the banner touched too. While a W is the recipient, small screens (max-xs) now hide the image section instead of shrinking it. The details list still names the collectible. The banner and the disabled Review button sit at the bottom with room above them: on that Android screen Review ends at 581 dp of 640, and on the SE at 631 pt of 667. Wider screens, and G, M and C recipients, keep the 240 pt image and the layout they had before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The W simulation stub checked only the debug store's flag. A PR preview is a dev-bundle release build: __DEV__ is false, but it gets the persisted debug store. Installed over a debug build, it could inherit the stub turned on, with no debug sheet to turn it off, and skip the simulation of every W send. The builder now checks __DEV__ before it reads the flag, so the stub only ever applies in a debug build. A release build simulates a W as normal, whatever the store holds. The comments in the builder and the debug store say so, and a new test runs a W build with __DEV__ false and the flag on and expects the normal simulation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SendXlmToMuxedContract checked that no "Memo" text was on the review
sheet. On iOS each review row is one accessible element, whose label
joins its texts ("Memo, None"), and Maestro's text is a full-match
regex, so "Memo" matched nothing even when the row was there. With a
local change that showed the memo row for a W, the flow still passed.
It now checks "Memo.*", after a "Fee.*" check that shows the same kind
of pattern matches a row that is there. With the same local change, the
flow now fails at the memo step, and it passes without it.
SendCustomTokenToMuxedContractBlocked types 1 of the token and expects
Continue to stay disabled because of the W. If the account held less
than that, the amount error would disable Continue too, and the check
would pass for the wrong reason. The error check after the amount now
also fails on the amount screen's two amount errors, "The amount entered
is greater than the available balance" and "XLM insufficient for
transaction fees". On an iPhone SE simulator, typing 1000 of a token the
account holds 25 of makes that check fail on the first toast.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Accessibility: the collectible screen's muxed address warning, the sheet
the W banner opens, now sets accessible={false} on its modal, as its
twin on the amount screen does. gorhom otherwise makes the whole sheet
one accessibility element, which hides the explanation from VoiceOver.
Tests:
- TransactionProcessingScreen: a W (and a C) recipient with nothing
submitting and no result shows "unsupported"; a G stays on "sending".
Device runs never reach that branch, so it had no coverage.
- The amount screen keeps a stored memo for a G or a C recipient, so a
change that cleared every recipient's memo would now fail the suite.
- The builder keeps the memo when it simulates a custom token sent to a G.
- The settings sheet: Save after a W clears the memo doesn't save the old
one, and the memo check is never handed the W as the contract.
- The review sheet's memo-row tests serve their recipient on every render.
- The collectible banner test gives each sheet its own present() and
checks that the one it opened is the W warning.
- JSDoc on the amount screen tests' routeFor and isContinueDisabled.
Wording, no behaviour change:
- determineMuxedDestination: an M with a memo is kept as is; the re-mux
branch is never reached for a real M.
- getSacContractIdForBalance's error now says a custom token is sent
through its own contract, not a derived SAC.
- buildPaymentTransaction returns the final destination on both paths,
and buildTransaction stores a result only for the latest request.
- isDestinationFunded documents that false also means a contract
destination, and setDestinationAddress reads the network only when it
looks up an account.
- Comments and test titles state the rule instead of a design-spec
decision id.
Co-Authored-By: Claude Opus 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-f08ddf3a12a93b1443ed |
|
iOS Simulator preview build is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-96e6e2e57b2fe13f9a05 |
A custom token whose contract doesn't support muxed addresses can't go to an M. The amount screen now blocks that send the way it blocks a custom token to a W, as the extension does: the "Muxed address not supported" banner with the M explanation, Continue disabled, and nothing built or simulated. Before, the screen built the transaction and only the review sheet showed the warning. The M check reads the contract's spec, so it answers after the screen renders. Until it does, nothing is built and Continue stays off. Each check starts from "unknown", and a late answer for a token or recipient the user has since changed is ignored, so a stale answer can't show or hide the banner. The review sheet keeps its M warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The collectible screen now blocks an M whose collection doesn't support muxed addresses the way the amount screen does since the previous commit, and the way it already blocked a W: the "Muxed address not supported" banner with the M explanation, Review disabled, nothing built or simulated, and the image hidden on small screens so the banner and Review fit. Before, the screen built the transfer and only the review sheet warned. As on the amount screen, nothing is built and Review stays off until the collection check answers, and each check starts from "unknown". The review sheet keeps its M warning. 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.
🟡 Changes recommended
An active finding remains because custom-token W sends can still trigger an unnecessary contract lookup and backend request, introducing avoidable failure or race behavior while the screen is disabled.
1 open issue
What changed in this PR
Adds support for sending native XLM and classic assets to muxed contract (W…) destinations through Stellar Asset Contract transfers, while blocking unsupported custom-token and collectible sends.
Changes:
- Accepts, validates, routes, and displays
W…destinations without account lookups or memo handling. - Adds SAC resolution, transaction-builder simulation behavior, UI blocking states, warnings, and debug simulation controls.
- Expands unit, integration, and Maestro coverage for
W…and unsupportedM…flows.
| File | Description |
|---|---|
| src/services/transactionService.ts | Updated as part of this pull request. |
| src/services/blockaid/helper.ts | Updated as part of this pull request. |
| src/i18n/locales/pt/translations.json | Updated as part of this pull request. |
| src/i18n/locales/en/translations.json | Updated as part of this pull request. |
| src/hooks/useRightHeader.tsx | Updated as part of this pull request. |
| src/helpers/stellar.ts | Updated as part of this pull request. |
| src/helpers/soroban.ts | Updated as part of this pull request. |
| src/helpers/qrValidation.ts | Updated as part of this pull request. |
| src/helpers/muxedAddress.ts | Updated as part of this pull request. |
| src/helpers/keyboard.ts | Updated as part of this pull request. |
| src/ducks/transactionBuilder.ts | Updated as part of this pull request. |
| src/ducks/sendRecipient.ts | Updated as part of this pull request. |
| src/ducks/debug.ts | Updated as part of this pull request. |
| src/components/TransactionSettingsBottomSheet.tsx | Updated as part of this pull request. |
| src/components/TransactionDetailsBottomSheet.tsx | Updated as part of this pull request. |
| src/components/screens/SendScreen/SendSearchContacts.tsx | Updated as part of this pull request. |
| src/components/screens/SendScreen/screens/TransactionProcessingScreen.tsx | Updated as part of this pull request. |
| src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx | Updated as part of this pull request. |
| src/components/screens/SendScreen/screens/SendCollectibleReview.tsx | Updated as part of this pull request. |
| src/components/screens/SendScreen/helpers/index.tsx | Updated as part of this pull request. |
| src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx | Updated as part of this pull request. |
| src/components/screens/AddTokenScreen/AddTokenScreen.tsx | Updated as part of this pull request. |
| src/components/MuxedAddressWarningBottomSheet.tsx | Updated as part of this pull request. |
| src/components/analytics/DebugBottomSheet.tsx | Updated as part of this pull request. |
| scripts/run-e2e-tests.sh | Updated as part of this pull request. |
| e2e/README.md | Updated as part of this pull request. |
| e2e/flows/transactions/SendXlmToMuxedContract.yaml | Updated as part of this pull request. |
| e2e/flows/transactions/SendCustomTokenToMuxedContractBlocked.yaml | Updated as part of this pull request. |
| e2e/flows/onboarding/ImportFundedWallet.yaml | Updated as part of this pull request. |
| e2e/docs/local-setup-and-env.md | Updated as part of this pull request. |
| e2e/docs/creating-tests.md | Updated as part of this pull request. |
| .env.example | Updated as part of this pull request. |
| tests/services/transactionService.test.ts | Updated as part of this pull request. |
| tests/services/buildPaymentTransactionContractTarget.test.ts | Updated as part of this pull request. |
| tests/hooks/useRightHeader.test.tsx | Updated as part of this pull request. |
| tests/helpers/stellar.test.ts | Updated as part of this pull request. |
| tests/helpers/soroban.test.ts | Updated as part of this pull request. |
| tests/helpers/qrValidation.test.ts | Updated as part of this pull request. |
| tests/helpers/muxedAddress.test.ts | Updated as part of this pull request. |
| tests/helpers/keyboard.test.ts | Updated as part of this pull request. |
| tests/ducks/transactionBuilder.test.ts | Updated as part of this pull request. |
| tests/ducks/sendRecipient.test.ts | Updated as part of this pull request. |
| tests/components/TransactionSettingsBottomSheet.test.tsx | Updated as part of this pull request. |
| tests/components/TransactionDetailsBottomSheet.test.tsx | Updated as part of this pull request. |
| tests/components/screens/SendScreen/SendSearchContacts.test.tsx | Updated as part of this pull request. |
| tests/components/screens/SendScreen/screens/TransactionProcessingScreen.test.tsx | Updated as part of this pull request. |
| tests/components/screens/SendScreen/screens/TransactionAmountScreen.test.tsx | Updated as part of this pull request. |
| tests/components/screens/SendScreen/screens/SendCollectibleReview.test.tsx | Updated as part of this pull request. |
| tests/components/screens/SendScreen/helpers/index.test.tsx | Updated as part of this pull request. |
| tests/components/screens/SendScreen/components/SendReviewBottomSheet.test.tsx | Updated as part of this pull request. |
| tests/components/MuxedAddressWarningBottomSheet.test.tsx | Updated as part of this pull request. |
Review effort: Lite
The amount screen asked the backend for the token contract's spec on every custom-token send, whatever the recipient. Only an M reads that answer: a G or C needs no muxed support, and a custom token sent to a W is blocked without a lookup. The screen now skips the request for anything but an M. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>
getMemoDisabledState turns the memo off for any M or W before it reads the contract, so the SAC the sheet resolved for a W never changed the result. A W now falls through to the C case and is passed as its own contractId, which the memo check never reads. The getSacContractIdForBalance and logger imports and point 3 of the doc comment go with it, the two tests that only covered the lookup are dropped, and the W tests now check the memo state instead of which contract the check was handed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The amount screen and the collectible review block an M whose token or collection doesn't support muxed addresses: Continue (Review) stays off and nothing is built, both while the contract check runs and after it says no. The review sheet only opens after a build, so its M banner, and the footer's handling of the same flag, could no longer show. They go; the screens' own banner and explanation sheet stay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The W comment still said the review sheet carries an M banner, which e54b5ec removed. The screen's JSDoc still said only a W is blocked, though an M whose collection doesn't support muxed addresses is blocked the same way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1038) * Refuse a Soroban send that would drop its mux id or memo A Soroban transfer carries no memo, so the only id it can carry is the one in a muxed destination. When the destination the user entered couldn't be built, the transfer went somewhere else instead: an M whose contract doesn't support muxed addresses went to its base G without the id, and a memo that couldn't become an M's id (the contract doesn't support muxed addresses, or the memo isn't a decimal uint64) was dropped. The transfer now throws a translated error instead, so nothing is built or signed. The amount and collectible screens already block an M the contract doesn't support, so that refusal only fires when the build's support check disagrees with the screen's. For a custom token, a memo that can't be carried used to reach the backend's simulation, which rejects a Soroban transaction with a memo, so Review failed with a generic "Request failed with status code 400". It now fails with a clear message as soon as the token is chosen. A memo for an M is refused too, since the M's own id is the only one the transfer can carry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Show a failed collectible build's error as a toast A failed collectible build was only logged, so tapping Review did nothing and the user never learned why. The collectible screen now shows the build error as a toast, as the amount screen does. This includes the refusals from the previous commit, such as a memo the collection can't carry, and any other build or simulation error. Each failed build shows it, since a new build clears the error first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Look up an M recipient's funding through its base G Horizon can't look up a muxed account (M) itself: /accounts/M... returns 400, which getAccount turns into null, so the send flow recorded an M as unfunded. Typing or picking an M of a funded account then showed "The destination account doesn't exist", and where Blockaid can scan the transaction (mainnet), the review also warned that it was expected to fail. An M is its base G plus an id, so its funding is now looked up through the G. Contract destinations (C and W) still get no lookup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Block a memo a Soroban transfer can't carry before building anything A Soroban transfer carries a memo only as the id of an M built from a G. With a memo it can't carry (a federation memo, or one saved while XLM was selected, then a custom token without muxed support, a memo that isn't a decimal uint64, or a C recipient), every build threw: the 300 ms fee estimate failed before the user did anything, and Continue looked dead, because the screen doesn't toast the same error twice. Both send screens now block it up front, the way they block an M or a W the token can't take: a "Memo not supported" banner that opens an explanation, Continue (Review) off, the percentage buttons or the collectible image hidden, and nothing built. A memo for a G waits for the same contract lookup an M takes; a memo for a C is refused without one. The build-time refusal stays as the backstop. getSorobanMemoRefusal holds the rules. Its tests run every case through determineMuxedDestination as well, so the screens and the build refuse the same memos. Unlike createMuxedAccount it logs nothing, since the screens check every memo the user saves. prepareTransaction checks the freshly saved memo too, because Settings asks for a rebuild before the screen re-renders with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Throw away the earlier build when the new settings can't be built On both send screens the review sheet's gear opens Settings, so the memo can change while the review is open. When Settings saved a memo the transfer can't carry, prepareTransaction returned without building, but the transaction built for the earlier memo stayed in the store. The review came back showing the new memo, and Confirm signed the old transaction, without that memo or with the earlier mux id. A second save while the memo blocked the send did the same, since it still runs with the blocked render's state. Every early return in prepareTransaction now resets the built transaction and closes the review sheet, so nothing built for other settings can be confirmed, and the screen's banner says why. Tests save such a memo from Settings on each screen, save again while blocked, and pin that XLM with a text memo still builds on Continue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Refuse a memo longer than 22 digits, as the SDK does getSorobanMemoRefusal let a zero-padded numeric memo of 23 to 28 digits through, but the SDK refuses a mux id longer than 22 digits even when its value fits a uint64. The screens didn't block such a memo, the fee estimate then failed with one toast, and Continue looked dead. The check now caps the length at 22 digits too, and the parity table pins both sides of the limit through the real SDK. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Name the memo block in the send screens' doc comments The collectible screen's JSDoc and the amount screen's banner opener now say they cover a memo the transfer can't carry too. The comment on the collectible screen's memo check said an M "is handled above"; it now says what happens: a W is blocked there, and Settings disables the memo for an M. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Say "collectible" in the collectible screen's numeric-memo sheet A memo that isn't a number, for a collection that supports muxed addresses, opened the token's explanation: "This token only accepts a numeric memo". The collectible screen now opens its own variant: "This collectible only accepts a numeric memo, from 0 to 18446744073709551615.", in en and pt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

TL;DR
Users can now send native XLM and classic assets, such as USDC, to a muxed contract address (
W…, CAP-84). The app sends it as atransferon the asset's Stellar Asset Contract, to that exactW, and with no memo, because the mux id is already in the address. It is never sent as a classic payment. This matches the extension's stellar/freighter#3047.As in stellar/freighter#3047, a custom token or a collectible can't go to a
W. The amount and collectible screens show "Muxed address not supported", keep Review disabled and build nothing, and the transaction service refuses those sends as well. The memo is disabled and hidden for aW; a memo that still reaches the builder fails the build rather than being dropped.GandCsends behave as before.An
Mthat the token or collection can't take is now blocked on screen the same way, as in the extension. For a custom token or collectible whose contract doesn't support muxed addresses, the amount and collectible screens show the banner with theMexplanation, keep Review disabled and build nothing. Before, they built the transaction and only the review sheet warned. Review stays disabled until the contract check answers.Real
Wsends of XLM and USDC went through on testnet after its Protocol 30 upgrade on 2026-11-03 (see Verification). Simulating one needs freighter-backend#329, which so far is only on backend v1's staging. That environment is private (testing against it locally needs sshuttle), and backend v1 has no public staging, so #329 goes public when it's deployed to the production backend that release builds, PR previews and, by default, dev builds call. Until then, a debug-build-only "W Address Simulation Stub" lets the e2e flows and reviewers reach the review sheet.Closes stellar/wallet-eng-monorepo#107
Known limitations
Wsend without the stub through the production backend (release builds, PR previews and, by default, dev builds) stops at simulation with "Request failed with status code 400", and nothing is signed.M's id or a memo.Changed lines
Source code is the app code under
src/, translations included. It also counts the DEV-only simulation stub that the XLM-to-We2e flow switches on. Tests are the unit tests, the e2e flows and the e2e runner script. Other is the e2e docs and.env.example.Screenshots and Videos
Choosing a
Wrecipientmain)XLM →
W(USDC behaves the same)Custom token or collectible →
W: blockedCustom token or collectible →
Mwithout muxed support: now blocked on screen (before, only the review sheet warned)Implementation details (for agents)
Every link is a permalink at
70908faaff9077c7e4afdaf523ec7d25bd51243c, except item 10's, at04e4863333c3c7659b5d59c0c718bde79a3eb09c. The branch has 16 commits of its own, and mergescg-p30-sdk-bumpin to keep up with #1034.Decisions (from the Protocol 30 design spec)
Wwhen the asset is a custom token (a balance with acontractId) or a collectible; allow native XLM and classic assets, sent through their SAC. This replaces the issue's "SAC only" (isAssetSac) check.W-only: no change to howG/M/Cmemos fold, or to otherG/M/Cbehaviour. The one exception came after the parity review: anMthat the token or collection can't take is now blocked on screen (item 9).What changed
Wis a valid destination (1b6fba77).isValidStellarAddressaccepts a whole, checksummedWstrkey. It doesn't stop at aWprefix, so federation names that start with W still resolve. QR scanning inherits it (doc).isSorobanTransactioncounts aCorWrecipient (isContractDestinationfrom chore(deps): upgrade @stellar/stellar-sdk to 17.4.0 for Protocol 30 #1034) as Soroban.determineMuxedDestinationreturns aWunchanged before anyG/Mlogic, so it is never re-encoded, stripped to itsCor rebuilt as anM.ce6c27c4).buildPaymentTransactionsends aWdown the SACtransferpath, neverOperation.payment, and loads only the sender's account. The token contract comes from the asset through the newgetSacContractIdForBalance, never from the recipient.Wand a memo with aW. The existing "token's own contract" guard compares aW's baseC.buildSendCollectibleTransactionrefuses aWbefore any network call.Was a contract destination and simulates it with an empty memo. Real amounts go to/simulate-token-transferand the amount-0 fee estimate to/simulate-tx, as for aC; both need freighter-backend#329.67131a42).searchAddressandsetDestinationAddressnever callgetAccountfor aCorW, which covers typed, pasted, Recents, My wallets and QR entries.buildUnfundedContextflags aWas a contract destination, so the unfunded notice (SendSearchContacts) and Blockaid's unfunded-destination warning stay hidden. The processing screen treats aWas a contract recipient.services/blockaid/helper.tschanges in comments only.d97c6ea6).TransactionSettingsBottomSheetcounts aWas a Soroban recipient, which shows the "Inclusion fee" label. It looks up no contract for aW: the memo check turns the memo off for anyMorWbefore it reads one, so the SAC lookup this PR first added here is gone (6e6d489e, from review).SendReviewBottomSheetandTransactionDetailsBottomSheethide the memo row for aW, as for anM.66dc3653).W: a "Muxed address not supported" banner, a disabled Continue, andprepareTransactionreturning first, so the auto-simulation, a settings change and Continue build nothing.W: the same banner, a disabled Review, and nothing built.MuxedAddressWarningBottomSheetwith the newvariant="contract": "This token does not support muxed contract address (W-) as a target destination." (feat(send): support muxed contract (W) destinations freighter#3047's copy; new keys in en and pt, plustransaction.errors.muxedContractNotSupportedfor the service). TheMcopy stays forM.d66c24d6,274b6aac,ee12e241,cce653d2,d2761943). See the sections below.dde064cb,cbc0f46a). See "Small-screen fixes".0d11e57a): the collectible screen's warning sheet is readable by VoiceOver (see "Beyond the issue's scope"), plus tests, doc comments and a clearer internal error message for a custom token. No user-visible change otherwise.Mblock on screen (1740fb16,70908faa), for parity with feat(send): support muxed contract (W) destinations freighter#3047 after it merged.isRecipientUnsupportedcovers anMwhose token's contract doesn't support muxed addresses (isMuxedAddressWithoutMemoSupport, which the review sheet already used) as well as a custom token to aW. It drives the banner, the disabled Continue, the hidden percentage buttons andprepareTransaction's early return. The banner's testID names the case:muxed-address-not-supported-bannerfor anM,muxed-contract-not-supported-bannerfor aW.Mcheck reads the contract's spec from freighter-backend, so it answers after the screen renders. Until it does (isMuxedSupportPending), nothing is built and Continue stays off; otherwise the 300 ms fee estimate would build the send first. The backend client times out, and a failed check counts as no support.Mwhose collection doesn't support muxed addresses, including the image hidden on small screens.Mwarning is gone (e54b5ec2, from review): Continue is off whenever it would have shown, and the sheet only opens from Continue.M(04e48633), from a review comment. The amount screen fetched the token contract's spec for every custom-token recipient, but only anMreads the answer: aGorCneeds no muxed support, and a custom token sent to aWis blocked without it. The screen now skips the request for anything but anM, as the collectible screen already did.freighter-mobile/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx
Lines 410 to 420 in 04e4863
Memo rules for a
WgetMemoDisabledStatedisables the memo for anMorWfirst, before any contract lookup, with "Memo is disabled for this transaction."Mor aW.W, even if a memo is stored.Wis simulated with an empty memo; the transaction service refuses a non-empty memo for aWwith the same message, so a memo is never dropped silently.G,MandC, and how memos fold into anM.Parity with stellar/freighter#3047
WWcopy, Continue disabledWtransferto the exactWWWMrecipient, with a custom token or collectible whose contract doesn't support muxed addressesMcopy, Continue disabled, no simulationDEV-only simulation stub
stubMuxedContractSimulationin the debug store, off by default, toggled from Home's debug sheet (toggle, "W Address Simulation Stub", en and pt).buildTransactionskips both simulate endpoints when the final destination is aWand keeps the device-built XDR, so the review sheet opens. That XDR has no Soroban resources, so the network rejects it if it is ever submitted.CandGdestinations always simulate.__DEV__before it reads the flag, so the stub never applies in a release build. A prod-bundle build gets the no-op debug store anyway. A dev-bundle release build, such as a PR preview, gets the persisted store and could keep a value an earlier debug build saved on the same install, but it simulates aWas normal regardless, so a PR preview can't inherit the stub.Beyond the issue's scope, on purpose
accessible={false}on 5 bottom sheets: the debug sheet, the amount screen's settings and muxed-address-warning sheets, the collectible screen's muxed-address-warning sheet (the one its banner opens), and Add Token's add sheet. @gorhom/bottom-sheet 5.2.6 makes each sheet a single "Bottom Sheet, adjustable" accessibility element, so VoiceOver can't reach anything inside it. Turning that off fixes it for these sheets, as the send review sheet already does, and lets the iOS e2e assertions see their content. Other sheets are unchanged.run-e2e-tests.shloadsE2E_TEST_CUSTOM_TOKEN_CONTRACTandE2E_TEST_CUSTOM_TOKEN_CODEfrom.envand passes them to Maestro..env.example,e2e/docs/local-setup-and-env.md,e2e/docs/creating-tests.mdand thee2e/README.mdflow table list them. CI's env-file creator treatsE2E_TEST*keys as optional.waitForKeyboardDismisstakes an optionaltimeoutMs. With it, the wait also ends after that long without a hide event; on Android it races keyboard-controller's dismiss against the same cap. Only the amount screen's banner passes 500 ms, so its sheet opens even if the hide event never comes. Other callers are unchanged.send-settings-button(through a new optionaltestIDonuseRightHeaderButton),add-token-search-input,debug-stub-muxed-contract-simulation,debug-sheet-close-button,muxed-address-warning-close-button,muxed-contract-not-supported-banner,muxed-address-not-supported-banner,send-collectible-imageandsend-collectible-image-section.transactions/SendXlmToMuxedContract.yamlturns the stub on and sends XLM to aWup to the review sheet. It checks Settings says the memo is disabled and the review has no memo row, then cancels; it never confirms. On iOS each review row is one accessibility element whose label joins its texts ("Memo, None"), so the row check matches"Memo.*", after a"Fee.*"check that shows the pattern matches a row that is there (d2761943). It needs a debug build, so it isn't in the CI matrix.transactions/SendCustomTokenToMuxedContractBlocked.yamladds a SEP-41 token and checks the block. Banner, Continue disabled with an amount typed, no build or simulation error toast on arrival or after typing, and the explanation sheet. After typing, it also fails on the amount screen's "amount too high" and "XLM insufficient for transaction fees" errors, which would disable Continue too (d2761943). On Android it closes the keyboard before checking the banner and Continue (ee12e241).onboarding/ImportFundedWallet.yamlretries the "Allow Paste" tap, which the iOS 26.5 paste prompt sometimes ignores.Small-screen fixes
They apply while the recipient blocks the send: a
W, or, since item 9, anMthe token or collection can't take. Other layouts are unchanged.dde064cb)dde064cb)max-xs) while the recipient blocks the send; the details list still names the collectible (cbc0f46a)Wider screens keep the 240 pt image with the banner. A
Grecipient's layout is identical before and after.Tests
+114 tests and 2 new suites:
MuxedAddressWarningBottomSheet.test.tsxandTransactionDetailsBottomSheet.test.tsx. The main additions:stellar,qrValidation,sorobanandmuxedAddresshelpers:Waccepted (including a federation name that starts with W), Soroban routing, memo disabled first,Wreturned unchanged.buildPaymentTransactionContractTargetandtransactionService: a SACtransferto the exactW, including mux ids 0 and u64 max; refusals for a custom token, a memo, and aWwrapping the token's own SAC; the collectible refusal before any I/O.transactionBuilder:Wrouting, empty-memo simulation (a custom token sent to aGkeeps its memo), and the stub (Wonly, both simulation paths;Cstill simulates; with__DEV__false, aWsimulates as normal even with the flag on).sendRecipient,SendScreen/helpersandSendSearchContacts: nogetAccountfor aWon either path, no unfunded notice.TransactionSettingsBottomSheet,SendReviewBottomSheetandTransactionDetailsBottomSheet: the memo off for aWwithout a contract lookup, the inclusion-fee label, no memo row for aW, Save after theWmemo clear doesn't bring the old memo back, andG/M/Cunchanged.TransactionAmountScreenandSendCollectibleReview: banner, disabled button, nothing built, the stale-memo clear (aGorCkeeps its memo), the percentage buttons and the collectible image, each withG/Ccontrols. The collectible banner test gives each sheet its ownpresent()and checks that the one it opens is theWwarning.TransactionAmountScreenandSendCollectibleReview,M: blocked when the contract lacks muxed support (theMbanner and explanation, Review disabled, nothing built, the percentage buttons or the image hidden), not blocked when it has it, and nothing built until the check answers. The amount screen also checks that XLM to anMis unaffected, that a token switch doesn't reuse an earlier token's answer, and that a late answer is ignored. Removing any one of the last three guards fails its test.TransactionAmountScreen: no muxed-support lookup for a custom token sent to aG,CorW; only anMgets one.TransactionProcessingScreen: aW(orC) recipient with nothing submitting and no result shows "unsupported"; aGstays on "sending".keyboard: the timeout on iOS and Android, and that no listener or timer is left behind.useRightHeader: the forwardedtestID.Verification
yarn testat674cfb78: 336 suites passed (1 skipped), 4,945 tests passed (6 skipped).yarn lint:tsat674cfb78: clean. ESLint and Prettier are clean on the two screens and their tests. The next three results are from0d11e57a.yarn checkstill exits 1 on lint debt already onmain. ESLint reports the same 111 errors in 18 files; the 30 in__tests__/helpers/soroban.test.tsare the same quote errors it has on the base branch. Prettier flags the same 9 tracked files as onmain, none of them changed here.yarn lint:translations: no missing translations; its 9 errors are theimport/orderandno-continueerrors in 4 untouched files thatmainhas too.mock-dapp,package.json,yarn.lockor.yarnrc.ymlchanged.0d11e57a, run with exactly the environment argumentsrun-e2e-tests.shbuilds. Retries inside the shared steps recovered first-attempt misses that predate this PR: the "Allow Paste" prompt, and the manage-accounts sheet inSwitchToTestnet. On several earlier runs that manage-accounts step missed all three of its tries right after the import, and the run was repeated.W(reverted, never committed),SendXlmToMuxedContractfails at its"Memo.*"check, right after the"Fee.*"check passes, and it passes again after the revert. The old"Memo"check still passed on that same screen.SendCustomTokenToMuxedContractBlocked's post-amount check fail on the "The amount entered is greater than the available balance" toast.0d11e57a, on a freshdevDebugbuild.SendCustomTokenToMuxedContractBlockedfailed on its first Android run: the open keyboard covers the bottom of the amount screen, where the banner sits, as it covers Continue on any send.ee12e241closes the keyboard on Android first.Mblock at70908faa(dev build, testnet, iPhone SE (3rd generation) and Pixel_10_Pro), through throwaway Maestro flows:transfertakes noto_muxed, sent to anM; and a temporary local fake collectible (never committed) sent to the sameM.Mbanner, Review disabled (on the amount screen, also after typing an amount), no build or simulation error, and theMexplanation from the banner.Wstill shows theWbanner and explanation on both.SendCustomTokenToMuxedContractBlockedandSendXlmToMuxedContractweren't re-run after these two commits.cbc0f46a(dev build, testnet before its Protocol 30 upgrade, the stub on unless noted). The later commits add the stub's debug-build check, which a dev build passes, the collectible warning sheet's accessibility flag, tests and comments:W: one suggestion row, no unfunded notice (an unfundedGcontrol does show it)W: Settings show the memo disabled and "Inclusion fee"; the review has no memo row; the fee breakdown shows Inclusion, Resource and TotalW(a fresh account fromprovision-test-account.mjs --with-usdc-balance)W: banner and disabled Review; the sheet shows theWcopyW(local fake): banner and disabled Reviewcbc0f46acbc0f46aat 720x1280 / 320 dpiWStub off, toast detail: Review doesn't show a second toast, because the amount screen shows a builder error only when its text changes, and both failures read the same. That has always been the case on this screen, for any recipient.
Testnet
Wsends, 2026-11-03, after the Protocol 30 upgrade: stub off, dev build of this branch, backend v1 pointed at its private staging environment through sshuttle (a local, uncommitted change to the app's STG URL), the only deployment with freighter-backend#329 that day, fromGAAP…SHK6toWA7QYNF7…E2LLEM(baseCA7QYNF7…UWDA, mux id 1234).9c972b68…9156eafa…GBBD47…)cd08ca68…invoke_host_functionon the asset's SAC,transfer(from, to = the W, amount)(10, or 100000 for USDC), with no memo. Itstransferevent pays the base contract withto_muxed_id: 1234. The success screen reads "… was sent to WA7Q…LLEM".Follow-ups / out of scope
accessible: falsethe default in the sharedBottomSheetwrapper would cover them all.Wbanner shows once the keyboard closes.Grecipient, the 240 pt image already pushes Review off the screen. This is unrelated toW, and this PR leaves that layout as it is.yarn test:e2ealso collectsSendXlmToMuxedContract, which needs a debug build.OpenManageAccountsSheetstep: right after an import, a tap on the sheet's settings button can land while the sheet is still opening, and Settings never opens; the same tap a few seconds later works. Waiting for the sheet to settle before that tap may steadySwitchToTestnetfor every flow that uses it.🤖 Generated with Claude Code