Skip to content

fix(send): refuse a Soroban send that would drop its mux id or memo - #1038

Merged
CassioMG merged 14 commits into
cg-p30-send-wfrom
cg-p30-muxed-fail-closed
Nov 7, 2026
Merged

CassioMG merged 14 commits into
cg-p30-send-wfrom
cg-p30-muxed-fail-closed

Conversation

@CassioMG

@CassioMG CassioMG commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

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: an M whose token or collection doesn't support muxed addresses went to its base G without the id, and a memo that couldn't become an M'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 M or W the 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 G without a memo, a G with a numeric memo the contract can take (it still becomes an M), an M the contract supports, W and C recipients, and classic payments.

Merge order: stacked on #1036 (send to W); it merges right after it.

Changed lines

Files Added Removed Share of changed lines
Source code 8 515 147 37%
Tests 7 1,049 64 63%
Total 15 1,564 211

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 M or W: 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.

Before (time-lapse) Before: Review fails with a generic 400 After (time-lapse) After: the sheet the banner opens
TZT with a memo before the fix: no message until Review, then a 400 toast Request failed with status code 400 TZT with a memo now: the Memo not supported banner once the contract check answers, Continue off, nothing built This token can't be sent with a memo to this address.

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 a G. Both runs treat TZT as supporting muxed addresses through a local change; before is on Android, after on iOS.

Before (time-lapse) Before: Review fails with a generic 400 After (time-lapse) After: the sheet the banner opens
Text memo on a TZT send before the fix: no message until Review, then a 400 toast Request failed with status code 400 Text memo on a TZT send now: the Memo not supported banner as soon as TZT is chosen This token only accepts a numeric memo, from 0 to 18446744073709551615

Collectible whose build fails

Before (time-lapse) Before: Review does nothing After (time-lapse) After: the error shows
Collectible send before the fix: Review does nothing Collectible screen after tapping Review, with no message Collectible send after the fix: the error shows as a toast The collectible screen with the error toast

M of a funded account

Before (existing bug on main) After
An M of a funded account shown as unfunded The same M with no unfunded notice
Implementation details (for agents)

Links for changes 1 and 2 are permalinks at 22d7846a4d9a56a53f7bd5de5ca9a757d379a957, for change 3 at 70a0d8550f62fb104edd7ceb978804eacc72c566, for change 4 at 86107dfd86ff7b774316f9599c7a69872691834d, and for change 5 at e644629c70f9a2435fb3b17bdcbdb9465fc936b7. The branch has 8 commits on cg-p30-send-w, and merges cg-p30-send-w in to pick up its later commits: 74da9295 for its .yarnrc.yml fix, most recently 64c6fed4.

What changed

  1. determineMuxedDestination refuses instead of falling back (4e794d2f):
    /**
    * Picks the address a Soroban transfer is sent to, or refuses the transfer.
    *
    * A Soroban transaction carries no memo, so the only id a transfer can carry
    * is the one in a muxed destination. When the recipient or the memo can't
    * arrive as entered, this throws instead of sending to a different address
    * or without the memo.
    *
    * - A muxed contract (`W...`) is returned unchanged, before anything else: the
    * mux id is already in the address, so it is never re-encoded, stripped to
    * its `C...` or rebuilt as an `M...`, and the memo and
    * `contractSupportsMuxed` are ignored for it.
    * - An `M...` is returned unchanged when the contract supports muxed
    * addresses, and refused when it doesn't. A memo for it is refused: the
    * `M...`'s own id is the only one the transfer can carry.
    * - Any other recipient without a memo is returned unchanged.
    * - A `G...` with a memo becomes an `M...` carrying the memo as its id. The
    * memo is refused when the contract doesn't support muxed addresses, when
    * the recipient isn't a `G...`, or when the memo isn't a decimal uint64.
    *
    * @param params The recipient, the memo, and whether the contract supports muxed
    * @returns The address to send to
    * @throws Error with a translated message when the recipient or the memo
    * can't be carried
    */
    export function determineMuxedDestination(
    params: DetermineMuxedDestinationParams,
    ): string {
    const { recipientAddress, transactionMemo, contractSupportsMuxed } = params;
    // A W already carries its own mux id: pass it through before any G/M logic
    // can validate, rebuild or unwrap it.
    if (isMuxedContract(recipientAddress)) {
    return recipientAddress;
    }
    const hasMemo =
    typeof transactionMemo === "string" && transactionMemo.length > 0;
    if (isMuxedAccount(recipientAddress)) {
    if (!contractSupportsMuxed) {
    throw new Error(translate("transaction.errors.muxedAddressNotSupported"));
    }
    if (hasMemo) {
    throw new Error(
    translate("transactionSettings.memoInfo.memoDisabledForTransaction"),
    );
    }
    return recipientAddress;
    }
    if (!hasMemo) {
    return recipientAddress;
    }
    const isRecipientGAddress =
    isValidStellarAddress(recipientAddress) && !isContractId(recipientAddress);
    if (!contractSupportsMuxed || !isRecipientGAddress) {
    throw new Error(
    translate("transactionSettings.memoInfo.memoNotSupportedForOperation"),
    );
    }
    const muxedWithMemo = createMuxedAccount(recipientAddress, transactionMemo);
    if (!muxedWithMemo) {
    throw new Error(translate("transaction.errors.nonNumericMemoNotSupported"));
    }
    return muxedWithMemo;
    }
    • A W is returned unchanged, as before. buildPaymentTransaction refuses a W with a memo, and buildSendCollectibleTransaction refuses any W, before this runs.
    • An M is returned when the contract supports muxed addresses. Otherwise it throws transaction.errors.muxedAddressNotSupported; before, it returned the base G. A memo for an M throws transactionSettings.memoInfo.memoDisabledForTransaction. Before, the memo was ignored, and a branch that would have replaced the M's id with the memo (unreachable for a real M) is gone.
    • Any other recipient without a memo is returned unchanged.
    • A G with a memo still becomes an M carrying the memo as its id. It throws transactionSettings.memoInfo.memoNotSupportedForOperation when the contract doesn't support muxed addresses or the recipient isn't a G, and transaction.errors.nonNumericMemoNotSupported when the memo isn't a decimal uint64. Before, both returned the plain G and dropped the memo.
    • Both callers already turn a throw into a failed build: "Failed to build payment transaction: …" through buildSorobanTransferOperation, and "Failed to build send collectible transaction: …". Their doc comments now say so.
      * - SAC transfer (Soroban): a custom token, or any contract destination
      * (`C...` or muxed contract `W...`), becomes a `transfer(from, to, amount)`
      * call on the token's contract: a custom token's own contract, otherwise the
      * asset's Stellar Asset Contract (see `getSacContractIdForBalance`). Only the
      * sender's account is loaded. It carries no transaction memo: when the token
      * contract supports muxed addresses, a memo for a `G...` becomes the id of
      * an `M...` destination instead. A memo or an `M...` the transfer can't
      * carry is refused (see `determineMuxedDestination`).

      /**
      * Builds a collectible transfer transaction: a `transfer(from, to, token_id)`
      * call on the collection contract. See `determineMuxedDestination` for how a
      * memo folds into the destination, and when a memo or an `M...` recipient is
      * refused.
      *
      * A muxed contract (`W...`) recipient is refused with
      * `transaction.errors.muxedContractNotSupported`, before any network call: no
      * account is loaded and no contract spec is looked up.
      *
      * @param params The collection, token id and recipient, the memo, fee and
      * timeout, and the network and sender
      * @returns The transaction, its XDR and the final destination
      * @throws Error `Failed to build send collectible transaction: ...` when a
      * parameter is missing or invalid, the recipient is a `W...`, the recipient or
      * memo can't be carried, or building fails
      */
    • Two new strings, in en and pt: transaction.errors.muxedAddressNotSupported and transaction.errors.nonNumericMemoNotSupported.
      "muxedAddressNotSupported": "This token does not support muxed address (M-) as a target destination.",
      "nonNumericMemoNotSupported": "This token only accepts a numeric memo, from 0 to 18446744073709551615."
    • The helper imports i18next's t as translate, because getMemoDisabledState in the same module takes its own t.
  2. The collectible screen shows build errors as a toast (22d7846a), as the amount screen does. Every failed build shows it, since a new build clears the error first.
    const { showToast } = useToast();
    // Show why a build failed, as the amount screen does: otherwise Review just
    // does nothing. Every failed build shows it, since a new build clears the
    // error first.
    useEffect(() => {
    if (transactionBuilderError) {
    showToast({
    variant: "error",
    title: transactionBuilderError,
    toastId: "transaction-builder-error",
    duration: 5000,
    });
    }
    }, [transactionBuilderError, showToast]);
  3. An M recipient's funding is looked up through its base G (70a0d855). Horizon answers 400 for /accounts/M…, and getAccount turns any error into null, so the send-recipient store recorded every M as unfunded. That drove the recipient screen's "doesn't exist" notice and, through buildUnfundedContext, 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 through getFundingLookupAddress, which maps an M to its base G and leaves any other address unchanged. C and W destinations are still classified before any lookup, so they never reach it.
    /**
    * The account to look up to learn whether a destination is funded. A muxed
    * account (`M...`) is its base `G...` plus an id, so it is funded exactly when
    * its `G...` is; Horizon can't look up the `M...` itself. Any other address is
    * looked up as is.
    *
    * @param address A destination that isn't a contract (`C...` or `W...`)
    * @returns The address to pass to `getAccount`
    */
    const getFundingLookupAddress = (address: string): string =>
    getBaseAccount(address) ?? address;

    } else {
    const account = await getAccount(
    getFundingLookupAddress(resolvedAddress),
    network,
    );
    isFunded = !!account;
  4. Both send screens block a memo the transfer can't carry, before building anything (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 a G or 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 (440de7c1 added the length cap). These are the memos determineMuxedDestination refuses. Unlike createMuxedAccount, it logs nothing, since the screens check every memo the user saves.
    • Amount screen: a memo on a Soroban send to a G takes the same contract lookup as an M, and nothing is built until it answers. A memo for a C is refused without one. isMemoUnsupported joins isRecipientUnsupported in isSendBlocked, which drives the banner (testID memo-not-supported-banner), the disabled Continue, the hidden percentage buttons and prepareTransaction's early return.
    • prepareTransaction also 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.
    • Collectible screen: the same, with its own check in prepareTransaction, and the image hidden on small screens while the memo blocks the send.
    • MuxedAddressWarningBottomSheet gets 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.") and numericMemo, which reuses transaction.errors.nonNumericMemoNotSupported. 0be6b47d adds 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.
  5. A memo the transfer can't carry throws away what was built before (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.
    • discardBuiltTransaction resets the built transaction and closes the review sheet, and the same on the collectible screen. Every early return in prepareTransaction calls it, so the screen's banner says why the send is blocked.
    • The review sheet is minimized while Settings is on top: gorhom 5.2.6's default stackBehavior is "switch", and nothing in src/ 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

  • A memo, up front (change 4): the screens refuse exactly the memos the build refuses (a test runs every case through both), and only on a Soroban send with a memo. XLM and classic assets to a G keep their memo, and a numeric memo still becomes an M when the contract supports muxed addresses.
  • M without 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.
  • A memo that can't be carried, custom token: this already failed. /simulate-token-transfer puts 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.
  • A memo that can't be carried, collectible: the one path that really sent without the memo, because the collectible transaction is built on the device and simulated without any memo. The memo field is disabled for a collection without muxed support, but a federation memo is kept when the recipient is picked, and for a collection with muxed support the field accepts any text.

Tests

  • muxedAddress.test.ts: the three fallbacks now throw (an M without support, a memo without support, a memo that doesn't fold); a memo for an M throws; a G without 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 the G; a memo without support and a memo for an M throw; decimal folding is unchanged.
  • transactionService.test.ts: for a collectible, a decimal memo becomes the M (checked in the XDR's to), a memo is refused with and without muxed support, and an M without support is refused; for a custom token, an M without 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 typed M and a picked M are both looked up through the base G, so an M of a funded account is funded; C and W are still never looked up.
  • muxedAddress.test.ts (change 4): getSorobanMemoRefusal refuses exactly what determineMuxedDestination refuses, over 17 cases (G, C and M recipients, 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) and SendCollectibleReview.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; a C blocked 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 a G aren't blocked, and that switching to XLM unblocks.
  • Change 5: TransactionAmountScreen.test.tsx (+3) and SendCollectibleReview.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

  • Suites: yarn test at 0be6b47d: 336 suites passed (1 skipped), 5,003 tests passed (6 skipped). yarn lint:ts is clean. ESLint and Prettier are clean on the 15 changed files. yarn check still exits 1 on the same 111 lint errors in untouched files that main has. yarn lint:translations reports no missing translations.
  • Device checks for change 4, iPhone SE (3rd generation) simulator, iOS 27.2, dev build on testnet at 61d0b2f6, through throwaway Maestro flows. The wallet holds no TZT, so the amount stays empty.
    • Memo "hello" saved while sending XLM to a 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.
    • The same with a local, uncommitted change that makes the app treat TZT as supporting muxed addresses (reverted after the run): the banner shows as soon as TZT is chosen, and its sheet reads "This token only accepts a numeric memo, from 0 to 18446744073709551615."
    • Not run on Android: change 4 is JavaScript only.
  • Change 5 wasn't run on a device. Reaching the review sheet's gear with a custom token needs a funded custom token and a clean Blockaid scan, and this testnet wallet has neither. The tests above cover it.
  • Device checks for changes 1–3, iPhone SE (3rd generation) simulator, iOS 26.5, dev build on testnet, through throwaway Maestro flows. Before is 70908faa, after is this branch at 70a0d855, before change 4.
    • Custom token: before, no message after switching to TZT, then "Request failed with status code 400" at Review. After, "Failed to build payment transaction: Error building Soroban transfer operation: Memo is not supported for this operation." as soon as TZT is chosen.
    • Collectible: a temporary local fake collectible (reverted, never committed) whose collection address is TZT's contract, so its transfer builds and then fails simulation. Before, Review does nothing. After, the simulation error shows as a toast.
    • M of a funded account (an M of Circle's testnet USDC issuer, sending XLM): before (this branch without 70a0d855), 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. A W recipient still gets no notice and no lookup.
    • The custom-token and M checks weren't run on Android: the change is JavaScript only.
  • Device checks on Android, Pixel 10 Pro emulator (API 37), dev build on testnet, through throwaway Maestro flows. Before is feat(send): support muxed contract (W) destinations #1036 (2aeacc1f), after is this branch at e30ec086, before change 4.
    • Text memo "hello" on a TZT send to a 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.
    • Collectible, with the same temporary fake collectible as on iOS: before, Review does nothing. After, the simulation error ("Request failed with status code 400") shows as a toast as soon as the screen's build fails, and it's still there when Review is tapped.

🤖 Generated with Claude Code

CassioMG and others added 2 commits November 2, 2026 14:05
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>
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

iOS Simulator preview build is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-9b8de630ad152fa9c38b
Backend: V1 prod + V2 staging (no sandbox configured for @CassioMG). SDF collaborators only — install instructions in the release description.

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Android Emulator preview APK is ready: https://github.com/stellar/freighter-mobile/releases/tag/untagged-6e2bfe92ae84d010ac03
Backend: V1 prod + V2 staging (no sandbox configured for @CassioMG). SDF collaborators only — install instructions in the release description.

CassioMG and others added 3 commits November 2, 2026 15:35
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>
@CassioMG
CassioMG marked this pull request as ready for review November 3, 2026 00:50
@CassioMG
CassioMG requested a lite review from Copilot November 3, 2026 00:50
@CassioMG CassioMG self-assigned this Nov 3, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 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 underlying G… 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 cg-p30-send-w's merge of cg-p30-sdk-bump: 900fd03 (two
missing tests) and b80ca26 (Node 22 in the docs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CassioMG
CassioMG requested a review from a team November 3, 2026 21:01
Comment thread src/helpers/muxedAddress.ts Resolved
CassioMG and others added 8 commits November 4, 2026 14:05
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>
@CassioMG
CassioMG merged commit 483fe24 into cg-p30-send-w Nov 7, 2026
6 checks passed
@CassioMG
CassioMG deleted the cg-p30-muxed-fail-closed branch November 7, 2026 00:01
CassioMG added a commit that referenced this pull request Nov 7, 2026
Brings in 483fe24, the squash merge of #1038 into this branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CassioMG added a commit that referenced this pull request Nov 7, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants