Skip to content

Commit d8ee94c

Browse files
JakeUrbanCopilot
andauthored
Skip unfunded-destination warning for Soroban on Search address screen (#2778)
* Skip unfunded-destination warning for Soroban on Search address screen PR #2720 removed the misleading "Blockaid unfunded destination" warning for pure Soroban custom tokens on the transaction Review step. The same warning still appeared one step earlier on the Search address screen (SendTo). This change consolidates the rule into a single helper so both surfaces agree by construction. - Add popup/helpers/sendWarnings.ts with two pure helpers: - shouldCheckUnfundedDestinationWarning (qualitative gate: classic asset + classic destination + non-collectible) - shouldShowAccountDoesntExistWarning (compound: gate + strict isFunded === false, matching Review-step semantics where isFunded is boolean | null) - SendTo/index.tsx: pull asset/isCollectible from transactionDataSelector and use the compound helper; delete the dead shouldAccountDoesntExistWarning export and unused baseReserve. - useSimulateTxData.tsx: refactor getExpectedToFailReason in-place to delegate the qualitative gate to the shared helper; thread destination and isCollectible (already available in the hook) into the single call site without widening the hook's public API. - Tests: - New sendWarnings.test.ts covers native, classic, SAC, pure Soroban, collectible, contract destination, null/undefined funding, and the early-return query-param hydration edge case. - useSimulateTxData.test.ts updated for the new params and gains collectible / contract-destination cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Import isContractId from @shared/api/helpers/soroban in sendWarnings.ts Addressed Copilot PR review suggestion: sendWarnings.ts only uses the isContractId predicate, so importing from the lightweight @shared source directly avoids pulling in the larger popup/helpers/soroban module. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 56d485f commit d8ee94c

5 files changed

Lines changed: 344 additions & 35 deletions

File tree

extension/src/popup/components/send/SendAmount/hooks/__tests__/useSimulateTxData.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getExpectedToFailReason } from "../useSimulateTxData";
22

3+
const G_DEST = "GA4UFF2WJM7KHHG4R5D5D2MZQ6FWMDOSVITVF7C5OLD5NFP6RBBW2FGV";
34
const t = (key: string) => key;
45

56
describe("getExpectedToFailReason", () => {
@@ -11,6 +12,8 @@ describe("getExpectedToFailReason", () => {
1112
assetCanonical:
1213
"USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
1314
amount: "10",
15+
destination: G_DEST,
16+
isCollectible: false,
1417
t,
1518
}),
1619
).toBeNull();
@@ -23,6 +26,8 @@ describe("getExpectedToFailReason", () => {
2326
assetCanonical:
2427
"USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
2528
amount: "10",
29+
destination: G_DEST,
30+
isCollectible: false,
2631
t,
2732
}),
2833
).toBeNull();
@@ -37,6 +42,8 @@ describe("getExpectedToFailReason", () => {
3742
assetCanonical:
3843
"USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
3944
amount: "10",
45+
destination: G_DEST,
46+
isCollectible: false,
4047
t,
4148
}),
4249
).toBe("Blockaid unfunded destination");
@@ -49,6 +56,8 @@ describe("getExpectedToFailReason", () => {
4956
assetCanonical:
5057
"LONGCODE12:GDMTVHLWJTHSUDMZVVMXXH6VJHA2ZV3HNG5LYNAZ6RTWB7GISM6PGTUV",
5158
amount: "10",
59+
destination: G_DEST,
60+
isCollectible: false,
5261
t,
5362
}),
5463
).toBe("Blockaid unfunded destination");
@@ -66,6 +75,8 @@ describe("getExpectedToFailReason", () => {
6675
assetCanonical:
6776
"PBT:CAZXRTOKNUQ2JQQF3NCRU7GYMDJNZ2NMQN6IGN4FCT5DWPODMPVEXSND",
6877
amount: "10",
78+
destination: G_DEST,
79+
isCollectible: false,
6980
t,
7081
}),
7182
).toBeNull();
@@ -78,6 +89,8 @@ describe("getExpectedToFailReason", () => {
7889
assetCanonical:
7990
"PBT:CAZXRTOKNUQ2JQQF3NCRU7GYMDJNZ2NMQN6IGN4FCT5DWPODMPVEXSND",
8091
amount: "0",
92+
destination: G_DEST,
93+
isCollectible: false,
8194
t,
8295
}),
8396
).toBeNull();
@@ -93,6 +106,8 @@ describe("getExpectedToFailReason", () => {
93106
assetCanonical:
94107
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef:lp",
95108
amount: "10",
109+
destination: G_DEST,
110+
isCollectible: false,
96111
t,
97112
}),
98113
).toBe("Blockaid unfunded destination");
@@ -106,6 +121,8 @@ describe("getExpectedToFailReason", () => {
106121
isDestinationFunded: false,
107122
assetCanonical: "native",
108123
amount: "0.5",
124+
destination: G_DEST,
125+
isCollectible: false,
109126
t,
110127
}),
111128
).toBe("Blockaid unfunded destination native");
@@ -117,6 +134,8 @@ describe("getExpectedToFailReason", () => {
117134
isDestinationFunded: false,
118135
assetCanonical: "native",
119136
amount: "1",
137+
destination: G_DEST,
138+
isCollectible: false,
120139
t,
121140
}),
122141
).toBeNull();
@@ -128,6 +147,8 @@ describe("getExpectedToFailReason", () => {
128147
isDestinationFunded: false,
129148
assetCanonical: "native",
130149
amount: "5",
150+
destination: G_DEST,
151+
isCollectible: false,
131152
t,
132153
}),
133154
).toBeNull();
@@ -139,9 +160,47 @@ describe("getExpectedToFailReason", () => {
139160
isDestinationFunded: false,
140161
assetCanonical: "native",
141162
amount: "",
163+
destination: G_DEST,
164+
isCollectible: false,
142165
t,
143166
}),
144167
).toBe("Blockaid unfunded destination native");
145168
});
146169
});
170+
171+
describe("destination is unfunded, collectibles", () => {
172+
it("returns null when isCollectible is true regardless of asset", () => {
173+
// Collectibles transfer via contract invocation; the destination
174+
// never needs to be a funded classic account.
175+
expect(
176+
getExpectedToFailReason({
177+
isDestinationFunded: false,
178+
assetCanonical: "native",
179+
destination: G_DEST,
180+
isCollectible: true,
181+
amount: "10",
182+
t,
183+
}),
184+
).toBeNull();
185+
});
186+
});
187+
188+
describe("destination is unfunded, contract destination", () => {
189+
it("returns null when destination is a contract address", () => {
190+
// Contract destinations have no classic account; the warning rule
191+
// does not apply regardless of the asset being sent.
192+
expect(
193+
getExpectedToFailReason({
194+
isDestinationFunded: false,
195+
assetCanonical:
196+
"USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
197+
destination:
198+
"CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
199+
isCollectible: false,
200+
amount: "10",
201+
t,
202+
}),
203+
).toBeNull();
204+
});
205+
});
147206
});

extension/src/popup/components/send/SendAmount/hooks/useSimulateTxData.tsx

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { findAddressBalance } from "popup/helpers/balance";
4444
import { AppDispatch, AppState } from "popup/App";
4545
import { useScanTx } from "popup/helpers/blockaid";
4646
import { cleanAmount } from "popup/helpers/formatters";
47+
import { shouldCheckUnfundedDestinationWarning } from "popup/helpers/sendWarnings";
4748
import {
4849
checkIsMuxedSupported,
4950
determineMuxedDestination,
@@ -76,40 +77,46 @@ interface SimSoroban {
7677
const CREATE_ACCOUNT_MIN_XLM = new BigNumber(1);
7778

7879
/**
79-
* Returns the translated user-facing reason why a transaction is expected to fail,
80-
* or null if no expected failure.
80+
* Returns the translated user-facing reason why a transaction is expected
81+
* to fail, or null if no expected failure.
8182
*
82-
* The unfunded-destination warning only applies to sends that use classic
83-
* Stellar account semantics: native XLM, credit_alphanum assets, and
84-
* Stellar Asset Contracts (SACs) wrapping those — all of which require a
85-
* funded classic destination. Pure Soroban custom tokens transfer via
86-
* contract invocation and don't touch the classic account ledger, so an
87-
* unfunded destination is not a failure condition for them.
88-
*
89-
* Asset ids encode the distinction: SACs normalize to their underlying
90-
* classic G-issuer, while pure Soroban custom tokens use a contract
91-
* (C-address) issuer.
83+
* The unfunded-destination warning rule (qualitative gate) is delegated to
84+
* `shouldCheckUnfundedDestinationWarning` in `popup/helpers/sendWarnings.ts`
85+
* so the same rule fires on both the Search-address screen
86+
* (`SendTo/index.tsx`) and here on Review. This branch overlays the
87+
* native ≥ 1 XLM create-account quantitative check, which only matters
88+
* once the amount is finalized.
9289
*/
9390
export const getExpectedToFailReason = ({
9491
isDestinationFunded,
9592
assetCanonical,
93+
destination,
94+
isCollectible,
9695
amount,
9796
t,
9897
}: {
9998
isDestinationFunded?: boolean;
10099
assetCanonical: string;
100+
destination: string;
101+
isCollectible: boolean;
101102
amount: string;
102103
t: (key: string) => string;
103104
}) => {
104105
if (isDestinationFunded !== false) {
105106
return null;
106107
}
107108

109+
if (
110+
!shouldCheckUnfundedDestinationWarning({
111+
assetCanonical,
112+
destination,
113+
isCollectible,
114+
})
115+
) {
116+
return null;
117+
}
118+
108119
if (assetCanonical !== "native") {
109-
const [, issuer] = assetCanonical.split(":");
110-
if (issuer && isContractId(issuer)) {
111-
return null;
112-
}
113120
return t("Blockaid unfunded destination");
114121
}
115122

@@ -408,7 +415,7 @@ function useSimulateTxData({
408415
const { t } = useTranslation();
409416
const reduxDispatch = useDispatch<AppDispatch>();
410417
const store = useStore();
411-
const { asset, amount, transactionFee, memo } = useSelector(
418+
const { asset, amount, transactionFee, memo, isCollectible } = useSelector(
412419
transactionDataSelector,
413420
);
414421

@@ -475,6 +482,8 @@ function useSimulateTxData({
475482
const expectedToFailReason = getExpectedToFailReason({
476483
isDestinationFunded: destBalancesResult.isFunded ?? undefined,
477484
assetCanonical: currentAsset,
485+
destination,
486+
isCollectible,
478487
amount: currentAmount,
479488
t,
480489
});

extension/src/popup/components/send/SendTo/index.tsx

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import React, { useEffect } from "react";
22
import { useDispatch, useSelector } from "react-redux";
3-
import { Asset, StrKey } from "stellar-sdk";
3+
import { StrKey } from "stellar-sdk";
44
import { useFormik } from "formik";
5-
import BigNumber from "bignumber.js";
65
import {
76
Button,
87
Input,
@@ -25,6 +24,7 @@ import { IdenticonImg } from "popup/components/identicons/IdenticonImg";
2524
import { FormRows } from "popup/basics/Forms";
2625
import { emitMetric } from "helpers/metrics";
2726
import { isContractId } from "popup/helpers/soroban";
27+
import { shouldShowAccountDoesntExistWarning } from "popup/helpers/sendWarnings";
2828
import { METRIC_NAMES } from "popup/constants/metricsNames";
2929
import { STELLAR_DOCS_CREATE_ACCOUNT_URL } from "popup/constants/externalLinks";
3030
import { View } from "popup/basics/layout/View";
@@ -46,17 +46,6 @@ import { reRouteOnboarding } from "popup/helpers/route";
4646

4747
import "../styles.scss";
4848

49-
const baseReserve = new BigNumber(1);
50-
51-
export const shouldAccountDoesntExistWarning = (
52-
isFunded: boolean,
53-
assetID: string,
54-
amount: string,
55-
) =>
56-
!isFunded &&
57-
(new BigNumber(amount).lt(baseReserve) ||
58-
assetID !== Asset.native().toString());
59-
6049
export const AccountDoesntExistWarning = () => {
6150
const { t } = useTranslation();
6251

@@ -108,7 +97,7 @@ export const SendTo = ({
10897
const { t } = useTranslation();
10998
const location = useLocation();
11099
const dispatch: AppDispatch = useDispatch<AppDispatch>();
111-
const { destination, federationAddress } = useSelector(
100+
const { destination, federationAddress, asset, isCollectible } = useSelector(
112101
transactionDataSelector,
113102
);
114103
const { state: sendDataState, fetchData } = useSendToData();
@@ -279,10 +268,13 @@ export const SendTo = ({
279268
<div>
280269
{formik.isValid ? (
281270
<>
282-
{sendDataState.data.destinationBalances &&
283-
!sendDataState.data.destinationBalances.isFunded && (
284-
<AccountDoesntExistWarning />
285-
)}
271+
{shouldShowAccountDoesntExistWarning({
272+
assetCanonical: asset,
273+
destination: sendDataState.data.validatedAddress,
274+
isCollectible,
275+
isFunded:
276+
sendDataState.data.destinationBalances?.isFunded,
277+
}) && <AccountDoesntExistWarning />}
286278
<div className="SendTo__subheading">
287279
<Icon.SearchLg />
288280
Suggestions

0 commit comments

Comments
 (0)