Repository navigation
fix(send): refuse a Soroban send that would drop its mux id or memo - #1038
Conversation
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>
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>
|
iOS Simulator preview build is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-9b8de630ad152fa9c38b |
|
Android Emulator preview APK is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-6e2bfe92ae84d010ac03 |
Brings in the base's update from cg-p30-sdk-bump: b4489ad, which re-comments the stellar-sdk age-gate bypass in .yarnrc.yml, and 9e5e0d2, which renames the SDK compatibility tests. This branch started before both, so it still had the bypass on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Brings in 04e4863, which makes the amount screen look up muxed support only for an M recipient. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟢 Approval recommended
No active review issues remain, with targeted handling for lossy muxed destinations, M-account funding checks, user-facing build errors, translations, and regression coverage included.
No open issues 🎉
What changed in this PR
This pull request makes Soroban send construction fail closed when muxed destinations or memos cannot be preserved, improves funding checks for M… recipients, and surfaces collectible build errors to users.
Changes:
- Reject unsupported muxed destinations and memos that cannot be encoded without data loss.
- Resolve
M…funding checks through the underlyingG…account. - Display collectible transaction-build failures as error toasts.
- Add English and Portuguese translations and regression coverage.
| File | Description |
|---|---|
src/services/transactionService.ts |
Documents the updated payment and collectible transaction build failure behavior. |
src/i18n/locales/pt/translations.json |
Adds Portuguese translations for the new refusal messages. |
src/i18n/locales/en/translations.json |
Adds English errors for unsupported muxed destinations and nonnumeric memos. |
src/helpers/muxedAddress.ts |
Refuses destinations and memos that cannot be represented without data loss. |
src/ducks/sendRecipient.ts |
Uses an M… address’s base G… account for funding checks. |
src/components/screens/SendScreen/screens/SendCollectibleReview.tsx |
Shows collectible transaction-build errors as toasts. |
__tests__/services/transactionService.test.ts |
Tests payment and collectible build refusal behavior and destination encoding. |
__tests__/helpers/muxedAddress.test.ts |
Tests refusal of unsupported muxed destinations and memos. |
__tests__/helpers/memoFolding.test.ts |
Tests valid and invalid memo-to-muxed-ID conversion. |
__tests__/ducks/sendRecipient.test.ts |
Verifies funding checks for typed and selected M… addresses. |
__tests__/components/screens/SendScreen/screens/SendCollectibleReview.test.tsx |
Verifies collectible build errors render as toasts. |
Review effort: Lite
Brings in 6e6d489 (the settings sheet no longer looks up a SAC for a W recipient). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Brings in e54b5ec (the review sheet no longer has a warning for an M the token can't take, which Continue being off made unreachable). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
Brings in 674cfb7 (the collectible screen's comments no longer mention the review sheet's M banner, and its JSDoc names the M block). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
* Bump stellar-sdk to 17.4.0 for Protocol 30
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Add muxed contract (W) address helpers
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Test a real checksum failure in the W helpers
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Scrub muxed contract (W) addresses from logs
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
* Add Protocol 30 SDK compatibility tests
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>
* Add memo folding tests for stellar-sdk 17.4.0
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>
* Support strict UTF-8 TextDecoder on Hermes for stellar-sdk 17.4.0
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>
* Strip a leading BOM in strict TextDecoder decodes
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>
* Decode only the viewed bytes in strict TextDecoder decodes
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>
* Clarify P30 test titles and a stale UTF-8 comment
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>
* Accept muxed contract (W) addresses as send destinations
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>
* Build and simulate sends to muxed contract (W) addresses
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>
* Skip account lookups and unfunded warnings for W destinations
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>
* Disable and hide the memo for W destinations
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>
* Block W destinations for custom tokens and collectibles
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>
* Add a DEV simulation stub and Maestro flows for W sends
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>
* Fit the blocked W send screens on small devices
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>
* Make the W-blocked e2e flow assert the block and run via the runner
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>
* Close the Android keyboard before the W-blocked flow's checks
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>
* Hide the collectible image for a W on small screens
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>
* Require a debug build for the W simulation stub
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>
* Make the W send flows' memo and amount checks able to fail
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>
* Tidy W send docs and tests from the final review
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>
* Rename the SDK compatibility tests and pin Horizon submission errors
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>
* Block an M the token can't reach on the amount screen
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>
* Block an M the collectible can't reach on the collectible screen
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>
* Re-comment the stellar-sdk age-gate bypass
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>
* Look up muxed support only for an M recipient
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>
* Test a too-short W in the scrubber and buildMuxedContract's leading zeros
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>
* Document Node 22, which package.json and CI already require
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>
* Drop the settings sheet's SAC lookup for a W recipient
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>
* Drop the review sheet's warning for an M the token can't take
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>
* Bring the collectible screen's comments up to date with the M block
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>
* fix(send): refuse a Soroban send that would drop its mux id or memo (#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>
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
TL;DR
A Soroban transfer (a custom token or a collectible) can't carry a transaction memo. The only id it can carry is the one inside a muxed
M…destination. When the app couldn't build the destination the user entered, it built a different one without saying so: anMwhose token or collection doesn't support muxed addresses went to its baseGwithout the id, and a memo that couldn't become anM's id was dropped. The app now refuses those sends, so nothing is built or signed.A memo the transfer can't carry is now caught before anything is built: both send screens show a "Memo not supported" banner and keep Continue (Review, for a collectible) off, the way #1036 blocks an
MorWthe token can't take. The banner opens a sheet that says why. Saving such a memo from the review sheet's Settings closes the review and throws away the transaction built before, so it can't be confirmed under the new memo. The build-time refusal stays as the backstop.Sends that work today are unchanged: a
Gwithout a memo, aGwith a numeric memo the contract can take (it still becomes anM), anMthe contract supports,WandCrecipients, and classic payments.Merge order: stacked on #1036 (send to
W); it merges right after it.Changed lines
Source code is the app code under
src/, translations included. Tests are the unit tests.Screenshots and Videos
Both send screens now block a memo the transfer can't carry before anything is built, the way #1036's "Muxed address not supported" banner blocks an
MorW: a "Memo not supported" banner, Continue off, and a sheet that says why. The "After" runs are on the iPhone SE simulator (iOS 27.2), with a wallet that holds no TZT, so the amount stays empty.1. A memo the token can't carry. The memo "hello" is set while sending XLM, then the token is switched to TZT, whose contract the app treats as not supporting muxed addresses.
2. A text memo on a token that supports muxed addresses. A numeric memo folds into an
M; text can't. The memo "hello" is set in Settings on a send to aG. Both runs treat TZT as supporting muxed addresses through a local change; before is on Android, after on iOS.Collectible whose build fails
Mof a funded accountImplementation details (for agents)
Links for changes 1 and 2 are permalinks at
22d7846a4d9a56a53f7bd5de5ca9a757d379a957, for change 3 at70a0d8550f62fb104edd7ceb978804eacc72c566, for change 4 at86107dfd86ff7b774316f9599c7a69872691834d, and for change 5 ate644629c70f9a2435fb3b17bdcbdb9465fc936b7. The branch has 8 commits oncg-p30-send-w, and mergescg-p30-send-win to pick up its later commits:74da9295for its.yarnrc.ymlfix, most recently64c6fed4.What changed
determineMuxedDestinationrefuses instead of falling back (4e794d2f):freighter-mobile/src/helpers/muxedAddress.ts
Lines 126 to 199 in 22d7846
Wis returned unchanged, as before.buildPaymentTransactionrefuses aWwith a memo, andbuildSendCollectibleTransactionrefuses anyW, before this runs.Mis returned when the contract supports muxed addresses. Otherwise it throwstransaction.errors.muxedAddressNotSupported; before, it returned the baseG. A memo for anMthrowstransactionSettings.memoInfo.memoDisabledForTransaction. Before, the memo was ignored, and a branch that would have replaced theM's id with the memo (unreachable for a realM) is gone.Gwith a memo still becomes anMcarrying the memo as its id. It throwstransactionSettings.memoInfo.memoNotSupportedForOperationwhen the contract doesn't support muxed addresses or the recipient isn't aG, andtransaction.errors.nonNumericMemoNotSupportedwhen the memo isn't a decimal uint64. Before, both returned the plainGand dropped the memo.buildSorobanTransferOperation, and "Failed to build send collectible transaction: …". Their doc comments now say so.freighter-mobile/src/services/transactionService.ts
Lines 374 to 381 in 22d7846
freighter-mobile/src/services/transactionService.ts
Lines 784 to 800 in 22d7846
enandpt:transaction.errors.muxedAddressNotSupportedandtransaction.errors.nonNumericMemoNotSupported.freighter-mobile/src/i18n/locales/en/translations.json
Lines 1153 to 1154 in 22d7846
tastranslate, becausegetMemoDisabledStatein the same module takes its ownt.22d7846a), as the amount screen does. Every failed build shows it, since a new build clears the error first.freighter-mobile/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx
Lines 143 to 157 in 22d7846
Mrecipient's funding is looked up through its baseG(70a0d855). Horizon answers 400 for/accounts/M…, andgetAccountturns any error intonull, so the send-recipient store recorded everyMas unfunded. That drove the recipient screen's "doesn't exist" notice and, throughbuildUnfundedContext, the review's expected-to-fail warning, which only appears when Blockaid returns a scan result. Both lookups, for a typed recipient and a picked one, now go throughgetFundingLookupAddress, which maps anMto its baseGand leaves any other address unchanged.CandWdestinations are still classified before any lookup, so they never reach it.freighter-mobile/src/ducks/sendRecipient.ts
Lines 64 to 74 in 70a0d85
freighter-mobile/src/ducks/sendRecipient.ts
Lines 297 to 302 in 70a0d85
86107dfd), from review. Before, such a memo (a federation memo, or one saved while XLM was selected, then a custom token) failed every build: the 300 ms fee estimate threw before the user did anything, and Continue looked dead, because the screen doesn't toast the same error twice.getSorobanMemoRefusal(new) holds the rules:"unsupported"when the recipient isn't aGor the contract doesn't support muxed addresses, and"nonNumeric"when the memo isn't a decimal uint64 of at most 22 digits, the SDK's limit (440de7c1added the length cap). These are the memosdetermineMuxedDestinationrefuses. UnlikecreateMuxedAccount, it logs nothing, since the screens check every memo the user saves.Gtakes the same contract lookup as anM, and nothing is built until it answers. A memo for aCis refused without one.isMemoUnsupportedjoinsisRecipientUnsupportedinisSendBlocked, which drives the banner (testIDmemo-not-supported-banner), the disabled Continue, the hidden percentage buttons andprepareTransaction's early return.prepareTransactionalso checks the memo it reads from the store, because Settings asks for a rebuild before the screen re-renders with the memo it just saved.prepareTransaction, and the image hidden on small screens while the memo blocks the send.MuxedAddressWarningBottomSheetgets three memo variants, titled "Memo not supported":memo("This token can't be sent with a memo to this address."),collectibleMemo("This collectible can't be sent with a memo to this address.") andnumericMemo, which reusestransaction.errors.nonNumericMemoNotSupported.0be6b47dadds a fourth,collectibleNumericMemo("This collectible only accepts a numeric memo, from 0 to 18446744073709551615."), so the collectible screen doesn't say "token". New strings in en and pt.e644629c). 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, change 4's early return built nothing but left the transaction built for the earlier memo 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.discardBuiltTransactionresets the built transaction and closes the review sheet, and the same on the collectible screen. Every early return inprepareTransactioncalls it, so the screen's banner says why the send is blocked.stackBehavioris"switch", and nothing insrc/overrides it.dismiss()on a minimized sheet unmounts it at once, so it isn't restored when Settings closes. On the amount screen that close also sends the review's "rejected" analytics event.Why each refusal doesn't break a working send
Gkeep their memo, and a numeric memo still becomes anMwhen the contract supports muxed addresses.Mwithout muxed support: feat(send): support muxed contract (W) destinations #1036 blocks it on both screens and builds nothing until the support check answers, so only a disagreement between the screen's check and the build's reaches the refusal. Neither check is cached, and a failed check counts as unsupported./simulate-token-transferputs the memo on the Soroban transaction and the RPC rejects it ("Transaction contains a memo. Soroban transactions do not support memos."), which Review showed as "Request failed with status code 400". A direct call to the endpoint with the same transfer returns 400 with the memo and 200 without it.Tests
muxedAddress.test.ts: the three fallbacks now throw (anMwithout support, a memo without support, a memo that doesn't fold); a memo for anMthrows; aGwithout a memo is still returned when the contract has no muxed support.memoFolding.test.ts(real SDK): "0x10", " 16 ", "AB12CD" and 2^64 now throw instead of returning theG; a memo without support and a memo for anMthrow; decimal folding is unchanged.transactionService.test.ts: for a collectible, a decimal memo becomes theM(checked in the XDR'sto), a memo is refused with and without muxed support, and anMwithout support is refused; for a custom token, anMwithout support and a memo without support are refused. The custom-token fixture moved to module scope so both suites share it.SendCollectibleReview.test.tsx: a builder error renders as a toast.sendRecipient.test.ts: a typedMand a pickedMare both looked up through the baseG, so anMof a funded account is funded;CandWare still never looked up.muxedAddress.test.ts(change 4):getSorobanMemoRefusalrefuses exactly whatdetermineMuxedDestinationrefuses, over 17 cases (G,CandMrecipients, the uint64 edges, and both sides of the SDK's 22-digit limit), returns nothing without a memo, and logs nothing.MuxedAddressWarningBottomSheet.test.tsx: the four memo variants, with the real en strings.TransactionAmountScreen.test.tsx(+10) andSendCollectibleReview.test.tsx(+7): banner, Continue (Review) off and nothing built without muxed support and for a memo that isn't a number; a numeric memo still builds; nothing until the contract check answers; aCblocked without a lookup; nothing built when Settings saves a memo the token can't carry; the percentage buttons or the image hidden. The amount screen also checks that XLM and a classic asset to aGaren't blocked, and that switching to XLM unblocks.TransactionAmountScreen.test.tsx(+3) andSendCollectibleReview.test.tsx(+1). On both screens, a memo the token can't carry saved from Settings throws away the earlier build and closes the review. On the amount screen, a second save while the memo blocks the send does the same, and XLM with a text memo still builds on Continue.Verification
yarn testat0be6b47d: 336 suites passed (1 skipped), 5,003 tests passed (6 skipped).yarn lint:tsis clean. ESLint and Prettier are clean on the 15 changed files.yarn checkstill exits 1 on the same 111 lint errors in untouched files thatmainhas.yarn lint:translationsreports no missing translations.61d0b2f6, through throwaway Maestro flows. The wallet holds no TZT, so the amount stays empty.G, then TZT chosen: once the contract check answers (about 3 s on testnet), the "Memo not supported" banner shows, Continue stays off and the percentage buttons hide. No toast, since nothing is built. The banner opens "This token can't be sent with a memo to this address." Switching back to XLM removes the banner.70908faa, after is this branch at70a0d855, before change 4.transferbuilds and then fails simulation. Before, Review does nothing. After, the simulation error shows as a toast.Mof a funded account (anMof Circle's testnet USDC issuer, sending XLM): before (this branch without70a0d855), the recipient screen says "The destination account doesn't exist"; after, it doesn't. The caution sheet shows only Blockaid's testnet "Unable to scan transaction" in both runs, since the expected-to-fail warning needs a scan result. AWrecipient still gets no notice and no lookup.Mchecks weren't run on Android: the change is JavaScript only.2aeacc1f), after is this branch ate30ec086, before change 4.G, with a local, uncommitted change that makes the app treat TZT as supporting muxed addresses: before, "Request failed with status code 400" at Review. After, "Failed to build payment transaction: Error building Soroban transfer operation: This token only accepts a numeric memo, from 0 to 18446744073709551615." as soon as an amount is entered.🤖 Generated with Claude Code