Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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 @@ -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,180 @@
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(),
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,
};

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('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(),
});
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);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { useRef } from 'react';

import { equalTokenNoCaseSensitive } from '@onekeyhq/shared/src/utils/tokenUtils';
import type { ESwapTabSwitchType } from '@onekeyhq/shared/types/swap/types';

import {
buildSwapRecipientRequiredScopeKey,
resolveSettledSwapRecipientRequired,
} from './useSwapAccount.utils';

type ISwapRecipientScopeToken = {
networkId?: string;
contractAddress?: string;
};

type IUseSettledSwapRecipientRequiredParams = {
swapType: ESwapTabSwitchType;
fromToken?: ISwapRecipientScopeToken;
toToken?: ISwapRecipientScopeToken;
sourceAccountId?: string;
/**
* The currently selected quote, straight from the selection atom. The
* selection layer intentionally keeps a previous actionable quote visible
* while a new round is requesting, so this hook never trusts its presence
* alone — it only counts as settled for the scope when its token pair
* matches the current selection.
*/
quoteResult?: {
toAmount?: string;
fromTokenInfo?: ISwapRecipientScopeToken;
toTokenInfo?: ISwapRecipientScopeToken;
};
/** The current input's quote round completed with no result at all. */
quoteSettledWithoutResult: boolean;
isAddressInfoReady: boolean;
hasTargetAddress: boolean;
noConnectWallet: boolean;
};

/**
* Whether the recipient entry must be shown, held steady across a quote cycle.
*
* The raw verdict flips false during every quote refresh window, which would
* collapse and re-expand the entry on each round; holding it keeps the row
* still. The hold is scoped to one quote round (tab, pair, source account),
* and a verdict is only adopted from a quote that belongs to that scope: the
* selection layer can retain the previous pair's actionable quote during the
* transition renders right after a switch, and that quote must not decide the
* new scope. (OK-58326)
*/
export function useSettledSwapRecipientRequired({
swapType,
fromToken,
toToken,
sourceAccountId,
quoteResult,
quoteSettledWithoutResult,
isAddressInfoReady,
hasTargetAddress,
noConnectWallet,
}: IUseSettledSwapRecipientRequiredParams) {
const scopeKey = buildSwapRecipientRequiredScopeKey({
swapType,
fromToken,
toToken,
sourceAccountId,
});
const quoteMatchesSelectedPair = Boolean(
quoteResult &&
equalTokenNoCaseSensitive({
token1: quoteResult.fromTokenInfo,
token2: fromToken,
}) &&
equalTokenNoCaseSensitive({
token1: quoteResult.toTokenInfo,
token2: toToken,
}),
);
const quoteSettled = quoteMatchesSelectedPair || quoteSettledWithoutResult;
const recipientRequiredNow = Boolean(
quoteMatchesSelectedPair &&
quoteResult?.toAmount &&
!hasTargetAddress &&
!noConnectWallet,
);
// Start neutral: every adoption, including the mount render, must pass the
// resolver's settled + address-ready gates.
const settledRef = useRef({ scopeKey, value: false });
settledRef.current = resolveSettledSwapRecipientRequired({
previous: settledRef.current,
scopeKey,
quoteSettled,
isAddressInfoReady,
recipientRequiredNow,
});
return settledRef.current.value;
}
Loading
Loading