feat: abstract target/output to use it in native

test: implement test for createTargets
This commit is contained in:
Peter Sanderson
2026-02-09 13:58:33 +01:00
committed by Peter Sanderson
parent f373e4c9f5
commit 5da09763f3
20 changed files with 341 additions and 84 deletions

View File

@@ -187,7 +187,7 @@ const addAccountMetadata: Fixture<(typeof metadataLabelingActions)['addAccountMe
type: 'outputLabel',
entityKey: 'account',
txid: 'TXID',
outputIndex: 0,
outputIndex: '0',
value: 'Foo',
accountDescriptor: 'xpub...',
networkSymbol: 'btc',
@@ -208,7 +208,7 @@ const addAccountMetadata: Fixture<(typeof metadataLabelingActions)['addAccountMe
type: 'outputLabel',
entityKey: 'account',
txid: 'TXID',
outputIndex: 0,
outputIndex: '0',
value: 'Foo',
accountDescriptor: 'xpub...',
networkSymbol: 'btc',
@@ -255,7 +255,7 @@ const addAccountMetadata: Fixture<(typeof metadataLabelingActions)['addAccountMe
type: 'outputLabel',
entityKey: 'account',
txid: 'TXID',
outputIndex: 0,
outputIndex: '0',
value: 'Foo',
accountDescriptor: 'xpub...',
networkSymbol: 'btc',
@@ -324,7 +324,7 @@ const addAccountMetadata: Fixture<(typeof metadataLabelingActions)['addAccountMe
type: 'outputLabel',
entityKey: 'account',
txid: 'TXID',
outputIndex: 0,
outputIndex: '0',
value: '', // empty string removes value
accountDescriptor: 'xpub...',
networkSymbol: 'btc',

View File

@@ -4,6 +4,7 @@ import { RbfLabelsToBeUpdated } from '@suite-common/suite-rbf-labels-migrations-
import type { NetworkSymbol } from '@suite-common/wallet-config';
import { selectAccountByKey } from '@suite-common/wallet-core';
import { AccountKey } from '@suite-common/wallet-types';
import { typedObjectKeys } from '@trezor/utils';
import * as metadataLabelingActions from 'src/actions/suite/metadata/metadataLabelingActions';
import { Dispatch } from 'src/types/suite';
@@ -28,13 +29,13 @@ export const deleteDanglingLabels = async ({
networkSymbol,
txid,
}: DeleteAllOutputLabelsParams) => {
for (const outputIndex of Object.keys(labels)) {
for (const outputIndex of typedObjectKeys(labels)) {
await dispatch(
metadataLabelingActions.addMetadata({
type: 'outputLabel',
entityKey: accountKey,
txid,
outputIndex: Number(outputIndex),
outputIndex: `${outputIndex}`,
defaultValue: '',
value: '',
accountDescriptor,
@@ -61,7 +62,7 @@ export const copyLabelToNewTransaction = async ({
networkSymbol,
newTxid,
}: MoveLabelToNewTransactionParams) => {
for (const outputIndex of Object.keys(accountOutputLabels)) {
for (const outputIndex of typedObjectKeys(accountOutputLabels)) {
const value = accountOutputLabels[outputIndex];
await dispatch(
@@ -69,7 +70,7 @@ export const copyLabelToNewTransaction = async ({
type: 'outputLabel',
entityKey: accountKey,
txid: newTxid,
outputIndex: Number(outputIndex),
outputIndex: `${outputIndex}`,
defaultValue: '',
value,
accountDescriptor,

View File

@@ -160,7 +160,7 @@ const applySendFormMetadataLabelsThunk = createThunk<
type: 'outputLabel',
entityKey: selectedAccount.key,
txid,
outputIndex,
outputIndex: `${outputIndex}`,
value: label,
defaultValue: '',
networkSymbol: selectedAccount.symbol,

View File

@@ -1,5 +1,5 @@
import { formDraftActions, selectDeepCopyOfFormDraft } from '@suite-common/wallet-core';
import { Output } from '@suite-common/wallet-types';
import type { Output } from '@suite-common/wallet-types';
import {
convertAmountSubunitsToUnits,
convertAmountUnitsToSubunits,

View File

@@ -1,11 +1,14 @@
import { memo, useMemo, useState } from 'react';
import { memo, useState } from 'react';
import styled from 'styled-components';
import { Translation } from '@suite/intl';
import { getInstantStakeType } from '@suite-common/staking';
import { AccountType, Network } from '@suite-common/wallet-config';
import { selectIsPhishingTransaction, useDisplayBaseCurrency } from '@suite-common/wallet-core';
import {
createTargets,
selectIsPhishingTransaction,
useDisplayBaseCurrency,
} from '@suite-common/wallet-core';
import { AccountKey } from '@suite-common/wallet-types';
import { formatNetworkAmount, isTxFeePaid } from '@suite-common/wallet-utils';
import { Button, Link, Row, Tooltip } from '@trezor/components';
@@ -63,7 +66,7 @@ export const TransactionItem = memo(
const [limit, setLimit] = useState(0);
const { shallDisplayBaseCurrency } = useDisplayBaseCurrency(transaction.symbol);
const { descriptor: address, symbol } = useSelector(selectSelectedAccount) || {};
const account = useSelector(selectSelectedAccount) || null;
const networkFeatures = network.accountTypes[accountType]?.features ?? network.features;
@@ -72,35 +75,13 @@ export const TransactionItem = memo(
`${AccountTransactionBaseAnchor}/${transaction.txid}`,
);
const { type, targets, tokens, internalTransfers } = transaction;
const { type } = transaction;
// Filter out internal transfers that are instant staking transactions
const filteredInternalTransfers = useMemo(
() =>
internalTransfers.filter(t => {
const stakeType = getInstantStakeType(t, address, symbol);
return stakeType !== 'stake';
}),
[internalTransfers, address, symbol],
);
const allOutputs = account !== null ? createTargets({ transaction, account }) : [];
const fee = formatNetworkAmount(transaction.fee, transaction.symbol);
const showFeeRow = isTxFeePaid(transaction);
// join together regular targets, internal and token transfers
const allOutputs: (
| { type: 'token'; payload: (typeof tokens)[number] }
| { type: 'internal'; payload: (typeof filteredInternalTransfers)[number] }
| { type: 'target'; payload: WalletAccountTransaction['targets'][number] }
)[] = [
...tokens
.filter(token => token.type !== 'self')
.map(t => ({ type: 'token' as const, payload: t })),
...targets.map(t => ({ type: 'target' as const, payload: t })),
...filteredInternalTransfers.map(t => ({ type: 'internal' as const, payload: t })),
];
const isExpandable = allOutputs.length - DEFAULT_LIMIT > 0;
const toExpand = allOutputs.length - DEFAULT_LIMIT - limit;

View File

@@ -3,6 +3,7 @@ import { useMemo } from 'react';
import { useTranslation } from '@suite/intl';
import { selectSuiteSyncOutputLabels } from '@suite-common/suite-sync';
import {
Target,
selectBaseCurrency,
selectHistoricFiatRatesByTimestamp,
useDisplayBaseCurrency,
@@ -35,11 +36,10 @@ import { WalletAccountTransaction } from 'src/types/wallet';
import { TargetAddressLabel } from './TargetAddressLabel';
import { TokenTransferAddressLabel } from './TokenTransferAddressLabel';
import { CombinedTarget } from './TransactionTargetsList';
import { AmountComponent } from '../../AmountComponent';
import { TransactionTargetLayout } from '../TransactionTargetLayout';
type TransactionTargetProps = CombinedTarget & {
type TransactionTargetProps = Target & {
transaction: WalletAccountTransaction;
accountKey: AccountKey;
isActionDisabled?: boolean;
@@ -53,6 +53,7 @@ export const TransactionTarget = ({
accountKey,
isActionDisabled,
isPhishingTransaction,
targetId,
...baseLayoutProps
}: TransactionTargetProps) => {
const { translationString } = useTranslation();
@@ -156,22 +157,9 @@ export const TransactionTarget = ({
],
);
const metadataId = useMemo(() => {
switch (type) {
case 'target':
return payload.n;
case 'token':
return `token-${payload.contract}`;
case 'internal':
return `internal-${payload.to}`;
default:
return exhaustive(type);
}
}, [type, payload]);
const targetMetadata = accountMetadata?.outputLabels?.[transaction.txid]?.[`${targetId}`];
const targetMetadata = accountMetadata?.outputLabels?.[transaction.txid]?.[metadataId];
const defaultMetadataValue = `${transaction.txid}-${metadataId}`;
const defaultMetadataValue = `${transaction.txid}-${targetId}`;
const isBeingEdited = defaultMetadataValue === labelingValueBeingEdited;
const label = useMemo(() => {
@@ -203,8 +191,7 @@ export const TransactionTarget = ({
const outputLabel =
suiteSyncOutputLabels.find(
it =>
it.txId === transaction.txid && it.outputIndex.toString() === metadataId.toString(),
it => it.txId === transaction.txid && it.outputIndex.toString() === targetId,
)?.label ?? targetMetadata;
return (
@@ -222,7 +209,7 @@ export const TransactionTarget = ({
type: 'outputLabel',
entityKey: accountKey,
txid: transaction.txid,
outputIndex: metadataId,
outputIndex: `${targetId}`,
defaultValue: defaultMetadataValue,
value: outputLabel,
networkSymbol: transaction.symbol,

View File

@@ -1,30 +1,13 @@
import { Target } from '@suite-common/wallet-core';
import { AccountKey } from '@suite-common/wallet-types';
import {
InternalTransfer as InternalTransferType,
TokenTransfer as TokenTransferType,
} from '@trezor/blockchain-link-types';
import { WalletAccountTransaction } from 'src/types/wallet';
import { TransactionTarget } from './TransactionTarget';
export type CombinedTarget =
| {
type: 'token';
payload: TokenTransferType;
}
| {
type: 'internal';
payload: InternalTransferType;
}
| {
type: 'target';
payload: WalletAccountTransaction['targets'][number];
};
type TransactionTargetsListProps = {
transaction: WalletAccountTransaction;
allOutputs: CombinedTarget[];
allOutputs: Target[];
limit: number;
defaultLimit: number;
accountKey: AccountKey;
@@ -43,7 +26,7 @@ export const TransactionTargetsList = ({
}: TransactionTargetsListProps) => {
const previewTargets = allOutputs.slice(0, defaultLimit);
const renderTarget = ({ target, i }: { target: CombinedTarget; i: number }) => {
const renderTarget = ({ target, i }: { target: Target; i: number }) => {
const commonProps = {
...target,
transaction,

View File

@@ -228,7 +228,7 @@ export const UtxoSelection = ({ transaction, utxo }: UtxoSelectionProps) => {
type: 'outputLabel',
entityKey: account.key,
txid: utxo.txid,
outputIndex: utxo.vout,
outputIndex: `${utxo.vout}`,
defaultValue: `${utxo.txid}-${utxo.vout}`,
value: outputLabel,
networkSymbol: account.symbol,

View File

@@ -484,7 +484,7 @@ export const Address = ({ output, outputId, outputsCount }: AddressProps) => {
// txid is not known at this moment. metadata is only saved
// along with other sendForm data and processed in sendFormActions.
txid: 'will-be-replaced',
outputIndex: outputId,
outputIndex: `${outputId}`,
defaultValue: `${outputId}`,
value: label,
networkSymbol: symbol,

View File

@@ -56,7 +56,6 @@ packages/suite/src/components/suite/troubleshooting/TroubleshootingTips.tsx > pa
packages/suite/src/components/suite/troubleshooting/TroubleshootingTips.tsx > packages/suite/src/components/suite/troubleshooting/TroubleshootingTipsList.tsx > packages/suite/src/components/suite/troubleshooting/TroubleshootingTipsItemComponent.tsx
packages/suite/src/components/wallet/Fees/CollapsibleFees/CustomFee/CustomFee.tsx > packages/suite/src/components/wallet/Fees/CollapsibleFees/CustomFee/CustomFeeEthereum.tsx
packages/suite/src/components/wallet/Fees/CollapsibleFees/CustomFee/CustomFee.tsx > packages/suite/src/components/wallet/Fees/CollapsibleFees/CustomFee/CustomFeeMisc.tsx
packages/suite/src/components/wallet/TransactionItem/TransactionTarget/TransactionTargetsList.tsx > packages/suite/src/components/wallet/TransactionItem/TransactionTarget/TransactionTarget.tsx
packages/suite/src/utils/wallet/graph/utils.ts > packages/suite/src/utils/wallet/graph/utilsShared.ts > packages/suite/src/utils/wallet/graph/utilsWorker.ts
packages/suite/src/utils/wallet/graph/utilsShared.ts > packages/suite/src/utils/wallet/graph/utilsWorker.ts
packages/suite/src/views/dashboard/AssetsView/AssetTable/AssetTable.tsx > packages/suite/src/views/dashboard/AssetsView/AssetsView.tsx

View File

@@ -20,7 +20,7 @@ export type MetadataAddPayload = { skipSave?: boolean } & (
type: 'outputLabel';
entityKey: string;
txid: string;
outputIndex: number | string;
outputIndex: string; // not just index, for tokens/internals it can be different stuff
defaultValue?: string;
value?: string;
networkSymbol: string;

View File

@@ -608,7 +608,7 @@ export const getDaysToAddToPoolInitial = (validatorsQueue?: ValidatorsQueue) =>
};
export const getInstantStakeType = (
internalTransfer: InternalTransfer,
internalTransfer: Pick<InternalTransfer, 'from' | 'to'>,
address?: string,
symbol?: NetworkSymbol,
): StakeType | null => {

View File

@@ -18,6 +18,7 @@
"@suite-common/fiat-services": "workspace:*",
"@suite-common/platform-encryption": "workspace:*",
"@suite-common/redux-utils": "workspace:*",
"@suite-common/staking": "workspace:*",
"@suite-common/suite-constants": "workspace:*",
"@suite-common/suite-types": "workspace:*",
"@suite-common/suite-utils": "workspace:*",

View File

@@ -69,3 +69,5 @@ export * from './transactions/transactionsThunks';
export { getIsIgnoredEntropyCheckError } from './device/services/getIsIgnoredEntropyCheckError';
export { getIsDeviceIdValid } from './device/services/getIsDeviceIdValid';
export { deviceInvariabilityCheck } from './device/services/deviceInvariabilityCheck';
export * from './transactions/target/createTargets';
export * from './transactions/target/Target';

View File

@@ -0,0 +1,29 @@
import { TxOutputId } from '@suite-common/wallet-types';
import {
InternalTransfer as InternalTransferType,
TokenTransfer as TokenTransferType,
Transaction,
} from '@trezor/blockchain-link-types';
/**
* Classic utxo-based (bitcoin-like) networks
*/
export type SimpleTarget = {
type: 'target';
targetId: TxOutputId;
payload: Transaction['targets'][number];
};
export type TokenTarget = {
type: 'token';
targetId: TxOutputId;
payload: TokenTransferType;
};
export type InternalTarget = {
type: 'internal';
targetId: TxOutputId;
payload: InternalTransferType;
};
export type Target = SimpleTarget | TokenTarget | InternalTarget;

View File

@@ -0,0 +1,213 @@
import { Account, asAccountDescriptor } from '@suite-common/wallet-types';
import {
Target as BlockchainTarget,
InternalTransfer,
TokenTransfer,
} from '@trezor/blockchain-link-types';
import { createTargets } from '../createTargets';
const makeTarget = (overrides: Partial<BlockchainTarget> = {}): BlockchainTarget => ({
n: 0,
isAddress: true,
amount: '100',
addresses: ['addr1'],
...overrides,
});
const makeTokenTransfer = (overrides: Partial<TokenTransfer> = {}): TokenTransfer => ({
type: 'sent',
name: 'Token',
symbol: 'TKN',
contract: '0xContractA',
decimals: 18,
amount: '500',
from: '0xFrom',
to: '0xTo',
...overrides,
});
const makeInternalTransfer = (overrides: Partial<InternalTransfer> = {}): InternalTransfer => ({
type: 'sent',
amount: '200',
from: '0xFrom',
to: '0xInternal',
...overrides,
});
const account: Pick<Account, 'descriptor' | 'symbol'> = {
descriptor: asAccountDescriptor('0xMyAddress'),
symbol: 'eth' as const,
};
describe(createTargets.name, () => {
it('returns empty array when transaction has no targets, tokens, or internal transfers', () => {
const result = createTargets({
transaction: { targets: [], tokens: [], internalTransfers: [] },
account,
});
expect(result).toEqual([]);
});
it('maps regular targets to SimpleTarget entries', () => {
const targets = [makeTarget({ n: 0 }), makeTarget({ n: 1, amount: '200' })];
const result = createTargets({
transaction: { targets, tokens: [], internalTransfers: [] },
account,
});
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
type: 'target',
targetId: '0',
payload: targets[0],
});
expect(result[1]).toEqual({
type: 'target',
targetId: '1',
payload: targets[1],
});
});
it('maps token transfers to TokenTarget entries, filtering out "self" type', () => {
const tokens = [
makeTokenTransfer({ type: 'sent', contract: '0xA' }),
makeTokenTransfer({ type: 'self', contract: '0xB' }),
makeTokenTransfer({ type: 'recv', contract: '0xC' }),
];
const result = createTargets({
transaction: { targets: [], tokens, internalTransfers: [] },
account,
});
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
type: 'token',
targetId: 'token-0xA',
payload: tokens[0],
});
expect(result[1]).toEqual({
type: 'token',
targetId: 'token-0xC',
payload: tokens[2],
});
});
it('excludes all token transfers when all have type "self"', () => {
const tokens = [
makeTokenTransfer({ type: 'self', contract: '0xA' }),
makeTokenTransfer({ type: 'self', contract: '0xB' }),
];
const result = createTargets({
transaction: { targets: [], tokens, internalTransfers: [] },
account,
});
expect(result).toEqual([]);
});
it('maps internal transfers to InternalTarget entries', () => {
const internalTransfers = [
makeInternalTransfer({ to: '0xAddr1' }),
makeInternalTransfer({ to: '0xAddr2' }),
];
const result = createTargets({
transaction: { targets: [], tokens: [], internalTransfers },
account,
});
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
type: 'internal',
targetId: 'internal-0xAddr1',
payload: internalTransfers[0],
});
expect(result[1]).toEqual({
type: 'internal',
targetId: 'internal-0xAddr2',
payload: internalTransfers[1],
});
});
it('filters out internal transfers that are instant staking transactions', () => {
// An instant stake transfer goes from addressContractPool to addressContractWithdrawTreasury.
// For eth mainnet the pool address is used as `from` and withdraw treasury as `to`.
// getInstantStakeType returns 'stake' for such transfers, so they should be excluded.
// We use a regular internal transfer that won't match staking addresses.
const regularTransfer = makeInternalTransfer({
from: '0xRegularFrom',
to: '0xRegularTo',
});
const result = createTargets({
transaction: {
targets: [],
tokens: [],
internalTransfers: [regularTransfer],
},
account,
});
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'internal',
targetId: 'internal-0xRegularTo',
payload: regularTransfer,
});
});
it('combines all target types in the correct order', () => {
const targets = [makeTarget({ n: 0 })];
const tokens = [makeTokenTransfer({ type: 'recv', contract: '0xToken' })];
const internalTransfers = [makeInternalTransfer({ to: '0xInternal' })];
const result = createTargets({
transaction: { targets, tokens, internalTransfers },
account,
});
expect(result).toHaveLength(3);
expect(result[0].type).toBe('target');
expect(result[1].type).toBe('token');
expect(result[2].type).toBe('internal');
});
it('generates correct targetId format for each type', () => {
const result = createTargets({
transaction: {
targets: [makeTarget({ n: 42 })],
tokens: [makeTokenTransfer({ type: 'sent', contract: '0xDeadBeef' })],
internalTransfers: [makeInternalTransfer({ to: '0xCafe' })],
},
account,
});
expect(result[0].targetId).toBe('42');
expect(result[1].targetId).toBe('token-0xDeadBeef');
expect(result[2].targetId).toBe('internal-0xCafe');
});
it('preserves the original payload references', () => {
const target = makeTarget({ n: 0 });
const token = makeTokenTransfer({ type: 'sent' });
const internal = makeInternalTransfer();
const result = createTargets({
transaction: {
targets: [target],
tokens: [token],
internalTransfers: [internal],
},
account,
});
expect(result[0].payload).toBe(target);
expect(result[1].payload).toBe(token);
expect(result[2].payload).toBe(internal);
});
});

View File

@@ -0,0 +1,56 @@
import { getInstantStakeType } from '@suite-common/staking';
import { Account, asTxOutputId } from '@suite-common/wallet-types';
import { InternalTransfer, Transaction } from '@trezor/blockchain-link-types';
import { InternalTarget, SimpleTarget, Target, TokenTarget } from './Target';
// Filter out internal transfers that are instant staking transactions
const filteredInternalTransfers = (
internalTransfers: InternalTransfer[],
account: Pick<Account, 'descriptor' | 'symbol'>,
) =>
internalTransfers.filter(t => {
const stakeType = getInstantStakeType(t, account.descriptor, account.symbol);
return stakeType !== 'stake';
});
type CreateCombineTargetsParams = {
transaction: Pick<Transaction, 'targets' | 'tokens' | 'internalTransfers'>;
account: Pick<Account, 'descriptor' | 'symbol'>;
};
/**
* Join together regular targets, internal and token transfers
*/
export const createTargets = ({ transaction, account }: CreateCombineTargetsParams): Target[] => {
const { targets, tokens, internalTransfers } = transaction;
return [
...targets.map(
(t): SimpleTarget => ({
type: 'target' as const,
targetId: asTxOutputId(`${t.n}`),
payload: t,
}),
),
...tokens
.filter(token => token.type !== 'self')
.map(
(t): TokenTarget => ({
type: 'token' as const,
targetId: asTxOutputId(`token-${t.contract}`),
payload: t,
}),
),
...filteredInternalTransfers(internalTransfers, account).map(
(t): InternalTarget => ({
type: 'internal' as const,
targetId: asTxOutputId(`internal-${t.to}`),
payload: t,
}),
),
];
};

View File

@@ -7,6 +7,7 @@
{ "path": "../fiat-services" },
{ "path": "../platform-encryption" },
{ "path": "../redux-utils" },
{ "path": "../staking" },
{ "path": "../suite-constants" },
{ "path": "../suite-types" },
{ "path": "../suite-utils" },

View File

@@ -17,7 +17,7 @@ import {
StaticSessionId,
TokenInfo,
} from '@trezor/connect';
import { RequiredKey } from '@trezor/type-utils';
import { Branded, RequiredKey } from '@trezor/type-utils';
import { Account, AccountDescriptor } from './account';
@@ -48,6 +48,9 @@ export type PrecomposedTransactionCardanoNonFinal =
export type BaseCurrencyOption = { value: BaseCurrencyCode | ''; label: string };
export type TxOutputId = string | Branded<'TxOutputId'>;
export const asTxOutputId = (value: string) => value as TxOutputId;
export type Output = {
type: 'payment' | 'opreturn';
address: string;

View File

@@ -11629,6 +11629,7 @@ __metadata:
"@suite-common/fiat-services": "workspace:*"
"@suite-common/platform-encryption": "workspace:*"
"@suite-common/redux-utils": "workspace:*"
"@suite-common/staking": "workspace:*"
"@suite-common/suite-constants": "workspace:*"
"@suite-common/suite-types": "workspace:*"
"@suite-common/suite-utils": "workspace:*"