Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,37 @@ export function useQuickBuyController(
liveSourceCurrencyExchangeRate && liveSourceCurrencyExchangeRate > 0,
);

// Buy mode freezes the quote conversion rate via the pay-with `useState`
// snapshot (`selectedSourceToken`). Sell mode's `positionToken` is
// selector-driven, so without an explicit freeze every market-data tick
// retargets `sourceTokenAmount` for the same fiat input — quotes look
// stale/`isPendingQuoteRefresh` and Sell stays disabled with no error label
// (TSA-976). Keep display rates live above; freeze only the rate used to
// convert committed fiat into the quote request amount.
const sellQuoteExchangeRateRef = useRef<number | undefined>(undefined);
const sellQuoteSourceTokenKeyRef = useRef<string | undefined>(undefined);
const positionTokenKey =
positionToken?.address != null && positionToken.chainId != null
? getTokenKey(positionToken)
: undefined;
if (positionTokenKey !== sellQuoteSourceTokenKeyRef.current) {
sellQuoteSourceTokenKeyRef.current = positionTokenKey;
sellQuoteExchangeRateRef.current = positionToken?.currencyExchangeRate;
} else if (
sellQuoteExchangeRateRef.current == null &&
positionToken?.currencyExchangeRate != null &&
positionToken.currencyExchangeRate > 0
) {
// Price arrived after the token was already selected — adopt it once so
// the first committed amount can convert; later ticks stay frozen.
sellQuoteExchangeRateRef.current = positionToken.currencyExchangeRate;
}
const sellQuoteExchangeRate = sellQuoteExchangeRateRef.current;
const quoteSourceExchangeRate =
tradeMode === 'sell'
? sellQuoteExchangeRate
: sourceToken?.currencyExchangeRate;

// The live balance for whichever token is the *source* this mode: the
// resynced pay-with token in buy mode, or the already-live position token in
// sell mode.
Expand Down Expand Up @@ -632,14 +663,14 @@ export function useQuickBuyController(
return latestSourceBalance.displayBalance;
}
if (hasSourcePrice) {
if (!quotedFiatAmount || !sourceToken?.currencyExchangeRate) {
if (!quotedFiatAmount || !quoteSourceExchangeRate) {
return undefined;
}
// `currencyExchangeRate` is user-currency-per-token and `quotedFiatAmount`
// is in the user's display currency, so fiat / rate yields token units.
const fiat = parseFloat(quotedFiatAmount);
if (isNaN(fiat) || fiat <= 0) return undefined;
return (fiat / sourceToken.currencyExchangeRate).toString();
return (fiat / quoteSourceExchangeRate).toString();
}
// Unpriced path: source amount is entered directly in token units.
if (!sourceAmountTokens) return undefined;
Expand All @@ -650,9 +681,9 @@ export function useQuickBuyController(
hasSourcePrice,
isMaxSourceAmount,
latestSourceBalance?.displayBalance,
quoteSourceExchangeRate,
quotedFiatAmount,
sourceAmountTokens,
sourceToken?.currencyExchangeRate,
]);

useEffect(() => {
Expand Down Expand Up @@ -1627,11 +1658,27 @@ export function useQuickBuyController(
sourceTokenAmount,
sourceToken.decimals,
).toFixed(0);
const sent = calcTokenValue(
activeQuote.sentAmount?.amount,
sourceToken.decimals,
).toFixed(0);
return sent === requested;
// Prefer `sentAmount` (full wallet deduction). Required for gas-included /
// gas-sponsored quotes where `quote.srcTokenAmount` is the post-fee
// routing amount and would never equal the request.
const sentAmountDecimal = activeQuote.sentAmount?.amount;
if (sentAmountDecimal != null && sentAmountDecimal !== '') {
const sent = calcTokenValue(
sentAmountDecimal,
sourceToken.decimals,
).toFixed(0);
if (sent === requested) {
return true;
}
}
// Fallback when `sentAmount` is missing (partial QuoteMetadata) or inflated
// by src-token protocol fees on top of an already-full request amount:
// match the quote's atomic routing amount against the request.
const srcTokenAmountAtomic = activeQuote.quote?.srcTokenAmount;
return (
srcTokenAmountAtomic != null &&
String(srcTokenAmountAtomic) === requested
);
} catch {
return false;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1537,6 +1537,48 @@ describe('useQuickBuyController', () => {
expect(result.current.isConfirmDisabled).toBe(false);
});

it('enables the CTA when sentAmount is missing but quote.srcTokenAmount matches the request', () => {
// Partial QuoteMetadata (#33559) can omit sentAmount. Without a fallback
// to quote.srcTokenAmount the CTA stayed disabled with a valid quote.
const quoteState: UseQuickBuyQuotesResult = {
activeQuote: undefined,
destTokenAmount: undefined,
isQuoteLoading: false,
isNoQuotesAvailable: false,
quoteFetchError: null,
isActiveQuoteForCurrentTokenPair: true,
isQuoteRequestStale: false,
sortedQuotes: [],
quoteCount: 0,
quotesLastFetchedAt: null,
refreshCount: 0,
quoteRefreshRateMs: 30000,
maxRefreshCount: 5,
refetchQuotes: jest.fn(),
};
(useQuickBuyQuotes as jest.Mock).mockImplementation(() => quoteState);

const props = {
target: createTarget(),
onClose: jest.fn(),
};
const { result, rerender } = renderHook(
({ target, onClose }) => useQuickBuyController(target, onClose),
{ initialProps: props },
);

act(() => {
result.current.handleAmountChange('20');
});
quoteState.activeQuote = createActiveQuote({
quote: { srcTokenAmount: '10000000000000000' },
sentAmount: { amount: undefined },
});
rerender(props);

expect(result.current.isConfirmDisabled).toBe(false);
});

it('enables the CTA for a gas-included quote whose srcTokenAmount is the post-fee amount', () => {
const quoteState: UseQuickBuyQuotesResult = {
activeQuote: undefined,
Expand Down Expand Up @@ -2389,13 +2431,92 @@ describe('useQuickBuyController', () => {
});

describe('sell mode availability', () => {
const createPositionToken = () =>
const createPositionToken = (overrides: Partial<BridgeToken> = {}) =>
createSourceToken({
address: '0xDEST',
chainId: '0x1',
symbol: 'TARGET',
...overrides,
});

const createSellReceiveNative = () =>
createSourceToken({
address: '0x0000000000000000000000000000000000000000',
chainId: '0x1',
symbol: 'ETH',
currencyExchangeRate: 2000,
});

it('keeps Sell enabled when the position token price ticks after a quote settles', () => {
// Regression (TSA-976): sell mode derived sourceTokenAmount from the live
// position-token rate. Market-data ticks changed the amount for the same
// fiat input, so isPendingQuoteRefresh stayed true and Sell remained
// disabled even with a valid quote on screen (no error label).
let positionToken = createPositionToken({ currencyExchangeRate: 2000 });
(usePositionTokenBalance as jest.Mock).mockImplementation(
() => positionToken,
);
(useReceiveTokens as jest.Mock).mockReturnValue([
createSellReceiveNative(),
]);
(useLatestBalance as jest.Mock).mockReturnValue({
displayBalance: '1.0',
atomicBalance: '1000000000000000000',
});

const quoteState: UseQuickBuyQuotesResult = {
activeQuote: undefined,
destTokenAmount: undefined,
isQuoteLoading: false,
isNoQuotesAvailable: false,
quoteFetchError: null,
isActiveQuoteForCurrentTokenPair: true,
isQuoteRequestStale: false,
sortedQuotes: [],
quoteCount: 0,
quotesLastFetchedAt: null,
refreshCount: 0,
quoteRefreshRateMs: 30000,
maxRefreshCount: 5,
refetchQuotes: jest.fn(),
};
(useQuickBuyQuotes as jest.Mock).mockImplementation(() => quoteState);

const props = {
target: createTarget(),
onClose: jest.fn(),
};
const { result, rerender } = renderHook(
({ target, onClose }) => useQuickBuyController(target, onClose),
{ initialProps: props },
);

act(() => {
result.current.setTradeMode('sell');
});
act(() => {
result.current.handleAmountChange('20');
});

const committedSourceAmount = result.current.sourceTokenAmount;
expect(committedSourceAmount).toBe('0.01');

quoteState.activeQuote = createActiveQuote({
sentAmount: { amount: '0.01' },
});
rerender(props);
expect(result.current.isConfirmDisabled).toBe(false);

// Price tick: same token identity, new exchange rate. The committed sell
// amount (and CTA) must stay stable — buy mode gets this for free via the
// pay-with useState snapshot; sell must freeze the quote conversion rate.
positionToken = createPositionToken({ currencyExchangeRate: 2500 });
rerender(props);

expect(result.current.sourceTokenAmount).toBe(committedSourceAmount);
expect(result.current.isConfirmDisabled).toBe(false);
});

it('resets tradeMode to buy when the position token balance becomes zero', () => {
(usePositionTokenBalance as jest.Mock).mockReturnValue(
createPositionToken(),
Expand Down
Loading