Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
1379973
fix: restore swap history order id with explorer link(OK-59978)
haicongliao Aug 12, 2026
d0f6759
fix: show explorer link on same-chain swap history tx hash
haicongliao Aug 12, 2026
97bc201
fix: use provider order id for swap history order id link(OK-59978)
haicongliao Aug 12, 2026
e064fa6
fix: shorten swap history order id display with full-value copy(OK-59…
haicongliao Aug 12, 2026
f07a696
fix: widen swap history order id abbreviation to one line(OK-59978)
haicongliao Aug 12, 2026
35351c4
fix: force show swap recipient entry when recipient is required(OK-58…
haicongliao Aug 12, 2026
3e3cb7f
fix: measure SwapSmoothReveal content with absolute position on nativ…
haicongliao Aug 12, 2026
8a9c32b
fix: hold swap recipient entry state across quote refresh cycles(OK-5…
haicongliao Aug 12, 2026
0c5c207
fix: address swap order id and recipient entry review feedback(OK-599…
haicongliao Aug 12, 2026
1a589fa
test: cover swap recipient required hold across real hook lifecycle(O…
haicongliao Aug 12, 2026
81407db
docs: align SwapSmoothReveal comment with native-only measurement(OK-…
haicongliao Aug 13, 2026
46a89f2
fix: adopt swap recipient verdict on the scope-changing render(OK-58326)
haicongliao Aug 13, 2026
89d0528
fix: gate recipient verdict on pair-matched quotes and guard order ur…
haicongliao Aug 15, 2026
6bfa6f6
fix: require active-request proof before adopting recipient verdict(O…
haicongliao Aug 15, 2026
fb0c993
fix: treat quote request-starting interval as unproven for recipient …
haicongliao Aug 15, 2026
510f6e0
Merge branch 'x' into fix/swap-order-id-and-recipient-entry
zhaono1 Aug 17, 2026
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 @@ -19,6 +19,7 @@ import {
isSwapQuoteInputAmountMatched,
isSwapQuoteInputAmountValid,
isSwapQuoteManualRefreshRequired,
isSwapQuoteProvenForCurrentRequest,
isSwapQuoteRequestForCurrentInput,
isSwapZeroProviderQuoteCompleted,
resolveSwapQuoteForDisplay,
Expand Down Expand Up @@ -1118,3 +1119,76 @@ describe('swap quote progress', () => {
expect(selectedQuote).toBe(manualErrorQuote);
});
});

describe('isSwapQuoteProvenForCurrentRequest', () => {
const retainedQuote = buildQuote({ eventId: 'event-1', provider: 'p1' });

it('proves an idle settled quote from the current event', () => {
expect(
isSwapQuoteProvenForCurrentRequest({
quote: retainedQuote,
quoteEventTotalCount: { count: 1, eventId: 'event-1' },
quoteLoading: false,
quoteEventFetching: false,
quoteActionLocked: false,
requestMatchesCurrentInput: true,
}),
).toBe(true);
});

it('rejects the request-starting interval before the event reports (OK-58326)', () => {
// quoteAction clears the event id and writes the new matching lock BEFORE
// runQuoteEvent flips the loading flags. A same-pair previous-account
// quote is still selected in that interval and must stay unproven even
// though the no-event-id fallback and the lock match would both pass.
expect(
isSwapQuoteProvenForCurrentRequest({
quote: retainedQuote,
quoteEventTotalCount: { count: 0 },
quoteLoading: false,
quoteEventFetching: false,
quoteActionLocked: true,
requestMatchesCurrentInput: true,
}),
).toBe(false);
});

it('rejects a previous-event quote once the new event reports its id', () => {
expect(
isSwapQuoteProvenForCurrentRequest({
quote: retainedQuote,
quoteEventTotalCount: { count: 1, eventId: 'event-2' },
quoteLoading: false,
quoteEventFetching: true,
quoteActionLocked: true,
requestMatchesCurrentInput: true,
}),
).toBe(false);
});

it('proves streamed quotes of the active event while it is still locked', () => {
expect(
isSwapQuoteProvenForCurrentRequest({
quote: buildQuote({ eventId: 'event-2', provider: 'p1' }),
quoteEventTotalCount: { count: 1, eventId: 'event-2' },
quoteLoading: false,
quoteEventFetching: true,
quoteActionLocked: true,
requestMatchesCurrentInput: true,
}),
).toBe(true);
});

it('rejects any quote when the request lock no longer matches the input', () => {
expect(
isSwapQuoteProvenForCurrentRequest({
quote: retainedQuote,
quoteEventTotalCount: { count: 1, eventId: 'event-1' },
quoteLoading: false,
quoteEventFetching: false,
quoteActionLocked: false,
requestMatchesCurrentInput: false,
}),
).toBe(false);
});
});
38 changes: 38 additions & 0 deletions packages/kit/src/states/jotai/contexts/swap/quoteProgress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,44 @@ export function isSwapQuoteFromCurrentEvent({
return !quoteLoading && !quoteEventFetching;
}

/**
* Proof that the selected quote belongs to the active quote round for the
* current inputs. Pair equality alone cannot provide this, and neither can
* event membership plus a lock match on their own: quoteAction's starting
* interval clears the event id and writes the new lock BEFORE runQuoteEvent
* flips the loading flags, so a retained previous quote would pass the
* no-event-id fallback while the freshly written lock matches the current
* input. That interval is identified by actionLock with no event id yet and
* counts as unproven.
*/
export function isSwapQuoteProvenForCurrentRequest({
quote,
quoteEventTotalCount,
quoteLoading,
quoteEventFetching,
quoteActionLocked,
requestMatchesCurrentInput,
}: {
quote?: IFetchQuoteResult;
quoteEventTotalCount: ISwapQuoteEventTotalCount;
quoteLoading: boolean;
quoteEventFetching: boolean;
quoteActionLocked: boolean;
requestMatchesCurrentInput: boolean;
}) {
if (quoteActionLocked && !quoteEventTotalCount.eventId) {
return false;
}
return (
isSwapQuoteFromCurrentEvent({
quote,
quoteEventTotalCount,
quoteLoading,
quoteEventFetching,
}) && requestMatchesCurrentInput
);
}

export function selectSwapPreviousActionableQuote({
quotes,
quoteEventTotalCount,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export function InfoItem({
showCopy = false,
openWithUrl,
disabledCopy = false,
copyContent,
...rest
}: {
label?: string | ReactNode;
Expand All @@ -39,6 +40,9 @@ export function InfoItem({
disabledCopy?: boolean;
showCopy?: boolean;
openWithUrl?: () => void;
// Copy this value instead of renderContent, e.g. when renderContent is a
// shortened display of the full value.
copyContent?: string;
} & IStackProps) {
const intl = useIntl();
const { copyText } = useClipboard();
Expand Down Expand Up @@ -107,7 +111,7 @@ export function InfoItem({
size="small"
onPress={() => {
if (!disabledCopy) {
copyText(renderContent);
copyText(copyContent ?? renderContent);
}
}}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { renderHook } from '@testing-library/react-native';

import { ESwapTabSwitchType } from '@onekeyhq/shared/types/swap/types';

import { useSettledSwapRecipientRequired } from './useSettledSwapRecipientRequired';

const ETH = { networkId: 'evm--1', contractAddress: '' };
const USDC = { networkId: 'evm--1', contractAddress: '0xusdc' };
const SOL = { networkId: 'sol--101', contractAddress: '' };

type IProps = Parameters<typeof useSettledSwapRecipientRequired>[0];

function buildQuote({
fromTokenInfo = ETH,
toTokenInfo = SOL,
toAmount = '1',
}: Partial<NonNullable<IProps['quoteResult']>> = {}) {
return { fromTokenInfo, toTokenInfo, toAmount };
}

// A quote round that settled for ETH -> SOL and needs a manual recipient
// (single-network private-key wallet: no target-chain address).
const settledRoundRequiringRecipient: IProps = {
swapType: ESwapTabSwitchType.SWAP,
fromToken: ETH,
toToken: SOL,
sourceAccountId: 'account-1',
quoteResult: buildQuote(),
quoteProvenForCurrentInput: true,
quoteSettledWithoutResult: false,
isAddressInfoReady: true,
hasTargetAddress: false,
noConnectWallet: false,
};

// The refresh window of the same round: no quote is selected while requesting.
const quotingRound: IProps = {
...settledRoundRequiringRecipient,
quoteResult: undefined,
quoteProvenForCurrentInput: false,
};

function renderSettled(initialProps: IProps) {
return renderHook((props: IProps) => useSettledSwapRecipientRequired(props), {
initialProps,
});
}

describe('useSettledSwapRecipientRequired', () => {
it('holds the verdict through a full quote refresh cycle', () => {
const { result, rerender } = renderSettled(settledRoundRequiringRecipient);
expect(result.current).toBe(true);

// Quote expires and a refresh starts: no quote is selected while the new
// round is requesting. The entry must not collapse here.
rerender(quotingRound);
expect(result.current).toBe(true);

// The new quote for the same pair settles and still needs a recipient.
rerender(settledRoundRequiringRecipient);
expect(result.current).toBe(true);
});

it('adopts the new verdict when the settled outcome actually changes', () => {
const { result, rerender } = renderSettled(settledRoundRequiringRecipient);
expect(result.current).toBe(true);

rerender({ ...settledRoundRequiringRecipient, hasTargetAddress: true });
expect(result.current).toBe(false);
});

it('ignores a retained previous-pair quote right after a pair switch', () => {
// The selection layer intentionally keeps the previous actionable quote
// visible while the next round is requesting. On the render where the
// pair has changed but the old ETH->SOL quote is still selected, that
// quote must not decide the ETH->USDC scope.
const { result, rerender } = renderSettled(settledRoundRequiringRecipient);
expect(result.current).toBe(true);

rerender({
...settledRoundRequiringRecipient,
toToken: USDC,
// Old pair's quote still selected during the transition render.
quoteResult: buildQuote({ toTokenInfo: SOL }),
// Same-network target: an address exists, no recipient needed.
hasTargetAddress: true,
});
expect(result.current).toBe(false);

// And the stale quote alone cannot resurrect the verdict later either.
rerender({
...settledRoundRequiringRecipient,
toToken: USDC,
quoteResult: buildQuote({ toTokenInfo: SOL }),
hasTargetAddress: false,
});
expect(result.current).toBe(false);
});

it('drops a Swap verdict when switching to Limit before any quote settles', () => {
// swapTypeSwitchAction clears the quote list and quoteEventCompleted, so
// the next render has no settled quote while both tokens stay selected.
const { result, rerender } = renderSettled(settledRoundRequiringRecipient);
expect(result.current).toBe(true);

rerender({
...quotingRound,
swapType: ESwapTabSwitchType.LIMIT,
});
expect(result.current).toBe(false);
});

it('drops the verdict when the source account changes mid-flight', () => {
const { result, rerender } = renderSettled(settledRoundRequiringRecipient);
expect(result.current).toBe(true);

rerender({
...quotingRound,
sourceAccountId: 'account-2',
});
expect(result.current).toBe(false);
});

it('ignores a same-pair quote retained across an account switch', () => {
// A same-pair account switch keeps the previous account's quote selected
// AND its event registered as "current" until the new request is written,
// so pair equality passes while the active-request proof must fail. The
// previous account's verdict must not decide the new account's scope.
const { result, rerender } = renderSettled(settledRoundRequiringRecipient);
expect(result.current).toBe(true);

rerender({
...settledRoundRequiringRecipient,
sourceAccountId: 'account-2',
quoteProvenForCurrentInput: false,
});
expect(result.current).toBe(false);

// The new account's own round settles: verdict re-establishes normally.
rerender({
...settledRoundRequiringRecipient,
sourceAccountId: 'account-2',
});
expect(result.current).toBe(true);
});

it('re-establishes the verdict once the new scope settles its own quote', () => {
const { result, rerender } = renderSettled(settledRoundRequiringRecipient);

const limitScopeQuoting: IProps = {
...quotingRound,
swapType: ESwapTabSwitchType.LIMIT,
};
rerender(limitScopeQuoting);
expect(result.current).toBe(false);

rerender({
...limitScopeQuoting,
quoteResult: buildQuote(),
quoteProvenForCurrentInput: true,
});
expect(result.current).toBe(true);
});

it('shows the entry when the account resolves while a quote already needs one', () => {
// The source account id starts undefined and resolves on a later render.
// That render changes the scope key and carries settled inputs at once;
// since the verdict lives in a ref, dropping it would leave the entry
// hidden with no follow-up render to recover it.
const { result, rerender } = renderSettled({
...quotingRound,
sourceAccountId: undefined,
});
expect(result.current).toBe(false);

rerender(settledRoundRequiringRecipient);
expect(result.current).toBe(true);

// And it stays put through the following quote refresh window.
rerender(quotingRound);
expect(result.current).toBe(true);
});

it('waits for target address resolution before adopting a verdict', () => {
const { result, rerender } = renderSettled({
...settledRoundRequiringRecipient,
isAddressInfoReady: false,
});
// Address resolution still pending: nothing to adopt yet.
expect(result.current).toBe(false);

rerender(settledRoundRequiringRecipient);
expect(result.current).toBe(true);
});

it('treats a no-result settlement as a definitive not-required verdict', () => {
const { result, rerender } = renderSettled(settledRoundRequiringRecipient);
expect(result.current).toBe(true);

rerender({
...quotingRound,
quoteSettledWithoutResult: true,
});
expect(result.current).toBe(false);
});
});
Loading
Loading