Skip to content
38 changes: 38 additions & 0 deletions @shared/api/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1178,11 +1178,16 @@ export const getAssetIcons = async ({
networkDetails,
assetsListsData,
cachedIcons,
additionalAssetIds,
}: {
balances: Balances;
networkDetails?: NetworkDetails;
assetsListsData?: AssetListResponse[];
cachedIcons: Record<string, string | null>;
// Canonicals to resolve alongside the held balances, even when the account
// doesn't hold them (e.g. the swap flow's default destination). Mirrors
// getTokenPrices' additionalAssetIds.
additionalAssetIds?: string[];
}) => {
const assetIcons = {} as { [code: string]: string | null };
const skipLookup = !assetsListsData || !networkDetails;
Expand Down Expand Up @@ -1242,6 +1247,39 @@ export const getAssetIcons = async ({
}
}

// Unheld extras run the same cache -> token lists -> issuer-toml chain as
// the held balances above (toml via the shared domainsToFetch batch below).
for (const canonical of additionalAssetIds || []) {
const [code, key] = canonical.split(":");
if (!key || canonical in assetIcons) {
// native (no issuer segment) or already covered by a held balance
continue;
}

const cachedIcon = cachedIcons[canonical];
if (cachedIcon) {
assetIcons[canonical] = cachedIcon;
continue;
}
if (cachedIcon === null || skipLookup) {
continue;
}

const tokenListIcon = await getIconFromTokenLists({
issuerId: isContractId(key) ? undefined : key,
contractId: isContractId(key) ? key : undefined,
code,
assetsListsData,
});
if (tokenListIcon.icon) {
assetIcons[canonical] = tokenListIcon.icon;
} else if (!isContractId(key)) {
domainsToFetch.push({ key, code });
} else {
assetIcons[canonical] = null;
}
}

if (domainsToFetch.length > 0 && networkDetails) {
const assetDomains = await getAssetDomains({
assetIssuerDomainsToFetch: domainsToFetch.map(({ key }) => key),
Expand Down
9 changes: 9 additions & 0 deletions @shared/constants/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@ export const DEFAULT_NETWORKS: Array<NetworkDetails> = [
TESTNET_NETWORK_DETAILS,
];

// Default swap destination ("You receive") per network. Only networks listed
// here get a default; on custom networks the picker starts empty.
export const DEFAULT_SWAP_DEST_CANONICAL: Partial<Record<NETWORKS, string>> = {
[NETWORKS.PUBLIC]:
"USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
[NETWORKS.TESTNET]:
"USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
};

export const BASE_RESERVE = 0.5 as const;
export const BASE_RESERVE_MIN_COUNT = 2 as const;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export type AssetDomains = NeedsReRoute | ResolvedAssetDomains;
export function useGetAssetDomainsWithBalances(getBalancesOptions: {
showHidden: boolean;
includeIcons: boolean;
additionalIconAssetIds?: string[];
}) {
const reduxDispatch = useDispatch<AppDispatch>();
const isSwap = useIsSwap();
Expand Down
34 changes: 24 additions & 10 deletions extension/src/helpers/hooks/useGetBalances.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,28 @@ const formatBalances = async ({
balances: NonNullable<BalanceMap>;
showHidden: boolean;
}) => {
const unfilteredBalances = sortBalances(balances);
if (!showHidden) {
const hiddenAssets = await getHiddenAssets({
activePublicKey: publicKey,
});
return sortBalances(
filterHiddenBalances(balances, hiddenAssets.hiddenAssets),
);
} else {
return sortBalances(balances);
return {
balances: sortBalances(
filterHiddenBalances(balances, hiddenAssets.hiddenAssets),
),
unfilteredBalances,
};
}
return { balances: unfilteredBalances, unfilteredBalances };
};

export interface AccountBalances {
balances: AssetType[];
// `balances` with no visibility filtering. Hidden assets are a display
// preference; anything that feeds transaction construction (e.g. "does a
// trustline already exist?") must consult this list, or a hidden held asset
// reads as unheld.
unfilteredBalances?: AssetType[];
isFunded: AccountBalancesInterface["isFunded"];
subentryCount: AccountBalancesInterface["subentryCount"];
error?: AccountBalancesInterface["error"];
Expand All @@ -62,6 +70,9 @@ export interface AccountBalances {
function useGetBalances(options: {
showHidden: boolean;
includeIcons: boolean;
// Canonicals to resolve icons for alongside the held balances (e.g. the
// swap flow's default destination, which the account may not hold).
additionalIconAssetIds?: string[];
}) {
const reduxDispatch = useDispatch<AppDispatch>();
const [state, dispatch] = useReducer(
Expand Down Expand Up @@ -96,15 +107,17 @@ function useGetBalances(options: {
shouldSkipScan,
);

const { balances, unfilteredBalances } = await formatBalances({
publicKey,
balances: accountBalances.balances as NonNullable<BalanceMap>,
showHidden: options.showHidden,
});
const payload = {
isFunded: accountBalances.isFunded,
subentryCount: accountBalances.subentryCount,
error: accountBalances.error,
balances: await formatBalances({
publicKey,
balances: accountBalances.balances as NonNullable<BalanceMap>,
showHidden: options.showHidden,
}),
balances,
unfilteredBalances,
} as AccountBalances;

if (options.includeIcons) {
Expand All @@ -129,6 +142,7 @@ function useGetBalances(options: {
networkDetails,
assetsListsData,
cachedIcons: cachedIconsFromCache,
additionalAssetIds: options.additionalIconAssetIds,
});
payload.icons = icons;
reduxDispatch(saveTokenLists(assetsListsData));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,43 @@ describe("ReviewTx trustline banner", () => {
).toBeInTheDocument();
});

it("renders the banner from the balances-derived flag when there is no pick-time snapshot (defaulted destination)", () => {
render(
<Wrapper state={{}} routes={["/"]}>
<ReviewTx
{...baseProps}
destinationTokenDetails={null}
requiresTrustline
/>
</Wrapper>,
);
// Token code falls back to the destination canonical (AQUA).
const banner = screen.getByTestId("review-tx-trustline-banner");
expect(banner).toBeInTheDocument();
fireEvent.click(banner);
expect(screen.getByTestId("trustline-info-sheet")).toBeInTheDocument();
});

it("lets the balances-derived flag override a stale snapshot", () => {
render(
<Wrapper state={{}} routes={["/"]}>
<ReviewTx
{...baseProps}
destinationTokenDetails={{
tokenCode: "AQUA",
requiresTrustline: true,
decimals: 7,
issuer: "GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA",
}}
requiresTrustline={false}
/>
</Wrapper>,
);
expect(
screen.queryByTestId("review-tx-trustline-banner"),
).not.toBeInTheDocument();
});

it("does not render the banner when no trustline is required", () => {
render(
<Wrapper state={{}} routes={["/"]}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ interface ReviewTxProps {
// Friendly per-feature reasons from the source token scan, listed in the
// expandable Blockaid pane alongside the transaction-scan reasons.
sourceTokenSecurityWarnings?: BlockaidWarning[];
// Balances-derived "this swap adds a trustline" flag. The pick-time snapshot
// above is absent for defaulted/deep-linked destinations, so callers that
// derive the flag from holdings pass it here; when omitted, the snapshot's
// flag applies (legacy behavior).
requiresTrustline?: boolean;
}

export const ReviewTx = ({
Expand All @@ -119,6 +124,7 @@ export const ReviewTx = ({
destinationTokenDetails,
sourceTokenSecurityLevel,
sourceTokenSecurityWarnings,
requiresTrustline: requiresTrustlineProp,
}: ReviewTxProps) => {
const { t } = useTranslation();
const dispatch = useDispatch();
Expand Down Expand Up @@ -311,7 +317,13 @@ export const ReviewTx = ({
const [isOnFeesPane, setIsOnFeesPane] = useState(false);
const [isOnMemoPane, setIsOnMemoPane] = useState(false);

const requiresTrustline = !!destinationTokenDetails?.requiresTrustline;
const requiresTrustline =
requiresTrustlineProp ?? !!destinationTokenDetails?.requiresTrustline;
// A defaulted/deep-linked destination has no pick-time snapshot, so the
// banner/sheet token code falls back to the destination canonical.
const trustlineTokenCode =
destinationTokenDetails?.tokenCode ||
(dstAsset ? getAssetFromCanonical(dstAsset.canonical).code : "");
const [isOnTrustlinePane, setIsOnTrustlinePane] = useState(false);

// Extract contract ID for custom tokens or collectibles
Expand Down Expand Up @@ -470,7 +482,7 @@ export const ReviewTx = ({
)}
{requiresTrustline && (
<TrustlineBanner
tokenCode={destinationTokenDetails!.tokenCode}
tokenCode={trustlineTokenCode}
onClick={() => setIsOnTrustlinePane(true)}
/>
)}
Expand Down Expand Up @@ -683,7 +695,7 @@ export const ReviewTx = ({
modal and appear in place. */}
{isOnTrustlinePane ? (
<TrustlineInfoSheet
tokenCode={destinationTokenDetails?.tokenCode || ""}
tokenCode={trustlineTokenCode}
onClose={() => setIsOnTrustlinePane(false)}
/>
) : isOnBlockaidSheet ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { NetworkDetails } from "@shared/constants/stellar";
import { emitMetric } from "helpers/metrics";
import { METRIC_NAMES } from "popup/constants/metricsNames";
import { getAssetFromCanonical, isMainnet } from "helpers/stellar";
import { getSdk } from "@shared/helpers/stellar";
import { AssetIcons } from "@shared/api/types";
import { allAccountsSelector } from "popup/ducks/accountServices";

Expand Down Expand Up @@ -57,7 +58,6 @@ function useSubmitTxData({
destination,
federationAddress,
destinationAsset,
destinationTokenDetails,
isCollectible,
collectibleData,
},
Expand Down Expand Up @@ -110,11 +110,24 @@ function useSubmitTxData({
to_asset_code: getAssetFromCanonical(destinationAsset).code,
});
// Trustline added only once the combined changeTrust +
// pathPaymentStrictSend transaction confirmed it.
if (destinationTokenDetails?.requiresTrustline) {
// pathPaymentStrictSend transaction confirmed it. Gate on the
// submitted transaction itself rather than the pick-time snapshot —
// a defaulted/deep-linked destination has no snapshot, but the
// changeTrust op it confirmed is right there in the XDR.
const Sdk = getSdk(networkDetails.networkPassphrase);
const submittedTx = Sdk.TransactionBuilder.fromXDR(
signedXDR,
networkDetails.networkPassphrase,
);
const changeTrustOp =
"operations" in submittedTx
? submittedTx.operations.find((op) => op.type === "changeTrust")
: undefined;
if (changeTrustOp && "line" in changeTrustOp) {
const { line } = changeTrustOp;
emitMetric(METRIC_NAMES.swapTrustlineAdded, {
asset_code: destinationTokenDetails.tokenCode,
asset_issuer: destinationTokenDetails.issuer,
asset_code: "code" in line ? line.code : undefined,
asset_issuer: "issuer" in line ? line.issuer : undefined,
});
}
} else {
Expand Down
3 changes: 0 additions & 3 deletions extension/src/popup/components/amount/AmountCard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -256,9 +256,6 @@ export const AmountCard = ({
</>
) : (
<>
<span className="AmountCard__select-icon">
<Icon.Plus />
</span>
<span className="AmountCard__asset-code">{t("Select")}</span>
<Icon.ChevronDown />
</>
Expand Down
27 changes: 5 additions & 22 deletions extension/src/popup/components/amount/AmountCard/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,16 @@
margin-bottom: 0;
border: 0;
color: var(--sds-clr-gray-12);
min-height: pxToRem(36px);

&:hover {
background-color: var(--sds-clr-gray-06);
}

&--empty {
padding: pxToRem(4px) pxToRem(8px) pxToRem(4px) pxToRem(10px);
}

.AccountAssets__asset--logo {
width: pxToRem(20px) !important;
height: pxToRem(20px) !important;
Expand Down Expand Up @@ -151,28 +156,6 @@
}
}

// Empty "+ Select" state (e.g. the swap receive card before a token is
// picked): a circular badge the same footprint (20px + 4px margin) as the
// asset logo, with a small plus on a slightly lighter background.
&__select-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: pxToRem(20px);
height: pxToRem(20px);
margin: pxToRem(4px);
border-radius: 50%;
background-color: var(--sds-clr-gray-04);
// Muted plus, matching the "You receive" label color.
color: var(--sds-clr-gray-11);

svg {
width: pxToRem(14px);
height: pxToRem(14px);
}
}

&__asset-code {
font-size: pxToRem(14px);
font-weight: var(--font-weight-medium);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ interface GetSwapDerivedDataParams {
networkDetails: NetworkDetails;
inputType: InputType;
isLiveQuoteLoading: boolean;
// Balances-derived "this swap adds a trustline", checked against the
// UNFILTERED balances (a hidden held asset needs no new trustline). Drives
// the reserve math; distinct from destinationIsNonHeld below, which is a
// display-list concept for the direction toggle.
destRequiresTrustline: boolean;
}

/**
Expand All @@ -74,6 +79,7 @@ export const getSwapDerivedData = ({
networkDetails,
inputType,
isLiveQuoteLoading,
destRequiresTrustline,
}: GetSwapDerivedDataParams) => {
const sendData = data;
const assetIcon = sendData.icons[asset];
Expand Down Expand Up @@ -124,7 +130,9 @@ export const getSwapDerivedData = ({
const availableBalance = deductNewTrustlineReserve({
spendable: baseAvailableBalance,
sourceIsXlm: asset === "native",
requiresTrustline: destinationTokenDetails?.requiresTrustline ?? false,
// The unfiltered-balances flag, NOT destinationIsNonHeld: a hidden held
// destination builds no changeTrust, so no reserve should be withheld.
requiresTrustline: destRequiresTrustline,
});
const displayTotal = `${formatAmount(availableBalance)}`;

Expand Down
Loading
Loading