diff --git a/.github/workflows/pr_push_qa.yml b/.github/workflows/pr_push_qa.yml index ee63d2a932..889c34d296 100644 --- a/.github/workflows/pr_push_qa.yml +++ b/.github/workflows/pr_push_qa.yml @@ -67,7 +67,7 @@ jobs: DEFI_SDK_TESTNET_API_URL: ${{ github.event.inputs.DEFI_SDK_TESTNET_API_URL || 'wss://api-testnet.zerion.io/' }} ZERION_API_URL: ${{ github.event.inputs.ZERION_API_URL || 'https://zpi.zerion.io/' }} ZERION_TESTNET_API_URL: ${{ github.event.inputs.ZERION_TESTNET_API_URL || 'https://zpi-testnet.zerion.io/' }} - BACKEND_ENV: ${{ github.event.inputs.BACKEND_ENV || '' }} + BACKEND_ENV: ${{ github.event.inputs.BACKEND_ENV || 'actions' }} PROXY_URL: ${{ github.event.inputs.PROXY_URL || 'https://proxy.zerion.io/' }} DEFI_SDK_TRANSACTIONS_API_URL: ${{ github.event.inputs.DEFI_SDK_TRANSACTIONS_API_URL || 'https://transactions.zerion.io' }} DEFI_SDK_API_TOKEN: Zerion.0JOY6zZTTw6yl5Cvz9sdmXc7d5AhzVMG diff --git a/src/modules/ethereum/message-signing/prepareTypedData.ts b/src/modules/ethereum/message-signing/prepareTypedData.ts index 5c320f57d9..8964e51992 100644 --- a/src/modules/ethereum/message-signing/prepareTypedData.ts +++ b/src/modules/ethereum/message-signing/prepareTypedData.ts @@ -12,8 +12,8 @@ export function isPermit({ message }: TypedData) { export function toTypedData(data: string | Partial): TypedData { if (typeof data === 'string') { try { - const typedData = JSON.parse(data); - return typedData as TypedData; + const typedData = JSON.parse(data) as TypedData; + return typedData; } catch (e) { throw new Error('Failed to parse typedData input'); } diff --git a/src/modules/ethereum/transactions/addressAction/addressActionMain.ts b/src/modules/ethereum/transactions/addressAction/addressActionMain.ts index 5c7310753f..6215f7245f 100644 --- a/src/modules/ethereum/transactions/addressAction/addressActionMain.ts +++ b/src/modules/ethereum/transactions/addressAction/addressActionMain.ts @@ -1,25 +1,48 @@ -import type { AddressAction, Asset, NFT, NFTAsset } from 'defi-sdk'; import type { BigNumberish } from 'ethers'; -import { createChain, type Chain } from 'src/modules/networks/Chain'; import { nanoid } from 'nanoid'; import type { MultichainTransaction } from 'src/shared/types/MultichainTransaction'; +import type { + AddressAction, + ActionChain, + Amount, + NFTPreview, +} from 'src/modules/zerion-api/requests/wallet-get-actions'; +import { invariant } from 'src/shared/invariant'; +import type { Asset, NFT } from 'defi-sdk'; +import type { NetworkConfig } from 'src/modules/networks/NetworkConfig'; +import type { Quote2 } from 'src/shared/types/Quote'; +import type { Fungible } from 'src/modules/zerion-api/types/Fungible'; import type { IncomingTransaction, IncomingTransactionWithFrom, } from '../../types/IncomingTransaction'; -import { getFungibleAsset } from '../actionAsset'; -export type ClientTransactionStatus = - | AddressAction['transaction']['status'] - | 'dropped'; +export type LocalActionTransaction = Omit< + NonNullable, + 'hash' +> & { + hash: string | null; +}; + +type LocalAct = Omit< + NonNullable[number], + 'transaction' +> & { + transaction: LocalActionTransaction; +}; -export type LocalAddressAction = Omit & { - transaction: Omit & { - status: ClientTransactionStatus; - data?: string; +export type LocalAddressAction = Omit & { + acts: LocalAct[] | null; + transaction: LocalActionTransaction | null; + rawTransaction: { + data?: string | null; value?: BigNumberish; - from?: string; - }; + from?: string | null; + nonce: number; + hash: string; + chain: string; + gasback?: number | null; + } | null; local: true; relatedTransaction?: string; // hash of related transaction (cancelled or sped-up) }; @@ -27,7 +50,7 @@ export type LocalAddressAction = Omit & { export type AnyAddressAction = AddressAction | LocalAddressAction; export function isLocalAddressAction( - addressAction: AnyAddressAction + addressAction: AddressAction | LocalAddressAction ): addressAction is LocalAddressAction { return 'local' in addressAction && addressAction.local; } @@ -35,258 +58,615 @@ export function isLocalAddressAction( export const ZERO_HASH = '0x0000000000000000000000000000000000000000000000000000000000000000'; -const toEmptyActionTx = (chain: Chain): AddressAction['transaction'] => +const toEmptyActionTx = (chain: string): LocalAddressAction['rawTransaction'] => ({ - chain: chain.toString(), + chain, hash: ZERO_HASH, - fee: null, - status: 'pending', nonce: -1, - sponsored: false, } as const); const toActionTx = ( tx: IncomingTransaction, - chain: Chain -): AddressAction['transaction'] => + chain: string +): LocalAddressAction['rawTransaction'] => ({ ...tx, - chain: chain.toString(), + chain, hash: (tx as { hash?: string }).hash || ZERO_HASH, - fee: null, - status: 'pending', nonce: -1, - sponsored: false, + value: tx.value ?? undefined, } as const); -function isAsset(x: Asset | NFT): x is Asset { - return 'asset_code' in x && 'decimals' in x && 'symbol' in x; +export function convertAssetToFungible(asset: Asset | null): Fungible | null { + if (!asset) { + return null; + } + return { + id: asset.id || asset.asset_code, + name: asset.name, + symbol: asset.symbol, + iconUrl: asset.icon_url || null, + implementations: asset.implementations || {}, + new: false, + verified: asset.is_verified, + meta: { + circulatingSupply: null, + totalSupply: null, + price: asset.price?.value || null, + marketCap: null, + fullyDilutedValuation: null, + relativeChange1d: asset.price?.relative_change_24h || null, + relativeChange30d: null, + relativeChange90d: null, + relativeChange365d: null, + }, + }; } -function isNFT(x: Asset | NFT): x is NFT { - return 'contract_address' in x && 'token_id' in x; + +export function convertNftToNftPreview(nft: NFT | null): NFTPreview | null { + if (!nft) { + return null; + } + return { + chain: nft.chain, + contractAddress: nft.contract_address, + tokenId: nft.token_id, + metadata: { + name: nft.metadata?.name || null, + content: { + imagePreviewUrl: nft.metadata?.content?.image_preview_url || null, + }, + }, + }; } -function toNftAsset(x: NFT): NFTAsset { +function convertNetworkToActionChain(network: NetworkConfig): ActionChain { return { - ...x, - asset_code: `${x.contract_address}:${x.token_id}`, - name: x.metadata.name, - symbol: '', - preview: { url: null, meta: null }, - detail: { url: null, meta: null }, - interface: x.contract_standard, - type: 'nft', - price: null, - icon_url: null, - is_verified: false, - collection_info: null, - tags: null, - floor_price: x.prices.converted?.floor_price ?? 0, - last_price: 0, + id: network.id, + name: network.name, + iconUrl: network.icon_url, }; } -export function createSendAddressAction({ - address, +export function getExplorerUrl( + explorerUrlTemplate: string | null, + hash: string | null +) { + return hash && explorerUrlTemplate + ? explorerUrlTemplate.replace('{HASH}', hash) + : null; +} + +export function createSendTokenAddressAction({ transaction, - asset, - quantity, - chain, + hash, + sendAsset, + sendAmount, + address, + network, + receiverAddress, + explorerUrl, }: { - address: string; transaction: MultichainTransaction; - asset: Asset | NFT; - quantity: string; - chain: Chain; + hash: string | null; + network: NetworkConfig; + sendAsset: Asset; + sendAmount: Amount; + address: string; + receiverAddress: string; + explorerUrl: string | null; }): LocalAddressAction { + const content = { + approvals: null, + transfers: [ + { + direction: 'out' as const, + amount: sendAmount, + fungible: convertAssetToFungible(sendAsset), + nft: null, + }, + ], + }; + + const actionTransaction = { + chain: convertNetworkToActionChain(network), + hash, + explorerUrl, + }; + return { id: nanoid(), - datetime: new Date().toISOString(), + timestamp: new Date().getTime(), address, - type: { display_value: 'Send', value: 'send' }, - label: null, - transaction: transaction.evm - ? toActionTx(transaction.evm, chain) - : toEmptyActionTx(chain), - content: { - transfers: { - outgoing: isAsset(asset) - ? [ - { - asset: { fungible: asset }, - quantity, - price: asset.price?.value ?? null, - }, - ] - : isNFT(asset) - ? [ - { - asset: { nft: toNftAsset(asset) }, - quantity, - price: asset.prices.converted?.floor_price ?? null, - }, - ] - : [], - incoming: [], + content, + transaction: actionTransaction, + acts: [ + { + content, + status: 'pending', + label: { + contract: null, + wallet: { + address: receiverAddress, + name: null, + iconUrl: null, + }, + }, + rate: null, + transaction: actionTransaction, + type: { + value: 'send', + displayValue: 'Send', + }, + }, + ], + status: 'pending', + type: { + displayValue: 'Send', + value: 'send', + }, + label: { + contract: null, + wallet: { + address: receiverAddress, + name: null, + iconUrl: null, }, }, + fee: null, + gasback: null, + refund: null, local: true, + rawTransaction: transaction.evm + ? toActionTx(transaction.evm, network.id) + : toEmptyActionTx(network.id), }; } -type AssetQuantity = { asset: Asset; quantity: string }; +export function createSendNFTAddressAction({ + hash, + transaction, + network, + sendAsset, + sendAmount, + address, + receiverAddress, + explorerUrl, +}: { + hash: string | null; + transaction: MultichainTransaction; + network: NetworkConfig; + sendAsset: NFT; + sendAmount: Amount; + address: string; + receiverAddress: string; + explorerUrl: string | null; +}): LocalAddressAction { + const content = { + approvals: null, + transfers: [ + { + direction: 'out' as const, + amount: sendAmount, + fungible: null, + nft: convertNftToNftPreview(sendAsset), + }, + ], + }; + const actionTransaction = { + chain: convertNetworkToActionChain(network), + hash, + explorerUrl, + }; + + return { + id: nanoid(), + timestamp: new Date().getTime(), + address, + content, + transaction: actionTransaction, + acts: [ + { + content, + status: 'pending', + label: { + contract: null, + wallet: { address: receiverAddress, name: null, iconUrl: null }, + }, + rate: null, + transaction: actionTransaction, + type: { + value: 'send', + displayValue: 'Send', + }, + }, + ], + status: 'pending', + type: { + displayValue: 'Send', + value: 'send', + }, + label: { + contract: null, + wallet: { + address: receiverAddress, + name: null, + iconUrl: null, + }, + }, + fee: null, + gasback: null, + refund: null, + local: true, + rawTransaction: transaction.evm + ? toActionTx(transaction.evm, network.id) + : toEmptyActionTx(network.id), + }; +} export function createTradeAddressAction({ address, transaction, - outgoing, - incoming, - chain, + hash, + spendAmount, + spendAsset, + receiveAmount, + receiveAsset, + network, + rate, + explorerUrl, }: { - address: string; transaction: MultichainTransaction; - outgoing: AssetQuantity[]; - incoming: AssetQuantity[]; - chain: Chain; + hash: string | null; + spendAmount: Amount; + spendAsset: Asset; + receiveAmount: Amount; + receiveAsset: Asset; + address: string; + network: NetworkConfig; + rate: Quote2['rate'] | null; + explorerUrl: string | null; }): LocalAddressAction { + const content = { + approvals: null, + transfers: [ + { + direction: 'out' as const, + amount: spendAmount, + fungible: convertAssetToFungible(spendAsset), + nft: null, + }, + { + direction: 'in' as const, + amount: receiveAmount, + fungible: convertAssetToFungible(receiveAsset), + nft: null, + }, + ], + }; + + const actionTransaction = { + chain: convertNetworkToActionChain(network), + hash, + explorerUrl, + }; + return { id: nanoid(), - datetime: new Date().toISOString(), address, - type: { value: 'trade', display_value: 'Trade' }, - transaction: transaction.evm - ? toActionTx(transaction.evm, chain) - : toEmptyActionTx(chain), - label: null, - content: { - transfers: { - outgoing: outgoing.map(({ asset, quantity }) => ({ - asset: { fungible: asset }, - quantity, - price: asset.price?.value ?? null, - })), - incoming: incoming.map(({ asset, quantity }) => ({ - asset: { fungible: asset }, - quantity, - price: asset.price?.value ?? null, - })), + timestamp: new Date().getTime(), + content, + transaction: actionTransaction, + acts: [ + { + content, + label: null, + rate, + status: 'pending', + type: { + value: 'trade', + displayValue: 'Trade', + }, + transaction: actionTransaction, }, + ], + status: 'pending', + type: { + value: 'trade', + displayValue: 'Trade', }, + label: null, + fee: null, + gasback: null, + refund: null, local: true, + rawTransaction: transaction.evm + ? toActionTx(transaction.evm, network.id) + : toEmptyActionTx(network.id), }; } export function createBridgeAddressAction({ address, transaction, - outgoing, - incoming, - chain, + hash, + spendAmount, + spendAsset, + receiveAmount, + receiveAsset, + inputNetwork, + outputNetwork, + explorerUrl, + receiverAddress, }: { address: string; transaction: MultichainTransaction; - outgoing: AssetQuantity[]; - incoming: AssetQuantity[]; - chain: Chain; + hash: string | null; + spendAmount: Amount; + spendAsset: Asset; + receiveAmount: Amount; + receiveAsset: Asset; + inputNetwork: NetworkConfig; + outputNetwork: NetworkConfig; + explorerUrl: string | null; + receiverAddress: string | null; }): LocalAddressAction { + const content = { + approvals: null, + transfers: [ + { + direction: 'out' as const, + amount: spendAmount, + fungible: convertAssetToFungible(spendAsset), + nft: null, + }, + { + direction: 'in' as const, + amount: receiveAmount, + fungible: convertAssetToFungible(receiveAsset), + nft: null, + }, + ], + }; + + const actionInputTransaction = { + chain: convertNetworkToActionChain(inputNetwork), + hash, + explorerUrl, + }; + + const actionOutputTransaction = { + chain: convertNetworkToActionChain(outputNetwork), + hash, + explorerUrl, + }; + return { id: nanoid(), - datetime: new Date().toISOString(), + timestamp: new Date().getTime(), address, - type: { display_value: 'Bridge', value: 'send' }, - label: null, - transaction: transaction.evm - ? toActionTx(transaction.evm, chain) - : toEmptyActionTx(chain), - content: { - transfers: { - outgoing: outgoing.map(({ asset, quantity }) => ({ - asset: { fungible: asset }, - quantity, - price: asset.price?.value ?? null, - })), - incoming: incoming.map(({ asset, quantity }) => ({ - asset: { fungible: asset }, - quantity, - price: asset.price?.value ?? null, - })), + content, + transaction: actionInputTransaction, + acts: [ + { + content, + label: null, + rate: null, + status: 'pending', + type: { + value: 'send', + displayValue: 'Send', + }, + transaction: actionInputTransaction, }, + receiverAddress + ? { + content: { + approvals: null, + transfers: [ + { + direction: 'out', + amount: receiveAmount, + fungible: convertAssetToFungible(receiveAsset), + nft: null, + }, + ], + }, + label: { + contract: null, + wallet: { + address: receiverAddress, + name: null, + iconUrl: null, + }, + }, + rate: null, + status: 'pending', + type: { + value: 'send', + displayValue: 'Send', + }, + transaction: actionOutputTransaction, + } + : { + content: { + approvals: null, + transfers: [ + { + direction: 'in', + amount: receiveAmount, + fungible: convertAssetToFungible(receiveAsset), + nft: null, + }, + ], + }, + label: null, + rate: null, + status: 'pending', + type: { + value: 'receive', + displayValue: 'Receive', + }, + transaction: actionOutputTransaction, + }, + ], + status: 'pending', + type: { + value: 'send', + displayValue: receiverAddress ? 'Send' : 'Bridge', }, + label: receiverAddress + ? { + contract: null, + wallet: { + address: receiverAddress, + name: null, + iconUrl: null, + }, + } + : null, + fee: null, + gasback: null, + refund: null, local: true, + rawTransaction: transaction.evm + ? toActionTx(transaction.evm, inputNetwork.id) + : toEmptyActionTx(inputNetwork.id), }; } export function createApproveAddressAction({ + hash, transaction, asset, - quantity, - chain, + amount, + network, + explorerUrl, }: { + hash: string | null; transaction: IncomingTransactionWithFrom; asset: Asset; - quantity: string; - chain: Chain; + amount: Amount; + network: NetworkConfig; + explorerUrl: string | null; }): LocalAddressAction { + const content = { + approvals: [ + { + amount, + fungible: convertAssetToFungible(asset), + nft: null, + collection: null, + unlimited: false, + }, + ], + transfers: null, + }; + + const actionTransaction = { + chain: convertNetworkToActionChain(network), + hash, + explorerUrl, + }; + return { id: nanoid(), + timestamp: new Date().getTime(), address: transaction.from, - datetime: new Date().toISOString(), - type: { value: 'approve', display_value: 'Approve' }, - transaction: toActionTx(transaction, chain), + content, + transaction: actionTransaction, + acts: [ + { + content, + label: null, + rate: null, + status: 'pending', + transaction: actionTransaction, + type: { + value: 'approve', + displayValue: 'Approve', + }, + }, + ], + status: 'pending', + type: { + value: 'approve', + displayValue: 'Approve', + }, label: null, - content: { single_asset: { asset: { fungible: asset }, quantity } }, + fee: null, + gasback: null, + refund: null, local: true, + rawTransaction: toActionTx(transaction, network.id), }; } export function createAcceleratedAddressAction( - addressAction: AnyAddressAction, + originalAddressAction: LocalAddressAction, transaction: IncomingTransaction ): LocalAddressAction { - const chain = createChain(addressAction.transaction.chain); + invariant( + originalAddressAction.rawTransaction, + 'Missing initial transaction data to create a cancel transaction' + ); + const chain = originalAddressAction.rawTransaction.chain; return { - ...addressAction, + ...originalAddressAction, id: nanoid(), - datetime: new Date().toISOString(), + timestamp: new Date().getTime(), local: true, - transaction: toActionTx(transaction, chain), - relatedTransaction: addressAction.transaction.hash, + rawTransaction: toActionTx(transaction, chain), + relatedTransaction: originalAddressAction.rawTransaction.hash, }; } export function createCancelAddressAction( - originalAddressAction: AnyAddressAction, + originalAddressAction: LocalAddressAction, transaction: IncomingTransactionWithFrom ): LocalAddressAction { - const chain = createChain(originalAddressAction.transaction.chain); + invariant( + originalAddressAction.rawTransaction, + 'Missing initial transaction data to create a cancel transaction' + ); + const chain = originalAddressAction.rawTransaction.chain; + const type = { displayValue: 'Send', value: 'send' as const }; return { id: nanoid(), - datetime: new Date().toISOString(), + timestamp: new Date().getTime(), local: true, address: transaction.from, - type: { display_value: 'Send', value: 'send' }, + type, label: null, content: null, - transaction: toActionTx(transaction, chain), - relatedTransaction: originalAddressAction.transaction.hash, + rawTransaction: toActionTx(transaction, chain), + relatedTransaction: originalAddressAction.rawTransaction.hash, + fee: null, + gasback: null, + refund: null, + status: 'pending', + transaction: originalAddressAction.transaction, + acts: originalAddressAction.transaction + ? [ + { + content: null, + label: null, + rate: null, + status: 'pending', + type, + transaction: originalAddressAction.transaction, + }, + ] + : [], }; } -export function getActionAsset(action: AnyAddressAction) { - const approvedAsset = action.content?.single_asset?.asset; - const sentAsset = action.content?.transfers?.outgoing?.[0]?.asset; - - if (approvedAsset) { - return getFungibleAsset(approvedAsset); - } else if (sentAsset) { - return getFungibleAsset(sentAsset); - } - return null; -} - -export function getActionAddress(action: AnyAddressAction) { +export function getActionApproval(action: AnyAddressAction) { return ( - action.label?.display_value.wallet_address || - action.label?.display_value.contract_address + (action?.acts?.length === 1 && + action.acts[0].content?.approvals?.length === 1 && + !action.acts[0].content.transfers + ? action.acts[0].content.approvals[0] + : null) || null ); } diff --git a/src/modules/ethereum/transactions/addressAction/creators.ts b/src/modules/ethereum/transactions/addressAction/creators.ts index 049fdcc332..319f4e209b 100644 --- a/src/modules/ethereum/transactions/addressAction/creators.ts +++ b/src/modules/ethereum/transactions/addressAction/creators.ts @@ -1,4 +1,4 @@ -import type { AddressAction, Client } from 'defi-sdk'; +import type { Client } from 'defi-sdk'; import { nanoid } from 'nanoid'; import { capitalize } from 'capitalize-ts'; import { @@ -6,7 +6,7 @@ import { type CachedAssetQuery, } from 'src/modules/defi-sdk/queries'; import type { Networks } from 'src/modules/networks/Networks'; -import type { Chain } from 'src/modules/networks/Chain'; +import { createChain } from 'src/modules/networks/Chain'; import { valueToHex } from 'src/shared/units/valueToHex'; import { UnsupportedNetwork } from 'src/modules/networks/errors'; import { normalizeChainId } from 'src/shared/normalizeChainId'; @@ -14,10 +14,11 @@ import { v5ToPlainTransactionResponse } from 'src/background/Wallet/model/ethers import { parseSolanaTransaction } from 'src/modules/solana/transactions/parseSolanaTransaction'; import { invariant } from 'src/shared/invariant'; import { solFromBase64 } from 'src/modules/solana/transactions/create'; -import type { - IncomingTransaction, - IncomingTransactionWithChainId, -} from '../../types/IncomingTransaction'; +import type { AddressAction } from 'src/modules/zerion-api/requests/wallet-get-actions'; +import { getDecimals } from 'src/modules/networks/asset'; +import { baseToCommon } from 'src/shared/units/convert'; +import type { NetworkConfig } from 'src/modules/networks/NetworkConfig'; +import type { IncomingTransactionWithChainId } from '../../types/IncomingTransaction'; import type { TransactionObject } from '../types'; import type { TransactionActionType } from '../describeTransaction'; import { @@ -26,13 +27,18 @@ import { } from '../describeTransaction'; import type { ChainId } from '../ChainId'; import { getTransactionObjectStatus } from '../getTransactionObjectStatus'; -import { ZERO_HASH, type LocalAddressAction } from './addressActionMain'; +import { + convertAssetToFungible, + getExplorerUrl, + ZERO_HASH, + type LocalAddressAction, +} from './addressActionMain'; export async function createActionContent( action: TransactionAction, currency: string, client: Client -): Promise { +): Promise { switch (action.type) { case 'execute': case 'send': { @@ -54,19 +60,32 @@ export async function createActionContent( currency, }; const asset = await fetchAssetFromCacheOrAPI(query, client); - return asset && action.amount - ? { - transfers: { - outgoing: [ - { - asset: { fungible: asset }, - quantity: action.amount.toString(), - price: null, - }, - ], + if (!asset || !action.amount) { + return null; + } + const commonQuantity = baseToCommon( + action.amount, + getDecimals({ asset, chain: action.chain }) + ); + return { + approvals: null, + transfers: [ + { + direction: 'out', + fungible: convertAssetToFungible(asset), + nft: null, + amount: { + currency, + usdValue: null, + quantity: commonQuantity.toFixed(), + value: + asset.price?.value != null + ? commonQuantity.multipliedBy(asset.price.value).toNumber() + : null, }, - } - : null; + }, + ], + }; } case 'approve': { const asset = await fetchAssetFromCacheOrAPI( @@ -78,19 +97,38 @@ export async function createActionContent( }, client ); - return asset - ? { - single_asset: { - asset: { fungible: asset }, - quantity: action.amount.toString(), + if (!asset) { + return null; + } + const commonQuantity = baseToCommon( + action.amount, + getDecimals({ asset, chain: action.chain }) + ); + return { + transfers: null, + approvals: [ + { + fungible: convertAssetToFungible(asset), + nft: null, + collection: null, + unlimited: false, + amount: { + currency, + usdValue: null, + quantity: commonQuantity.toFixed(), + value: + asset.price?.value != null + ? commonQuantity.multipliedBy(asset.price.value).toNumber() + : null, }, - } - : null; + }, + ], + }; } } } -type AddressActionLabelType = 'to' | 'from' | 'application' | 'contract'; +type AddressActionLabelType = 'to' | 'from' | 'application'; const actionTypeToLabelType: Record< TransactionActionType, @@ -98,29 +136,38 @@ const actionTypeToLabelType: Record< > = { deploy: 'from', send: 'to', - execute: 'contract', + execute: 'application', approve: 'application', }; function createActionLabel( - transaction: IncomingTransaction, - action: TransactionAction + addressAction: TransactionAction ): AddressAction['label'] { - let wallet_address = undefined; - if (action.type === 'send') { - wallet_address = action.receiverAddress; - } else if (action.type === 'approve') { - wallet_address = action.spenderAddress; - } + const title = actionTypeToLabelType[addressAction.type]; return { - type: actionTypeToLabelType[action.type], - value: transaction.to || action.contractAddress || '', - display_value: { - text: '', - wallet_address, - contract_address: action.contractAddress, - }, + title, + displayTitle: capitalize(title), + wallet: + addressAction.type === 'send' + ? { + address: addressAction.receiverAddress, + name: addressAction.receiverAddress, + iconUrl: null, + } + : null, + contract: + addressAction.type === 'send' + ? null + : { + address: addressAction.contractAddress, + dapp: { + id: addressAction.contractAddress, + name: addressAction.contractAddress, + iconUrl: null, + url: null, + }, + }, }; } @@ -132,14 +179,14 @@ async function pendingEvmTxToAddressAction( ): Promise { invariant(transactionObject.hash, 'Must be evm tx'); const { transaction, hash, timestamp } = transactionObject; - let chain: Chain | null; + let network: NetworkConfig | null; const chainId = normalizeChainId(transaction.chainId); const networks = await loadNetworkByChainId(chainId); try { - chain = networks.getChainById(chainId); + network = networks.getNetworkById(chainId); } catch (error) { if (error instanceof UnsupportedNetwork) { - chain = null; + network = null; } else { throw error; } @@ -148,57 +195,83 @@ async function pendingEvmTxToAddressAction( ...v5ToPlainTransactionResponse(transaction), chainId, }; - const action = chain - ? describeTransaction(normalizedTx, { networks, chain }) + const action = network + ? describeTransaction(normalizedTx, { + networks, + chain: createChain(network.id), + }) : null; - const label = action ? createActionLabel(normalizedTx, action) : null; + const label = action ? createActionLabel(action) : null; const content = action ? await createActionContent(action, currency, client) : null; + const actionTransaction = { + hash, + chain: { + id: network?.id || valueToHex(transaction.chainId), + name: network?.name || valueToHex(transaction.chainId), + iconUrl: network?.icon_url || '', + }, + explorerUrl: getExplorerUrl(network?.explorer_tx_url || null, hash), + }; + const type = { + value: action?.type || 'execute', + displayValue: capitalize(action?.type || 'execute'), + }; return { id: hash, address: transaction.from, - transaction: { + status: getTransactionObjectStatus(transactionObject), + transaction: actionTransaction, + timestamp: timestamp ?? Date.now(), + label, + type, + refund: null, + gasback: null, + fee: null, + acts: [ + { + content, + rate: null, + status: getTransactionObjectStatus(transactionObject), + label, + type, + transaction: actionTransaction, + }, + ], + content, + rawTransaction: { ...normalizedTx, hash, - chain: chain - ? chain.toString() + chain: network + ? network.id : // It's okay to fallback to a stringified chainId because this is // only a representational object valueToHex(transaction.chainId), - status: getTransactionObjectStatus(transactionObject), - fee: null, nonce: Number(transaction.nonce) || 0, - sponsored: false, }, - datetime: new Date(timestamp ?? Date.now()).toISOString(), - label, - type: action - ? { - display_value: capitalize(action.type), - value: action.type, - } - : { display_value: '[Missing network data]', value: 'execute' }, - content, local: true, relatedTransaction: transactionObject.relatedTransactionHash, }; } function pendingSolanaTxToAddressAction( - transactionObject: TransactionObject + transactionObject: TransactionObject, + currency: string ): LocalAddressAction { invariant(transactionObject.signature, 'Must be solana tx'); const tx = solFromBase64(transactionObject.solanaBase64); - const addressAction = parseSolanaTransaction(transactionObject.publicKey, tx); + const action = parseSolanaTransaction( + transactionObject.publicKey, + tx, + currency + ); return { - ...addressAction, - datetime: new Date(transactionObject.timestamp).toISOString(), + ...action, + timestamp: transactionObject.timestamp, + status: getTransactionObjectStatus(transactionObject), local: true, - transaction: { - ...addressAction.transaction, - status: getTransactionObjectStatus(transactionObject), - }, + rawTransaction: null, }; } @@ -216,7 +289,7 @@ export async function pendingTransactionToAddressAction( client ); } else if (transactionObject.signature) { - return pendingSolanaTxToAddressAction(transactionObject); + return pendingSolanaTxToAddressAction(transactionObject, currency); } else { throw new Error('Unexpected TransactionObject'); } @@ -232,31 +305,58 @@ export async function incomingTxToIncomingAddressAction( client: Client ): Promise { const { transaction, timestamp } = transactionObject; - const chain = networks.getChainById(normalizeChainId(transaction.chainId)); - const label = createActionLabel(transaction, transactionAction); + const network = networks.getNetworkById( + normalizeChainId(transaction.chainId) + ); + const label = createActionLabel(transactionAction); const content = await createActionContent( transactionAction, currency, client ); + + const type = { + displayValue: capitalize(transactionAction.type), + value: transactionAction.type, + }; + + const actionTransaction = { + hash: ZERO_HASH, + chain: { + id: network?.id || valueToHex(transaction.chainId), + name: network?.name || valueToHex(transaction.chainId), + iconUrl: network?.icon_url || '', + }, + explorerUrl: null, + }; + return { id: nanoid(), local: true, address: transaction.from, - transaction: { + status: 'pending', + rawTransaction: { hash: ZERO_HASH, - chain: chain.toString(), - status: 'pending', - fee: null, - sponsored: false, + chain: network.id, nonce: transaction.nonce ?? -1, }, - datetime: new Date(timestamp ?? Date.now()).toISOString(), + timestamp: timestamp ?? Date.now(), label, - type: { - display_value: capitalize(transactionAction.type), - value: transactionAction.type, - }, + type, content, + fee: null, + gasback: null, + refund: null, + transaction: actionTransaction, + acts: [ + { + content, + rate: null, + status: 'pending', + label, + type, + transaction: actionTransaction, + }, + ], }; } diff --git a/src/modules/ethereum/transactions/appovals.ts b/src/modules/ethereum/transactions/appovals.ts index a03d7fea8d..7f9fc74ea9 100644 --- a/src/modules/ethereum/transactions/appovals.ts +++ b/src/modules/ethereum/transactions/appovals.ts @@ -1,6 +1,14 @@ +import BigNumber from 'bignumber.js'; import { ethers } from 'ethers'; import { invariant } from 'src/shared/invariant'; +import { produce } from 'immer'; +import { UNLIMITED_APPROVAL_AMOUNT } from '../constants'; import { parseApprove } from './describeTransaction'; +import type { AnyAddressAction } from './addressAction'; + +export function isUnlimitedApproval(value?: BigNumber.Value | null) { + return new BigNumber(value?.toString() || 0).gte(UNLIMITED_APPROVAL_AMOUNT); +} export async function createApprovalTransaction(params: { contractAddress: string; @@ -34,3 +42,41 @@ export async function modifyApproveAmount< ...newApproval, }; } + +export function applyCustomAllowance({ + addressAction, + customAllowanceQuantityCommon, + customAllowanceQuantityBase, +}: { + addressAction?: AnyAddressAction; + customAllowanceQuantityCommon: string | null; + customAllowanceQuantityBase: string | null; +}) { + if ( + customAllowanceQuantityCommon == null || + addressAction?.acts?.length !== 1 || + addressAction.acts[0].content?.approvals?.length !== 1 + ) { + return addressAction; + } + return produce(addressAction, (draft) => { + if (draft.acts?.[0].content?.approvals?.[0]) { + if (draft.acts[0].content.approvals[0].amount) { + draft.acts[0].content.approvals[0].amount.quantity = + customAllowanceQuantityCommon; + } else { + draft.acts[0].content.approvals[0].amount = { + quantity: customAllowanceQuantityCommon, + value: null, + usdValue: null, + currency: '', + }; + } + if (customAllowanceQuantityBase != null) { + draft.acts[0].content.approvals[0].unlimited = isUnlimitedApproval( + customAllowanceQuantityBase + ); + } + } + }); +} diff --git a/src/modules/ethereum/transactions/getTransactionObjectStatus.ts b/src/modules/ethereum/transactions/getTransactionObjectStatus.ts index f40d1fd8d5..a57b6b0c9f 100644 --- a/src/modules/ethereum/transactions/getTransactionObjectStatus.ts +++ b/src/modules/ethereum/transactions/getTransactionObjectStatus.ts @@ -1,10 +1,10 @@ import { invariant } from 'src/shared/invariant'; +import type { ActionStatus } from 'src/modules/zerion-api/requests/wallet-get-actions'; import type { TransactionObject } from './types'; -import type { ClientTransactionStatus } from './addressAction/addressActionMain'; function transactionReceiptToActionStatus( transactionObject: Pick -): ClientTransactionStatus { +): ActionStatus { return transactionObject.receipt ? transactionObject.receipt.status === 1 ? 'confirmed' @@ -16,7 +16,7 @@ function transactionReceiptToActionStatus( function solanaTransactionObjectToStatus( transactionObject: TransactionObject -): ClientTransactionStatus { +): ActionStatus { invariant(transactionObject.signature, 'Must be solana tx'); if (transactionObject.dropped) { return 'dropped'; diff --git a/src/modules/ethereum/transactions/interpret.ts b/src/modules/ethereum/transactions/interpret.ts deleted file mode 100644 index e55ba3192d..0000000000 --- a/src/modules/ethereum/transactions/interpret.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { type Client, client as defaultClient } from 'defi-sdk'; -import { rejectAfterDelay } from 'src/shared/rejectAfterDelay'; -import { valueToHex } from 'src/shared/units/valueToHex'; -import type { Chain } from 'src/modules/networks/Chain'; -import type { MultichainTransaction } from 'src/shared/types/MultichainTransaction'; -import type { TypedData } from '../message-signing/TypedData'; -import type { InterpretResponse } from './types'; -import { getGas } from './getGas'; -import type { ChainId } from './ChainId'; - -export function interpretTransaction({ - address, - chain, - transaction, - origin, - client = defaultClient, - currency, -}: { - address: string; - chain: Chain; - transaction: MultichainTransaction; - origin: string; - client?: Client; - currency: string; -}): Promise { - return Promise.race([ - rejectAfterDelay(10000, 'interpret transaction'), - new Promise((resolve) => { - let value: InterpretResponse | null = null; - - let normalizedEvmTx; - if (transaction.evm) { - normalizedEvmTx = { - ...transaction.evm, - maxFee: transaction.evm.maxFeePerGas, - maxPriorityFee: transaction.evm.maxPriorityFeePerGas, - }; - const gas = getGas(transaction.evm); - if (gas != null) { - normalizedEvmTx.gas = valueToHex(gas); - } - if (normalizedEvmTx.value != null) { - normalizedEvmTx.value = valueToHex(normalizedEvmTx.value); - } - } - const unsubscribe = client.subscribe< - InterpretResponse, - 'interpret', - 'transaction' - >({ - namespace: 'interpret', - method: 'stream', - body: { - scope: ['transaction'], - payload: { - address, - chain: chain.toString(), - currency, - transaction: normalizedEvmTx, - solanaTransaction: transaction.solana, - domain: origin, - }, - }, - // Here we're using onMessage instead of onData because of - // bug in defi-sdk (unsubscribe function is not always returned) - onMessage: (event, data) => { - if (event === 'done') { - resolve(value as InterpretResponse); - unsubscribe(); - return; - } - value = data.payload.transaction; - }, - }); - }), - ]); -} - -export function interpretSignature({ - address, - chainId, - typedData, - client = defaultClient, - currency, - origin, -}: { - address: string; - chainId?: ChainId | null; - typedData: TypedData; - client?: Client; - currency: string; - origin: string; -}): Promise { - return Promise.race([ - rejectAfterDelay(10000, 'interpret signature'), - new Promise((resolve) => { - let value: InterpretResponse | null = null; - - const unsubscribe = client.subscribe< - InterpretResponse, - 'interpret', - 'signature' - >({ - namespace: 'interpret', - method: 'stream', - body: { - scope: ['signature'], - payload: { - address, - chain_id: chainId, - currency, - typed_data: typedData, - domain: origin, - }, - }, - // Here we're using onMessage instead of onData because of - // bug in defi-sdk (unsubscribe function is not always returned) - onMessage: (event, data) => { - if (event === 'done') { - resolve(value as InterpretResponse); - unsubscribe(); - return; - } - value = data.payload.signature; - }, - }); - }), - ]); -} - -export function getInterpretationFunctionName( - interpretation: InterpretResponse -) { - return interpretation.input?.sections[0]?.blocks.find( - ({ name }) => name === 'Function Name' - )?.value; -} diff --git a/src/modules/ethereum/transactions/types.ts b/src/modules/ethereum/transactions/types.ts index ac3b3151ef..7b2b658b6b 100644 --- a/src/modules/ethereum/transactions/types.ts +++ b/src/modules/ethereum/transactions/types.ts @@ -1,4 +1,3 @@ -import type { AddressAction } from 'defi-sdk'; import type { EthersV5TransactionReceiptStripped, EthersV5TransactionResponse, @@ -59,32 +58,3 @@ export type TransactionObject = CombineUnion & { }; export type StoredTransactions = Array; - -export type WarningSeverity = 'Red' | 'Orange' | 'Yellow' | 'Gray'; - -interface Warning { - severity: WarningSeverity; - title?: string; - description: string; - details?: string; -} - -interface Block { - name: string; - value: string; -} - -interface Section { - name: string | null; - blocks: Block[]; -} - -export interface InterpretInput { - sections: Section[]; -} - -export interface InterpretResponse { - action: AddressAction | null; - input?: InterpretInput; - warnings: Warning[]; -} diff --git a/src/modules/networks/Networks.ts b/src/modules/networks/Networks.ts index e000e24608..2ed3ab678d 100644 --- a/src/modules/networks/Networks.ts +++ b/src/modules/networks/Networks.ts @@ -281,10 +281,10 @@ export class Networks { } getExplorerAddressUrlByName(chain: Chain, address: string) { - return this.getExplorerAddressUrl(this.getByNetworkId(chain), address); + return Networks.getExplorerAddressUrl(this.getByNetworkId(chain), address); } - private getExplorerAddressUrl( + static getExplorerAddressUrl( network: NetworkConfig | undefined, address: string ) { diff --git a/src/modules/networks/asset.ts b/src/modules/networks/asset.ts index 6d2504e9d0..5beb391bce 100644 --- a/src/modules/networks/asset.ts +++ b/src/modules/networks/asset.ts @@ -7,15 +7,23 @@ export function getAssetImplementationInChain({ asset, chain, }: { - asset?: Asset; + asset?: Pick; chain: Chain; }) { return asset?.implementations?.[String(chain)]; } -export function getDecimals({ asset, chain }: { asset: Asset; chain: Chain }) { +export function getDecimals({ + asset, + chain, +}: { + asset: Pick & { decimals?: number }; + chain: Chain; +}) { return ( - getAssetImplementationInChain({ asset, chain })?.decimals || asset.decimals + getAssetImplementationInChain({ asset, chain })?.decimals || + asset.decimals || + 18 ); } diff --git a/src/modules/solana/transactions/parseSolanaTransaction.test.ts b/src/modules/solana/transactions/parseSolanaTransaction.test.ts index 45a1d293ed..9f3cd40372 100644 --- a/src/modules/solana/transactions/parseSolanaTransaction.test.ts +++ b/src/modules/solana/transactions/parseSolanaTransaction.test.ts @@ -14,13 +14,11 @@ describe('parseSolanaTransaction', () => { test('send should be parsed', () => { const tx = solFromBase64(samples.simpleSend); expect(tx).toBeDefined(); - const parsed = parseSolanaTransaction(samples.sender, tx); + const parsed = parseSolanaTransaction(samples.sender, tx, 'usd'); - expect(parsed.type.display_value).toBe('Send'); - expect(parsed.content?.transfers?.outgoing?.at(0)?.quantity).toBe( - '10100000' - ); - expect(parsed.content?.transfers?.outgoing?.at(0)?.recipient).toBe( + expect(parsed.type.displayValue).toBe('Send'); + expect(parsed.content?.transfers?.at(0)?.amount?.quantity).toBe('0.0101'); + expect(parsed.label?.wallet?.address).toBe( 'BJpYy4oW3XREUi9mQhPXzzqtf37azUbz1JPqMbU5qU23' ); expect(parsed.address).toBe(samples.sender); diff --git a/src/modules/solana/transactions/parseSolanaTransaction.ts b/src/modules/solana/transactions/parseSolanaTransaction.ts index ec5205be20..30582e6ae6 100644 --- a/src/modules/solana/transactions/parseSolanaTransaction.ts +++ b/src/modules/solana/transactions/parseSolanaTransaction.ts @@ -1,11 +1,12 @@ +import { baseToCommon } from 'src/shared/units/convert'; import type { Transaction, VersionedTransaction, MessageV0, } from '@solana/web3.js'; import { SystemProgram, TransactionInstruction } from '@solana/web3.js'; -import type { AddressAction } from 'defi-sdk'; import type { Fungible } from 'src/modules/zerion-api/types/Fungible'; +import type { AddressAction } from 'src/modules/zerion-api/requests/wallet-get-actions'; import { SolanaSigning } from '../signing'; function isSystemTransfer(ix: TransactionInstruction): boolean { @@ -20,28 +21,6 @@ function parseLamports(ix: TransactionInstruction): number { return Number(ix.data.readBigUInt64LE(4)); } -export const SOL_ASSET = { - fungible: { - id: 'sol', - asset_code: '11111111111111111111111111111111', - name: 'Solana', - symbol: 'SOL', - decimals: 9, - type: '', - icon_url: - 'https://token-icons.s3.amazonaws.com/11111111111111111111111111111111.png', - price: null, - is_displayable: true, - is_verified: true, - implementations: { - solana: { - address: '11111111111111111111111111111111', - decimals: 9, - }, - }, - }, -}; - export const SOL_ASSET_FUNGIBLE: Fungible = { id: '11111111111111111111111111111111', name: 'Solana', @@ -71,9 +50,10 @@ export const SOL_ASSET_FUNGIBLE: Fungible = { export function parseSolanaTransaction( from: string, - tx: Transaction | VersionedTransaction + tx: Transaction | VersionedTransaction, + currency: string ): AddressAction { - const now = new Date().toISOString(); + const timestamp = new Date().getTime(); const instructions = 'version' in tx @@ -91,69 +71,112 @@ export function parseSolanaTransaction( : (tx as Transaction).instructions; const transferIx = instructions.find(isSystemTransfer); + const hash = SolanaSigning.getTransactionSignature(tx); if (transferIx) { const toAddr = transferIx.keys[1].pubkey.toBase58(); const lamports = parseLamports(transferIx); + const content = { + approvals: null, + transfers: [ + { + amount: { + currency, + value: null, + usdValue: null, + quantity: baseToCommon( + lamports, + SOL_ASSET_FUNGIBLE.implementations.solana.decimals + ).toFixed(), + }, + direction: 'out' as const, + fungible: SOL_ASSET_FUNGIBLE, + nft: null, + }, + ], + }; + + const label = { + title: 'to' as const, + displayTitle: 'To', + contract: null, + wallet: { + address: toAddr, + name: toAddr, + iconUrl: null, + }, + }; + + const type = { value: 'send' as const, displayValue: 'Send' }; + + const transaction = { + chain: { + id: 'solana', + name: 'Solana', + iconUrl: '', + }, + hash: hash ?? '', + explorerUrl: hash ? `https://solscan.io/tx/${hash}` : null, + }; return { id: crypto.randomUUID(), - datetime: now, + timestamp, address: from, - type: { value: 'send', display_value: 'Send' }, - transaction: { - chain: 'solana', - hash: SolanaSigning.getTransactionSignature(tx) ?? '', - status: 'confirmed', - nonce: 0, - sponsored: false, - fee: null, - }, - label: { - type: 'to', - value: toAddr, - display_value: { wallet_address: toAddr }, - }, - content: { - transfers: { - outgoing: [ - { - asset: SOL_ASSET, - quantity: lamports.toString(), - price: 0, - recipient: toAddr, - }, - ], - incoming: [], + type, + status: 'confirmed', + transaction, + label, + fee: null, + gasback: null, + refund: null, + content, + acts: [ + { + content, + label, + rate: null, + status: 'confirmed', + type, + transaction, }, - }, + ], }; } // fallback return { id: crypto.randomUUID(), - datetime: now, + timestamp, address: from, - type: { value: 'execute', display_value: 'Execute' }, + type: { value: 'execute', displayValue: 'Execute' }, transaction: { - chain: 'solana', - hash: SolanaSigning.getTransactionSignature(tx) ?? '', - status: 'confirmed', - nonce: 0, - sponsored: false, - fee: null, + chain: { + id: 'solana', + name: 'Solana', + iconUrl: '', + }, + hash: hash ?? '', + explorerUrl: hash ? `https://solscan.io/tx/${hash}` : null, }, label: { - type: 'application', - value: from, - display_value: { contract_address: from }, - }, - content: { - transfers: { - outgoing: [], - incoming: [], + title: 'application', + displayTitle: 'Application', + contract: { + address: from, + dapp: { + id: from, + name: from, + url: null, + iconUrl: null, + }, }, }, + acts: [], + content: null, + fee: null, + gasback: null, + refund: null, + status: 'confirmed', }; } diff --git a/src/modules/zerion-api/hooks/useAssetFullInfo.ts b/src/modules/zerion-api/hooks/useAssetFullInfo.ts index 636c2d2146..cbe02aee11 100644 --- a/src/modules/zerion-api/hooks/useAssetFullInfo.ts +++ b/src/modules/zerion-api/hooks/useAssetFullInfo.ts @@ -6,12 +6,16 @@ import type { BackendSourceParams } from '../shared'; export function useAssetFullInfo( params: Params, { source }: BackendSourceParams, - { suspense = false }: { suspense?: boolean } = {} + { + suspense = false, + enabled = true, + }: { suspense?: boolean; enabled?: boolean } = {} ) { return useQuery({ queryKey: ['assetGetFungibleFullInfo', params, source], queryFn: () => ZerionAPI.assetGetFungibleFullInfo(params, { source }), suspense, + enabled, staleTime: 20000, }); } diff --git a/src/modules/zerion-api/hooks/useWalletActions.ts b/src/modules/zerion-api/hooks/useWalletActions.ts new file mode 100644 index 0000000000..3311212a63 --- /dev/null +++ b/src/modules/zerion-api/hooks/useWalletActions.ts @@ -0,0 +1,60 @@ +import { useCallback, useMemo } from 'react'; +import { type InfiniteData, useInfiniteQuery } from '@tanstack/react-query'; +import { queryClient } from 'src/ui/shared/requests/queryClient'; +import { ZerionAPI } from '../zerion-api.client'; +import type { Payload } from '../requests/wallet-get-actions'; +import type { BackendSourceParams } from '../shared'; + +export function useWalletActions( + params: Payload, + { source }: BackendSourceParams, + { + suspense = false, + enabled = true, + refetchInterval = false, + }: { + suspense?: boolean; + enabled?: boolean; + keepPreviousData?: boolean; + refetchInterval?: number | false; + } = {} +) { + const queryData = useInfiniteQuery({ + queryKey: ['walletGetActions', params, source], + queryFn: ({ pageParam }) => + ZerionAPI.walletGetActions( + { + ...params, + chain: params.chain || undefined, + cursor: pageParam ?? params.cursor, + }, + { source } + ), + enabled, + suspense, + getNextPageParam: (lastPage) => + lastPage?.meta?.pagination.cursor || undefined, + refetchOnWindowFocus: false, + refetchOnMount: true, + refetchInterval, + }); + + const actions = useMemo(() => { + return queryData.data?.pages.flatMap((page) => page.data); + }, [queryData.data]); + + // Slice data to the first page on refetch + // Based on: https://github.com/TanStack/query/discussions/1670#discussioncomment-13006968 + const refetch = useCallback(() => { + queryClient.setQueryData>( + ['walletGetActions', params, source], + (data) => ({ + pages: data?.pages.slice(0, 1) || [], + pageParams: data?.pageParams.slice(0, 1) || [], + }) + ); + queryClient.refetchQueries(['walletGetActions', params, source]); + }, [params, source]); + + return { actions, refetch, queryData }; +} diff --git a/src/modules/zerion-api/requests/wallet-get-actions.ts b/src/modules/zerion-api/requests/wallet-get-actions.ts new file mode 100644 index 0000000000..d802e68bb6 --- /dev/null +++ b/src/modules/zerion-api/requests/wallet-get-actions.ts @@ -0,0 +1,314 @@ +import { produce } from 'immer'; +import { invariant } from 'src/shared/invariant'; +import type { ZerionApiContext } from '../zerion-api-bare'; +import type { ClientOptions } from '../shared'; +import { CLIENT_DEFAULTS, ZerionHttpClient } from '../shared'; +import type { Fungible } from '../types/Fungible'; + +export type NFTPreview = { + /** + * @description Chain identifier on which the nft is located // [!code link {"token":"Chain","href":"/docs/actions/entities.html#chain"}] + * @example ethereum + */ + chain: string; + /** + * @description Address of a smart contract of the NFT // [!code link {"token":"NFT","href":"/docs/actions/entities.html#nft"}] + * @example 0x932261f9fc8da46c4a22e31b45c4de60623848bf + */ + contractAddress: string; + /** + * @description Identifier of the NFT // [!code link {"token":"NFT","href":"/docs/actions/entities.html#nft"}] + * @example 5555 + */ + tokenId: string; + /** @description Metadata of NFT */ + metadata: { + /** + * @description Name of the NFT // [!code link {"token":"NFT","href":"/docs/actions/entities.html#nft"}] + * @example #5555 + */ + name: string | null; + content: { + /** + * Format: uri + * @description Url to attached preview image + * @example https://lh3.googleusercontent.com/1shEuaYwl9TQKi_GEMVDXITwraVvqynajZdwmJlJwVCn78JTeIuCbMJMtgug8wPYMZaDaYPnqgMlkuPlv-jgH0xwVvJWQYlCEZvT=s250 + */ + imagePreviewUrl: string | null; + } | null; + } | null; +}; + +export type Collection = { + /** @description Unique identifier for the collection */ + id: null | string; + /** @description Human-readable name of the collection */ + name: null | string; + /** @description URL to the collection's logo */ + iconUrl: null | string; +}; + +export type ActionType = + | 'execute' + | 'send' + | 'receive' + | 'mint' + | 'burn' + | 'deposit' + | 'withdraw' + | 'trade' + | 'approve' + | 'revoke' + | 'deploy' + | 'cancel' + | 'borrow' + | 'repay' + | 'stake' + | 'unstake' + | 'claim' + | 'batch_execute'; + +export interface Payload { + /** + * @description Currency name for price conversions // [!code link {"token":"Currency","href":"/docs/actions/entities.html#currency"}] + * @example usd + */ + currency: string; + /** @description Wallet addresses */ + addresses: string[]; + /** @description Pagination cursor */ + cursor?: string; + /** @description Pagination limit */ + limit?: number; + /** + * @description Chain identifier on which the nft is located // [!code link {"token":"Chain","href":"/docs/actions/entities.html#chain"}] + * @example ethereum + */ + chain?: string; + /** @description Filter by types of actions */ + actionTypes?: ActionType[]; + /** @description Filter by types of assets */ + assetTypes?: ('fungible' | 'nft')[]; + /** @description Filter by asset id for fungible assets */ + fungibleId?: string; + /** @description Search query */ + searchQuery?: string; + /** @description Include spam transactions */ + includeSpam?: boolean; +} + +type Wallet = { + /** @example test.zerion.eth */ + name: string | null; + /** @example https://lh3.googleusercontent.com/MtCGsfm3h_n9wjzVloLzF4ocL4nhU9iYL81HKpZ4wZxCF6bwB2RFmK6hI7EO_fmPwPKjAx-d-qKsqNrVjn2jbJLibAW0-nBqYQ=s250 */ + iconUrl: string | null; + /** @example 0x42b9df65b219b3dd36ff330a4dd8f327a6ada990 */ + address: string; + /** @example false */ + premium?: boolean; + /** @description Indicates if this label is clickable in the UI */ + trackable?: boolean; +}; + +type DApp = { + /** + * @description Unique identifier for the DApp // [!code link {"token":"DApp","href":"/docs/actions/entities.html#dapp"}] + * @example uniswap-v2 + */ + id: string; + /** + * @description Name of the DApp // [!code link {"token":"DApp","href":"/docs/actions/entities.html#dapp"}] + * @example Uniswap V2 + */ + name: string; + /** + * @description URL to the DApp's icon // [!code link {"token":"DApp","href":"/docs/actions/entities.html#dapp"}] + * @example https://protocol-icons.s3.amazonaws.com/icons/uniswap-v2.jpg + */ + iconUrl: string | null; + /** + * @description URL to the DApp's website // [!code link {"token":"DApp","href":"/docs/actions/entities.html#dapp"}] + * @example https://app.uniswap.org/ + */ + url: string | null; +}; + +export type ActionLabel = { + /** + * @description Internal title for rendering + * @enum {string} + */ + title?: 'to' | 'from' | 'application'; + /** @description Human-readable display title in English, to be used as a fallback when title is unknown */ + displayTitle?: string; + wallet?: null | Wallet; + contract: null | { + address: string; + dapp: DApp; + }; +}; + +export type Amount = { + currency: string; + /** @description Amount in common units (like token units) */ + quantity: string; + /** @description Amount in fiat units */ + value: number | null; + /** @description Amount in USD */ + usdValue: number | null; +}; + +type Fee = { + /** @description Whether the network fee is free */ + free: boolean; + /** @description Fee amount (can be expected fee or max fee) */ + amount: Amount; + fungible: null | Fungible; +}; + +export type ActionFee = Fee; + +export type ActionDirection = 'in' | 'out'; + +export type Transfer = { + direction: ActionDirection; + amount: null | Amount; + fungible: null | Fungible; + nft: null | NFTPreview; +}; + +export type Approval = { + /** @description Whether the amount is unlimited */ + unlimited: boolean; + /** @description Approval amount, null if unlimited or collection/erc721 approval */ + amount: null | Amount; + fungible: null | Fungible; + nft: null | NFTPreview; + collection: null | Collection; +}; + +type Content = { + transfers: null | Transfer[]; + approvals: null | Approval[]; +}; + +export type ActionContent = Content; + +type Chain = { + id: string; + /** + * @description Name of the chain + * @example Ethereum + */ + name: string; + /** + * @description URL to the chain's icon + * @example https://example.com/icon.png + */ + iconUrl: string; +}; + +export type ActionChain = Chain; + +export type ActionTransaction = { + chain: Chain; + /** @description Unique identifier for the transaction, hash for EVM, signature for Solana */ + hash: string; + /** @description URL to the transaction on the blockchain explorer */ + explorerUrl: null | string; +}; + +type Rate = { + value: number; + symbol: string; +}[]; + +export type ActionRate = Rate; + +export type ActionStatus = 'confirmed' | 'failed' | 'pending' | 'dropped'; + +type Act = { + type: { value: ActionType; displayValue: string }; + label: ActionLabel | null; + content: Content | null; + /** @description Exchange rate, non-null only for Trade acts with exactly 2 transfers */ + rate: null | Rate; + /** + * @description Act status // [!code link {"token":"Act","href":"/docs/actions/entities.html#act"}] + * @enum {string} + */ + status: ActionStatus; + transaction: ActionTransaction; +}; + +type Action = { + address: string; + /** @description Unique identifier for the action */ + id: string; + /** @description Unix timestamp of the action */ + timestamp: number; + /** + * @description Action status // [!code link {"token":"Action","href":"/docs/actions/entities.html#action"}] + * @enum {string} + */ + status: ActionStatus; + type: { value: ActionType; displayValue: string }; + label: ActionLabel | null; + /** @description Fee */ + fee: null | Fee; + /** @description Refund */ + refund: null | { + /** @description Refund amount */ + amount: Amount; + fungible: null | Fungible; + }; + /** @description Gasback amount if applicable (when network fee is not free) */ + gasback: null | number; + acts: Act[] | null; + content: Content | null; + transaction: ActionTransaction | null; +}; + +export type AddressAction = Action; + +export type ActionWithoutTimestamp = Omit; + +export interface Response { + data: Action[]; + meta: { + pagination: { + /** + * @description Cursor can contain any type of information; clients should not rely on its contents, but should simply send it as it is. // [!code link {"token":"Cursor","href":"/docs/actions/entities.html#cursor"}] + * @example 10 + */ + cursor: string; + }; + } | null; + errors?: { title: string; detail: string }[]; +} + +export async function walletGetActions( + this: ZerionApiContext, + params: Payload, + options: ClientOptions = CLIENT_DEFAULTS +) { + invariant(params.addresses.length > 0, 'Addresses param is empty'); + const firstAddress = params.addresses[0]; + const provider = await this.getAddressProviderHeader(firstAddress); + const kyOptions = this.getKyOptions(); + const endpoint = 'wallet/get-actions/v1'; + const result = await ZerionHttpClient.post( + { + endpoint, + body: JSON.stringify(params), + headers: { 'Zerion-Wallet-Provider': provider }, + ...options, + }, + kyOptions + ); + return produce(result, (draft) => { + draft.data.forEach((action) => { + action.timestamp = action.timestamp * 1000; + }); + }); +} diff --git a/src/modules/zerion-api/requests/wallet-simulate-signature.ts b/src/modules/zerion-api/requests/wallet-simulate-signature.ts new file mode 100644 index 0000000000..4fab1bb588 --- /dev/null +++ b/src/modules/zerion-api/requests/wallet-simulate-signature.ts @@ -0,0 +1,53 @@ +import type { TypedData } from 'src/modules/ethereum/message-signing/TypedData'; +import type { ClientOptions } from '../shared'; +import { CLIENT_DEFAULTS, ZerionHttpClient } from '../shared'; +import type { ZerionApiContext } from '../zerion-api-bare'; +import type { AddressAction } from './wallet-get-actions'; +import type { Warning } from './wallet-simulate-transaction'; + +type Signature = { + /** @description Optional message string */ + message?: string; + typedData?: TypedData; +}; + +interface Payload { + /** + * @description Currency name for price conversions // [!code link {"token":"Currency","href":"/docs/actions/entities.html#currency"}] + * @example usd + */ + currency: string; + /** @description Wallet address */ + address: string; + /** @description Domain context */ + domain: string; + /** @description Blockchain network identifier */ + chain: string; + signature: Signature; +} + +type Response = { + data: { action: AddressAction; warnings: Warning[] }; + errors?: { title: string; detail: string }[]; +}; + +export type SignatureInterpretResponse = Response; + +export async function walletSimulateSignature( + this: ZerionApiContext, + params: Payload, + options: ClientOptions = CLIENT_DEFAULTS +) { + const provider = await this.getAddressProviderHeader(params.address); + const kyOptions = this.getKyOptions(); + const endpoint = 'wallet/simulate-signature/v1'; + return ZerionHttpClient.post( + { + endpoint, + body: JSON.stringify(params), + headers: { 'Zerion-Wallet-Provider': provider }, + ...options, + }, + kyOptions + ); +} diff --git a/src/modules/zerion-api/requests/wallet-simulate-transaction.ts b/src/modules/zerion-api/requests/wallet-simulate-transaction.ts new file mode 100644 index 0000000000..b9f8a670e4 --- /dev/null +++ b/src/modules/zerion-api/requests/wallet-simulate-transaction.ts @@ -0,0 +1,63 @@ +import type { TransactionEVM } from 'src/shared/types/Quote'; +import type { SolTxSerializable } from 'src/modules/solana/SolTransaction'; +import type { ClientOptions } from '../shared'; +import { CLIENT_DEFAULTS, ZerionHttpClient } from '../shared'; +import type { ZerionApiContext } from '../zerion-api-bare'; +import type { AddressAction } from './wallet-get-actions'; + +interface Payload { + /** + * @description Currency name for price conversions // [!code link {"token":"Currency","href":"/docs/actions/entities.html#currency"}] + * @example usd + */ + currency: string; + /** @description Wallet address */ + address: string; + /** @description Domain context */ + domain: string; + /** @description Blockchain network identifier */ + chain: string; + transaction: { + evm?: TransactionEVM; + solana?: SolTxSerializable; + }; +} + +export type WarningSeverity = 'Red' | 'Orange' | 'Yellow' | 'Gray'; + +export type Warning = { + /** @description Warning severity level */ + severity: WarningSeverity; + /** @description Warning title */ + title: string; + /** @description Warning description */ + description: string; + /** @description Additional warning details */ + details: string; +}; + +type Response = { + data: { action: AddressAction; warnings: Warning[] }; + errors?: { title: string; detail: string }[]; +}; + +export type InterpretResponse = Response; + +export async function walletSimulateTransaction( + this: ZerionApiContext, + params: Payload, + options: ClientOptions = CLIENT_DEFAULTS +) { + const provider = await this.getAddressProviderHeader(params.address); + const kyOptions = this.getKyOptions(); + const endpoint = 'wallet/simulate-transaction/v1'; + return ZerionHttpClient.post( + { + endpoint, + body: JSON.stringify(params), + headers: { 'Zerion-Wallet-Provider': provider }, + ...options, + }, + kyOptions + ); +} diff --git a/src/modules/zerion-api/zerion-api-bare.ts b/src/modules/zerion-api/zerion-api-bare.ts index a1cf893721..9d77cf0dd0 100644 --- a/src/modules/zerion-api/zerion-api-bare.ts +++ b/src/modules/zerion-api/zerion-api-bare.ts @@ -21,6 +21,9 @@ import { walletGetAssetDetails } from './requests/wallet-get-asset-details'; import { assetGetFungiblePnl } from './requests/asset-get-fungible-pnl'; import { assetGetChart } from './requests/asset-get-chart'; import { searchQuery } from './requests/search-query'; +import { walletGetActions } from './requests/wallet-get-actions'; +import { walletSimulateSignature } from './requests/wallet-simulate-signature'; +import { walletSimulateTransaction } from './requests/wallet-simulate-transaction'; export interface ZerionApiContext { getAddressProviderHeader(address: string): Promise; @@ -38,6 +41,9 @@ export const ZerionApiBare = { getPaymasterParams, walletGetPositions, walletGetPortfolio, + walletGetActions, + walletSimulateSignature, + walletSimulateTransaction, checkReferral, referWallet, claimRetro, diff --git a/src/shared/analytics/shared/addressActionToAnalytics.ts b/src/shared/analytics/shared/addressActionToAnalytics.ts index fc215cab0b..154f32b757 100644 --- a/src/shared/analytics/shared/addressActionToAnalytics.ts +++ b/src/shared/analytics/shared/addressActionToAnalytics.ts @@ -1,12 +1,6 @@ -import type { ActionAsset } from 'defi-sdk'; import { isTruthy } from 'is-truthy-ts'; -import { getFungibleAsset } from 'src/modules/ethereum/transactions/actionAsset'; import type { AnyAddressAction } from 'src/modules/ethereum/transactions/addressAction'; -import type { Chain } from 'src/modules/networks/Chain'; -import { createChain } from 'src/modules/networks/Chain'; -import { getDecimals } from 'src/modules/networks/asset'; import type { Quote2 } from 'src/shared/types/Quote'; -import { baseToCommon } from 'src/shared/units/convert'; interface AnalyticsTransactionData { action_type: string; @@ -25,56 +19,6 @@ interface AnalyticsTransactionData { gas_price?: number; } -interface AssetQuantity { - asset: ActionAsset; - quantity: string | null; -} - -function assetQuantityToValue( - assetWithQuantity: AssetQuantity, - chain: Chain -): number { - const { asset: actionAsset, quantity } = assetWithQuantity; - const asset = getFungibleAsset(actionAsset); - if (asset && 'implementations' in asset && asset.price && quantity !== null) { - return baseToCommon(quantity, getDecimals({ asset, chain })) - .times(asset.price.value) - .toNumber(); - } - return 0; -} - -function createPriceAdder(chain: Chain) { - return (total: number, assetQuantity: AssetQuantity) => { - total += assetQuantityToValue(assetQuantity, chain); - return total; - }; -} - -function createQuantityConverter(chain: Chain) { - return ({ - asset: actionAsset, - quantity, - }: { - asset: ActionAsset; - quantity: string | null; - }): string | null => { - const asset = getFungibleAsset(actionAsset); - if (asset && quantity !== null) { - return baseToCommon(quantity, getDecimals({ asset, chain })).toFixed(); - } - return null; - }; -} - -function getAssetName({ asset }: { asset: ActionAsset }) { - return getFungibleAsset(asset)?.name; -} - -function getAssetAddress({ asset }: { asset: ActionAsset }) { - return getFungibleAsset(asset)?.asset_code; -} - export function toMaybeArr( arr: (T | null | undefined)[] | null | undefined ): T[] | undefined { @@ -98,23 +42,35 @@ export function addressActionToAnalytics({ usd_amount_sent: null, }; } - const chain = createChain(addressAction.transaction.chain); - const convertQuantity = createQuantityConverter(chain); - const addAssetPrice = createPriceAdder(chain); - - const outgoing = addressAction.content?.transfers?.outgoing; - const incoming = addressAction.content?.transfers?.incoming; + const outgoing = addressAction.acts + ?.at(0) + ?.content?.transfers?.filter(({ direction }) => direction === 'out'); + const incoming = addressAction.acts + ?.at(0) + ?.content?.transfers?.filter(({ direction }) => direction === 'in'); const value = { - action_type: addressAction.type.display_value || 'Execute', - usd_amount_sent: outgoing?.reduce(addAssetPrice, 0) ?? null, - usd_amount_received: incoming?.reduce(addAssetPrice, 0) ?? null, - asset_amount_sent: toMaybeArr(outgoing?.map(convertQuantity)), - asset_amount_received: toMaybeArr(incoming?.map(convertQuantity)), - asset_name_sent: toMaybeArr(outgoing?.map(getAssetName)), - asset_name_received: toMaybeArr(incoming?.map(getAssetName)), - asset_address_sent: toMaybeArr(outgoing?.map(getAssetAddress)), - asset_address_received: toMaybeArr(incoming?.map(getAssetAddress)), + action_type: addressAction.type.displayValue || 'Execute', + usd_amount_sent: + outgoing?.reduce((acc, item) => acc + (item.amount?.usdValue || 0), 0) ?? + null, + usd_amount_received: + incoming?.reduce((acc, item) => acc + (item.amount?.usdValue || 0), 0) ?? + null, + asset_amount_sent: toMaybeArr( + outgoing?.map((item) => item.amount?.quantity) + ), + asset_amount_received: toMaybeArr( + incoming?.map((item) => item.amount?.quantity) + ), + asset_name_sent: toMaybeArr(outgoing?.map((item) => item.fungible?.name)), + asset_name_received: toMaybeArr( + incoming?.map((item) => item.fungible?.name) + ), + asset_address_sent: toMaybeArr(outgoing?.map((item) => item.fungible?.id)), + asset_address_received: toMaybeArr( + incoming?.map((item) => item.fungible?.id) + ), }; if (quote) { const zerion_fee_percentage = quote.protocolFee.percentage; diff --git a/src/ui/App/App.tsx b/src/ui/App/App.tsx index 202e0aab3f..97ff2bf250 100644 --- a/src/ui/App/App.tsx +++ b/src/ui/App/App.tsx @@ -84,6 +84,7 @@ import { ProgrammaticNavigationHelper } from '../shared/routing/ProgrammaticNavi import { Invite } from '../features/referral-program'; import { XpDrop } from '../features/xp-drop'; import { BridgeForm } from '../pages/BridgeForm'; +import { ActionInfo } from '../pages/ActionInfo'; import { TurnstileTokenHandler } from '../features/turnstile'; import { AnalyticsIdHandler } from '../shared/analytics/AnalyticsIdHandler'; import { ScreenViewTracker } from '../shared/ScreenViewTracker'; @@ -251,6 +252,14 @@ function Views({ initialRoute }: { initialRoute?: string }) { } /> + + + + } + /> void; }) { const { currency } = useCurrency(); @@ -34,38 +34,38 @@ export function AllowanceView({ 'requestedAllowanceQuantityBase is required to set custom allowance' ); - const assetIds = asset ? [asset?.asset_code] : []; + const assetIds = assetId ? [assetId] : []; const { data, isLoading: positionsAreLoading } = useHttpAddressPositions( { addresses: [address], currency, assetIds }, { source: useHttpClientSource() }, { - enabled: Boolean(asset), + enabled: Boolean(assetId), refetchInterval: usePositionsRefetchInterval(10000), } ); const positions = data?.data; - const positionQuantity = useMemo( + const position = useMemo( () => positions?.find( - (position) => position.chain === chain.toString() && !position.dapp?.id - )?.quantity, - [chain, positions] + (position) => position.chain === network.id && !position.dapp?.id + ), + [network.id, positions] ); - const balance = useMemo( - () => - positionQuantity && asset - ? getCommonQuantity({ - asset, - chain, - baseQuantity: positionQuantity, - }) - : null, - [asset, chain, positionQuantity] - ); + const chain = createChain(network.id); + + const balance = useMemo(() => { + return position?.quantity + ? getCommonQuantity({ + asset: position.asset, + chain, + baseQuantity: position?.quantity, + }) + : null; + }, [chain, position]); - if (positionsAreLoading || !asset) { + if (positionsAreLoading || !position?.asset) { return ; } @@ -74,7 +74,7 @@ export function AllowanceView({ - ) : nft?.icon_url || nft?.collection?.icon_url ? ( + return fungible?.iconUrl ? ( + + ) : nft?.metadata?.content?.imagePreviewUrl ? ( + ) : collection?.iconUrl ? ( + ) : ( (fallback as JSX.Element) diff --git a/src/ui/components/AssetLink/AssetLink.tsx b/src/ui/components/AssetLink/AssetLink.tsx index e73733b707..f00f0c5e31 100644 --- a/src/ui/components/AssetLink/AssetLink.tsx +++ b/src/ui/components/AssetLink/AssetLink.tsx @@ -1,6 +1,6 @@ import type { Asset } from 'defi-sdk'; import React from 'react'; -import { useStatsigExperiment } from 'src/modules/statsig/statsig.client'; +import type { Fungible } from 'src/modules/zerion-api/types/Fungible'; import { usePreferences } from 'src/ui/features/preferences'; import { openInNewWindow } from 'src/ui/shared/openInNewWindow'; import { TextAnchor } from 'src/ui/ui-kit/TextAnchor'; @@ -11,7 +11,7 @@ export function AssetAnchor({ title, address, }: { - asset: Pick; + asset: Pick; title?: string; address?: string; }) { @@ -22,13 +22,14 @@ export function AssetAnchor({ } return ( { e.stopPropagation(); openInNewWindow(e); }} + title={content} style={{ overflow: 'hidden', textOverflow: 'ellipsis', @@ -42,29 +43,21 @@ export function AssetAnchor({ } export function AssetLink({ - asset, + fungible, title, - address, }: { - asset: Pick; + fungible: Fungible; title?: string; - address?: string; }) { const { preferences } = usePreferences(); - const { data: statsigData } = useStatsigExperiment( - 'android-revamp_asset_page_version_1' - ); - const assetPageEnabled = statsigData?.group_name === 'Group1'; - const content = title || asset.symbol || asset.name; + const content = title || fungible.symbol || fungible.name; if (preferences?.testnetMode?.on) { return content; } - if (!assetPageEnabled) { - return ; - } return ( @@ -58,7 +59,7 @@ export function AssetQuantity({ ) : null} {level === QuantityLevel.large ? `${muchGreater} 1T` : null} {level === QuantityLevel.normal - ? `${sign || ''}${formatWithSignificantValue(commonQuantity)}` + ? `${sign || ''}${formatWithSignificantValue(quantityNumber)}` : null} ); diff --git a/src/ui/components/NFTLink/NFTLink.tsx b/src/ui/components/NFTLink/NFTLink.tsx index 1a10cd70ad..21411f5dbd 100644 --- a/src/ui/components/NFTLink/NFTLink.tsx +++ b/src/ui/components/NFTLink/NFTLink.tsx @@ -1,28 +1,20 @@ -import type { NFTAsset } from 'defi-sdk'; import React from 'react'; -import type { Chain } from 'src/modules/networks/Chain'; -import { NetworkId } from 'src/modules/networks/NetworkId'; +import type { NFTPreview } from 'src/modules/zerion-api/requests/wallet-get-actions'; import { openInNewWindow } from 'src/ui/shared/openInNewWindow'; import { TextAnchor } from 'src/ui/ui-kit/TextAnchor'; +import { TextLink } from 'src/ui/ui-kit/TextLink'; -export function NFTLink({ +export function NFTAnchor({ nft, - chain, address, - title, }: { - nft: NFTAsset; - chain?: Chain; + nft: NFTPreview; address?: string; - title?: string; }) { return ( { e.stopPropagation(); @@ -35,7 +27,24 @@ export function NFTLink({ outlineOffset: -1, // make focus ring visible despite overflow: hidden }} > - {title || nft.name} + {nft.metadata?.name || 'NFT'} ); } + +export function NFTLink({ nft }: { nft: NFTPreview }) { + return ( + + ); +} diff --git a/src/ui/components/TiltCard/index.ts b/src/ui/components/TiltCard/index.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/ui/components/TiltCard/styles.module.css b/src/ui/components/TiltCard/styles.module.css deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/ui/components/address-action/ActInfo/ActInfo.tsx b/src/ui/components/address-action/ActInfo/ActInfo.tsx new file mode 100644 index 0000000000..d2663b8c12 --- /dev/null +++ b/src/ui/components/address-action/ActInfo/ActInfo.tsx @@ -0,0 +1,287 @@ +import React, { useMemo } from 'react'; +import { UIText } from 'src/ui/ui-kit/UIText'; +import { VStack } from 'src/ui/ui-kit/VStack'; +import { minus } from 'src/ui/shared/typography'; +import { Surface } from 'src/ui/ui-kit/Surface'; +import { Spacer } from 'src/ui/ui-kit/Spacer'; +import { animated, useSpring } from '@react-spring/web'; +import type { AnyAddressAction } from 'src/modules/ethereum/transactions/addressAction'; +import type { + NFTPreview, + ActionDirection, + Amount, + Collection, +} from 'src/modules/zerion-api/requests/wallet-get-actions'; +import { formatPriceValue } from 'src/shared/units/formatPriceValue'; +import { formatTokenValue } from 'src/shared/units/formatTokenValue'; +import { HStack } from 'src/ui/ui-kit/HStack'; +import { TokenIcon } from 'src/ui/ui-kit/TokenIcon'; +import type { Fungible } from 'src/modules/zerion-api/types/Fungible'; +import { isUnlimitedApproval } from 'src/modules/ethereum/transactions/appovals'; +import { AssetAnchor } from '../../AssetLink'; +import { NFTAnchor } from '../../NFTLink/NFTLink'; + +function AssetContent({ + fungible, + nft, + collection, + direction, + amount, + unlimited, + address, +}: { + fungible: Fungible | null; + nft: NFTPreview | null; + collection: Collection | null; + direction: ActionDirection | null; + amount: Amount | null; + unlimited?: boolean; + address: string; +}) { + if (fungible) { + return ( + + + + + + + {unlimited || isUnlimitedApproval(amount?.quantity) + ? 'Unlimited' + : amount?.quantity + ? `${ + direction === 'out' + ? minus + : direction === 'in' + ? '+' + : '' + }${formatTokenValue(amount?.quantity || '0', '')}` + : null} + + + + + {direction != null ? ( + + {amount?.value != null + ? formatPriceValue(amount.value || '0', 'en', amount.currency) + : 'N/A'} + + ) : null} + + + ); + } + + if (nft) { + return ( + + + + + + + {amount?.quantity + ? `${ + direction === 'out' + ? minus + : direction === 'in' + ? '+' + : '' + }${formatTokenValue(amount?.quantity || '0', '')}` + : null} + + + + + + {direction != null ? ( + + {amount?.value != null + ? formatPriceValue(amount.value || '0', 'en', amount.currency) + : 'N/A'} + + ) : null} + + + + ); + } + + if (collection) { + return ( + + + + {collection.name} + + + ); + } +} + +function Appear({ + children, + delay = 0, +}: React.PropsWithChildren<{ delay?: number }>) { + const style = useSpring({ + from: { opacity: 0 }, + to: { opacity: 1 }, + delay, + }); + return {children}; +} + +export function ActInfo({ + address, + act, + initialDelay, + elementEnd, +}: { + address: string; + act: NonNullable[number]; + initialDelay: number; + elementEnd: React.ReactNode; +}) { + const incomingTransfers = useMemo( + () => act.content?.transfers?.filter(({ direction }) => direction === 'in'), + [act.content?.transfers] + ); + const outgoingTransfers = useMemo( + () => + act.content?.transfers?.filter(({ direction }) => direction === 'out'), + [act.content?.transfers] + ); + const approvals = act.content?.approvals; + + const outgoingDelay = initialDelay + (approvals?.length ? 150 : 0); + const incomingDelay = + initialDelay + + (outgoingTransfers?.length ? 150 : 0) + + (approvals?.length ? 150 : 0); + + if ( + !approvals?.length && + !outgoingTransfers?.length && + !incomingTransfers?.length + ) { + return null; + } + + return ( + + {approvals?.length ? ( + + + + Allow to Spend + + + {approvals.map((approval, index) => ( + + ))} + + + + ) : null} + {outgoingTransfers?.length ? ( + + + + Send + + + {outgoingTransfers.map((transfer, index) => ( + + ))} + + + + ) : null} + {incomingTransfers?.length ? ( + + + + Receive + + + + {incomingTransfers.map((transfer, index) => ( + + ))} + + + + ) : null} + + + {act.type.displayValue} + + {elementEnd ? ( +
+ {elementEnd} +
+ ) : null} +
+
+ ); +} diff --git a/src/ui/components/address-action/ActInfo/index.ts b/src/ui/components/address-action/ActInfo/index.ts new file mode 100644 index 0000000000..8a7df9794a --- /dev/null +++ b/src/ui/components/address-action/ActInfo/index.ts @@ -0,0 +1 @@ +export { ActInfo } from './ActInfo'; diff --git a/src/ui/components/address-action/AddressActionDetails/AddressActionDetails.tsx b/src/ui/components/address-action/AddressActionDetails/AddressActionDetails.tsx index ecb0b7960f..39d1909ca9 100644 --- a/src/ui/components/address-action/AddressActionDetails/AddressActionDetails.tsx +++ b/src/ui/components/address-action/AddressActionDetails/AddressActionDetails.tsx @@ -1,145 +1,76 @@ -import React from 'react'; -import { createChain, type Chain } from 'src/modules/networks/Chain'; -import type { ActionTransfers, AddressAction } from 'defi-sdk'; -import type { Networks } from 'src/modules/networks/Networks'; +import React, { useMemo } from 'react'; import type { AnyAddressAction } from 'src/modules/ethereum/transactions/addressAction'; -import { useNetworks } from 'src/modules/networks/useNetworks'; import { VStack } from 'src/ui/ui-kit/VStack'; -import { InterpretationSecurityCheck } from 'src/ui/shared/security-check'; -import type { InterpretResponse } from 'src/modules/ethereum/transactions/types'; -import { RenderArea } from 'react-area'; +import type { NetworkConfig } from 'src/modules/networks/NetworkConfig'; +import { applyCustomAllowance } from 'src/modules/ethereum/transactions/appovals'; import { RecipientLine } from '../RecipientLine'; import { ApplicationLine } from '../ApplicationLine'; -import { Transfers } from '../Transfers'; -import { SingleAsset } from '../SingleAsset'; +import { ActInfo } from '../ActInfo'; export function AddressActionDetails({ address, - recipientAddress, addressAction, - chain, - networks, - actionTransfers, - singleAsset, - allowanceQuantityBase, + network, + allowanceQuantityCommon, + customAllowanceQuantityBase, showApplicationLine, singleAssetElementEnd, }: { address: string; - recipientAddress?: string; - addressAction?: Pick; - chain: Chain; - networks: Networks; - actionTransfers?: ActionTransfers; - singleAsset?: NonNullable['single_asset']; - allowanceQuantityBase: string | null; + addressAction?: AnyAddressAction; + network: NetworkConfig; + allowanceQuantityCommon: string | null; + customAllowanceQuantityBase: string | null; showApplicationLine: boolean; singleAssetElementEnd: React.ReactNode; }) { + const recipientAddress = addressAction?.label?.wallet?.address; const showRecipientLine = recipientAddress && addressAction?.type.value === 'send'; - const applicationLineVisible = - showApplicationLine && addressAction?.label && !showRecipientLine; + showApplicationLine && addressAction?.label?.contract && !showRecipientLine; + + const actionWithAppliedAllowance = useMemo( + () => + applyCustomAllowance({ + addressAction, + customAllowanceQuantityCommon: allowanceQuantityCommon, + customAllowanceQuantityBase, + }), + [addressAction, allowanceQuantityCommon, customAllowanceQuantityBase] + ); + + const showEndElement = + addressAction?.acts?.length === 1 && + !addressAction.acts[0].content?.transfers && + addressAction.acts[0].content?.approvals?.length === 1; return ( <> {showRecipientLine ? ( ) : null} {applicationLineVisible ? ( - + ) : null} - {actionTransfers?.outgoing?.length || - actionTransfers?.incoming?.length ? ( - - ) : null} - {singleAsset && addressAction ? ( - + {actionWithAppliedAllowance?.acts?.length ? ( + + {actionWithAppliedAllowance.acts.map((act, index) => ( + + ))} + ) : null} ); } - -const UNSUPPORTED_NETWORK_INTERPRETATION: InterpretResponse = { - action: null, - warnings: [ - { - severity: 'Gray', - title: '', - description: - 'Our security checks do not support Solana network right now. Please proceed with caution.', - }, - ], -}; - -/** - * TODO: Temporary helper, later the whole AddressActionDetails - * must be refactored to take as few params as possible - * and to derive as much data as possible from `addressAction` - */ -export function AddressActionComponent({ - address, - addressAction, - showApplicationLine, - vGap = 16, -}: { - address: string; - addressAction: AnyAddressAction; - showApplicationLine: boolean; - vGap?: number; -}) { - const recipientAddress = addressAction.label?.display_value.wallet_address; - const actionTransfers = addressAction.content?.transfers; - const singleAsset = addressAction.content?.single_asset; - const { networks } = useNetworks(); - - if (!networks) { - return null; - } - - return ( - - - - - - ); -} diff --git a/src/ui/components/address-action/ApplicationLine/ApplicationLine.tsx b/src/ui/components/address-action/ApplicationLine/ApplicationLine.tsx index 73620eb2a9..168f9180bb 100644 --- a/src/ui/components/address-action/ApplicationLine/ApplicationLine.tsx +++ b/src/ui/components/address-action/ApplicationLine/ApplicationLine.tsx @@ -1,12 +1,9 @@ import { animated, useTransition } from '@react-spring/web'; -import { capitalize } from 'capitalize-ts'; import React, { useLayoutEffect, useState } from 'react'; import ArrowLeftTop from 'jsx:src/ui/assets/arrow-left-top.svg'; import { toChecksumAddress } from 'src/modules/ethereum/toChecksumAddress'; import type { AnyAddressAction } from 'src/modules/ethereum/transactions/addressAction'; -import type { Chain } from 'src/modules/networks/Chain'; import type { NetworkConfig } from 'src/modules/networks/NetworkConfig'; -import type { Networks } from 'src/modules/networks/Networks'; import { BlockieImg } from 'src/ui/components/BlockieImg'; import { NetworkIcon } from 'src/ui/components/NetworkIcon'; import { ShuffleText } from 'src/ui/components/ShuffleText'; @@ -18,6 +15,7 @@ import { Image } from 'src/ui/ui-kit/MediaFallback'; import { Surface } from 'src/ui/ui-kit/Surface'; import { TextAnchor } from 'src/ui/ui-kit/TextAnchor'; import { UIText } from 'src/ui/ui-kit/UIText'; +import { Networks } from 'src/modules/networks/Networks'; const FadeOutAndIn = ({ src, @@ -58,15 +56,15 @@ const FadeOutAndIn = ({ }; function ApplicationImage({ - action, + addressAction, network, }: { - action: Pick; + addressAction: Pick; network: NetworkConfig | null; }) { return ( (
( @@ -116,47 +110,35 @@ function ApplicationImage({ ); } -function getApplicationAddress(action: Pick) { - const contractAddress = action.label?.display_value.contract_address; - if (contractAddress) { - return contractAddress; - } - - if (!action.label?.value) { - return null; - } - - try { - return toChecksumAddress(action.label.value); - } catch { - return null; - } -} - export function ApplicationLine({ - action, - chain, - networks, + addressAction, + network, }: { - action: Pick; - chain: Chain; - networks: Networks; + addressAction: Pick; + network: NetworkConfig; }) { - const network = networks.getNetworkByName(chain) || null; + const applicationAddress = addressAction.label?.contract?.address + ? toChecksumAddress(addressAction.label.contract.address) + : null; - // TODO: Remove this hacky fallback once we have backend support - const applicationAddress = getApplicationAddress(action); + const name = + addressAction.label?.contract?.dapp.name !== + addressAction.label?.contract?.address + ? addressAction.label?.contract?.dapp.name + : null; return ( } + image={ + + } vGap={0} text={ - action.label?.type ? ( + addressAction.label?.displayTitle ? ( - {capitalize(action.label.type)} + {addressAction.label.displayTitle} ) : null } @@ -165,7 +147,7 @@ export function ApplicationLine({ toChecksumAddress(recipientAddress), [recipientAddress] ); + + const showRecipientName = recipientName && recipientName !== recipientAddress; + return ( - {truncateAddress(checksumAddress, 15)} + {showRecipientName + ? recipientName + : truncateAddress(checksumAddress, 15)} } diff --git a/src/ui/components/address-action/SingleAsset/SingleAsset.tsx b/src/ui/components/address-action/SingleAsset/SingleAsset.tsx deleted file mode 100644 index 55d36e6c8c..0000000000 --- a/src/ui/components/address-action/SingleAsset/SingleAsset.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import type { ActionType, AddressAction, Asset } from 'defi-sdk'; -import type { NFTAsset } from 'defi-sdk'; -import React, { useMemo } from 'react'; -import BigNumber from 'bignumber.js'; -import UnknownIcon from 'jsx:src/ui/assets/actionTypes/unknown.svg'; -import { - getFungibleAsset, - getNftAsset, -} from 'src/modules/ethereum/transactions/actionAsset'; -import { Media } from 'src/ui/ui-kit/Media'; -import { Surface } from 'src/ui/ui-kit/Surface'; -import { UIText } from 'src/ui/ui-kit/UIText'; -import { TokenIcon } from 'src/ui/ui-kit/TokenIcon'; -import { getCommonQuantity } from 'src/modules/networks/asset'; -import type { Chain } from 'src/modules/networks/Chain'; -import { VStack } from 'src/ui/ui-kit/VStack'; -import { HStack } from 'src/ui/ui-kit/HStack'; -import { isUnlimitedApproval } from 'src/ui/pages/History/isUnlimitedApproval'; -import { AssetQuantity } from '../../AssetQuantity'; -import { AssetAnchor } from '../../AssetLink'; - -function AssetAllowance({ - baseQuantity, - commonQuantity, -}: { - baseQuantity: BigNumber; - commonQuantity: BigNumber; -}) { - if (isUnlimitedApproval(baseQuantity)) { - return Unlimited; - } else { - return ; - } -} - -function FungibleAsset({ - address, - chain, - actionType, - fungible, - quantity, - elementEnd, -}: { - address: string; - chain: Chain; - actionType: ActionType; - fungible: Asset; - quantity: string; - elementEnd?: React.ReactNode; -}) { - const commonQuantity = useMemo( - () => - getCommonQuantity({ - asset: fungible, - chain, - baseQuantity: quantity, - }), - [chain, fungible, quantity] - ); - - return ( - - - {actionType === 'approve' ? ( - - Allow to spend - - ) : null} - - ) : ( - - ) - } - text={ - - - {actionType === 'approve' ? ( - - ) : ( - - )}{' '} - - - {elementEnd} - - } - detailText={null} - /> - - - ); -} - -function NFTAssetComponent({ nft }: { nft: NFTAsset }) { - const iconUrl = nft.icon_url || nft.collection?.icon_url; - - return ( - - - - Access to collection - - - ) : ( - - ) - } - text={{nft.name}} - detailText={null} - /> - - - ); -} - -export function SingleAsset({ - address, - chain, - actionType, - singleAsset, - allowanceQuantityBase, - elementEnd, -}: { - address: string; - chain: Chain; - actionType: ActionType; - singleAsset: NonNullable< - NonNullable['single_asset'] - >; - allowanceQuantityBase: string | null; - elementEnd?: React.ReactNode; -}) { - const fungibleAsset = getFungibleAsset(singleAsset.asset); - const nftAsset = getNftAsset(singleAsset.asset); - - // The actual quantity that we want to display here could be either: - // 1) The value that user set as approved spending allowance - // 2) Original value that we got from local or interpreted address action - const quantity = allowanceQuantityBase || singleAsset.quantity; - - if (fungibleAsset) { - return ( - - ); - } - if (nftAsset) { - return ; - } - - return null; -} diff --git a/src/ui/components/address-action/SingleAsset/index.ts b/src/ui/components/address-action/SingleAsset/index.ts deleted file mode 100644 index fff6e2e3c8..0000000000 --- a/src/ui/components/address-action/SingleAsset/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SingleAsset } from './SingleAsset'; diff --git a/src/ui/components/address-action/TransactionConfirmationView/TransactionConfirmationView.tsx b/src/ui/components/address-action/TransactionConfirmationView/TransactionConfirmationView.tsx index 53bd240600..452e98bdfb 100644 --- a/src/ui/components/address-action/TransactionConfirmationView/TransactionConfirmationView.tsx +++ b/src/ui/components/address-action/TransactionConfirmationView/TransactionConfirmationView.tsx @@ -58,8 +58,7 @@ export function TransactionConfirmationView({ eligibilityQuery, origin: 'https://app.zerion.io', }); - const gasbackValue = - txInterpretQuery.data?.action?.transaction.gasback ?? null; + const gasbackValue = txInterpretQuery.data?.data.action?.gasback ?? null; useEffect(() => { if (gasbackValue != null) { onGasbackReady?.(gasbackValue); @@ -67,7 +66,7 @@ export function TransactionConfirmationView({ }, [gasbackValue, onGasbackReady]); const interpretationString = useMemo(() => { - return JSON.stringify(txInterpretQuery.data?.action); + return JSON.stringify(txInterpretQuery.data?.data.action); }, [txInterpretQuery]); if (query.isLoading) { @@ -155,18 +154,14 @@ export function TransactionConfirmationView({ paymasterPossible={paymasterPossible} paymasterWaiting={false} gasback={ - txInterpretQuery.data?.action?.transaction.gasback != null - ? { - value: - txInterpretQuery.data?.action.transaction.gasback, - } + txInterpretQuery.data?.data.action?.gasback != null + ? { value: txInterpretQuery.data?.data.action.gasback } : null } /> - ) : txInterpretQuery.data?.action?.transaction.fee ? ( + ) : txInterpretQuery.data?.data.action?.fee ? ( ) : null} diff --git a/src/ui/components/address-action/TransactionSimulation/TransactionSimulation.tsx b/src/ui/components/address-action/TransactionSimulation/TransactionSimulation.tsx index 059715997f..eef8869d4c 100644 --- a/src/ui/components/address-action/TransactionSimulation/TransactionSimulation.tsx +++ b/src/ui/components/address-action/TransactionSimulation/TransactionSimulation.tsx @@ -29,6 +29,10 @@ import { solFromBase64 } from 'src/modules/solana/transactions/create'; import { createChain } from 'src/modules/networks/Chain'; import { NetworkId } from 'src/modules/networks/NetworkId'; import type { MultichainTransaction } from 'src/shared/types/MultichainTransaction'; +import { getActionApproval } from 'src/modules/ethereum/transactions/addressAction'; +import { baseToCommon } from 'src/shared/units/convert'; +import { getDecimals } from 'src/modules/networks/asset'; +import { UNLIMITED_APPROVAL_AMOUNT } from 'src/modules/ethereum/constants'; import { AddressActionDetails } from '../AddressActionDetails'; export function TransactionSimulation({ @@ -104,29 +108,41 @@ export function TransactionSimulation({ const localSolanaAddressAction = useMemo(() => { return transaction.solana - ? parseSolanaTransaction(address, solFromBase64(transaction.solana)) + ? parseSolanaTransaction( + address, + solFromBase64(transaction.solana), + currency + ) : null; - }, [transaction.solana, address]); + }, [transaction.solana, address, currency]); const interpretation = txInterpretQuery.data; - const interpretAddressAction = interpretation?.action; + const interpretAddressAction = interpretation?.data.action; const addressAction = interpretAddressAction || localEvmAddressAction || localSolanaAddressAction; + + const maybeApproval = addressAction ? getActionApproval(addressAction) : null; + const requestedAllowanceQuantityCommon = + maybeApproval?.amount?.quantity ?? UNLIMITED_APPROVAL_AMOUNT.toFixed(); + + const chain = transaction.evm ? evmChain : solanaChain; + + const allowanceQuantityCommon = + chain && maybeApproval?.fungible && customAllowanceValueBase + ? baseToCommon( + customAllowanceValueBase, + getDecimals({ asset: maybeApproval.fungible, chain }) + ).toFixed() + : requestedAllowanceQuantityCommon; + if (!addressAction || !networks) { return

loading...

; } - const recipientAddress = addressAction.label?.display_value.wallet_address; - const actionTransfers = addressAction.content?.transfers; - const singleAsset = addressAction.content?.single_asset; - - // TODO: what if network doesn't support simulations (txInterpretQuery is idle or isError), - // but this is an approval tx? Can there be a bug? - const allowanceQuantityBase = - customAllowanceValueBase || addressAction.content?.single_asset?.quantity; - const chain = transaction.evm ? evmChain : solanaChain; invariant(chain, 'Chain must be defined for transaction simulation'); + const network = networks.getByNetworkId(chain); + invariant(network, 'Network must be known for transaction simulation'); return ( <> @@ -149,8 +165,7 @@ export function TransactionSimulation({ closeKind="icon" /> - getCommonQuantity({ - asset: fungible, - chain, - baseQuantity: transfer.quantity, - }), - [chain, fungible, transfer.quantity] - ); - const amountInUsd = useMemo(() => { - if (transfer.price == null) { - return noValueDash; - } - const commonQuantity = getCommonQuantity({ - asset: fungible, - chain, - baseQuantity: transfer.quantity, - }); - return formatCurrencyValue( - commonQuantity.times(transfer.price), - 'en', - currency - ); - }, [chain, fungible, transfer, currency]); - - return ( - } - /> - } - text={ - - {' '} - - - } - detailText={ - - {amountInUsd} - - } - /> - ); -} - -function TransferItemNFT({ - transfer, - nft, - direction, -}: { - transfer: ActionTransfer; - nft: NFTAsset; - direction: Direction; -}) { - return ( - } - /> - } - text={ - - - - } - detailText={ - - Amount: {transfer.quantity} - - } - /> - ); -} - -function TransferItem({ - address, - transfer, - chain, - direction, -}: { - address: string; - transfer: ActionTransfer; - chain: Chain; - direction: Direction; -}) { - const fungible = getFungibleAsset(transfer.asset); - const nft = getNftAsset(transfer.asset); - - if (fungible) { - return ( - - ); - } else if (nft) { - return ( - - ); - } - - return null; -} - -function Appear({ - children, - delay = 0, -}: React.PropsWithChildren<{ delay?: number }>) { - const style = useSpring({ - from: { opacity: 0 }, - to: { opacity: 1 }, - delay, - }); - return {children}; -} - -export function Transfers({ - address, - chain, - transfers, -}: { - address: string; - chain: Chain; - transfers: ActionTransfers; -}) { - return ( - - {transfers.outgoing?.length ? ( - - - - Send - - - - {transfers.outgoing.map((transfer) => ( - - ))} - - - - ) : null} - {transfers.incoming?.length ? ( - - - - Receive - - - - {transfers.incoming.map((transfer) => ( - - ))} - - - - ) : null} - - ); -} diff --git a/src/ui/components/address-action/Transfers/index.ts b/src/ui/components/address-action/Transfers/index.ts deleted file mode 100644 index 55da812a41..0000000000 --- a/src/ui/components/address-action/Transfers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Transfers } from './Transfers'; diff --git a/src/ui/pages/ActionInfo/ActionInfo.tsx b/src/ui/pages/ActionInfo/ActionInfo.tsx new file mode 100644 index 0000000000..9f9ec88a22 --- /dev/null +++ b/src/ui/pages/ActionInfo/ActionInfo.tsx @@ -0,0 +1,409 @@ +import { capitalize } from 'capitalize-ts'; +import React, { useMemo } from 'react'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import type { + AddressAction, + ActionDirection, + Amount, + Collection, + NFTPreview, +} from 'src/modules/zerion-api/requests/wallet-get-actions'; +import { invariant } from 'src/shared/invariant'; +import { isNumeric } from 'src/shared/isNumeric'; +import { NavigationTitle } from 'src/ui/components/NavigationTitle'; +import { PageColumn } from 'src/ui/components/PageColumn'; +import { isInteractiveElement } from 'src/ui/shared/isInteractiveElement'; +import { UIText } from 'src/ui/ui-kit/UIText'; +import { UnstyledButton } from 'src/ui/ui-kit/UnstyledButton'; +import { VStack } from 'src/ui/ui-kit/VStack'; +import ChevronRightIcon from 'jsx:src/ui/assets/chevron-right.svg'; +import { HStack } from 'src/ui/ui-kit/HStack'; +import { TokenIcon } from 'src/ui/ui-kit/TokenIcon'; +import { formatTokenValue } from 'src/shared/units/formatTokenValue'; +import { minus } from 'src/ui/shared/typography'; +import { AssetLink } from 'src/ui/components/AssetLink'; +import { formatPriceValue } from 'src/shared/units/formatPriceValue'; +import { NFTLink } from 'src/ui/components/NFTLink'; +import type { Fungible } from 'src/modules/zerion-api/types/Fungible'; +import { RateLine } from './RateLine'; +import { LabelLine } from './LabelLine'; +import { FeeLine } from './FeeLine'; +import { ExplorerInfo } from './ExplorerInfo'; +import * as styles from './styles.module.css'; + +const dateFormatter = new Intl.DateTimeFormat('en', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', +}); + +function AssetContent({ + fungible, + nft, + collection, + direction, + amount, + unlimited, +}: { + fungible: Fungible | null; + nft: NFTPreview | null; + collection: Collection | null; + direction: ActionDirection | null; + amount: Amount | null; + unlimited?: boolean; +}) { + if (fungible) { + return ( + + + + + + + {unlimited + ? 'Unlimited' + : amount?.quantity + ? `${ + direction === 'out' + ? minus + : direction === 'in' + ? '+' + : '' + }${formatTokenValue(amount?.quantity || '0', '')}` + : null} + + + + + {direction != null ? ( + + {amount?.value != null + ? formatPriceValue(amount.value || '0', 'en', amount.currency) + : 'N/A'} + + ) : null} + + + ); + } + + if (nft) { + return ( + + + + + + + + {direction != null ? ( + + + + {amount?.quantity + ? `Amount: ${formatTokenValue( + amount?.quantity || '0', + '' + )}` + : null} + + + {amount?.value != null + ? formatPriceValue( + amount.value || '0', + 'en', + amount.currency + ) + : null} + + + + ) : null} + + + + ); + } + + if (collection) { + return ( + + + {collection.name} + + ); + } +} + +function TransferDivider() { + return ( +
+
+
+
+ ); +} + +function ActContent({ + act, + showActType, +}: { + act: NonNullable[number]; + showActType: boolean; +}) { + const approvals = act.content?.approvals; + const incomingTransfers = useMemo( + () => + act.content?.transfers?.filter((transfer) => transfer.direction === 'in'), + [act.content?.transfers] + ); + const outgoingTransfers = useMemo( + () => + act.content?.transfers?.filter( + (transfer) => transfer.direction === 'out' + ), + [act.content?.transfers] + ); + + return ( +
+ {showActType ? ( + + {act.type.displayValue} + + ) : null} + {approvals?.length ? ( + + {showActType ? null : ( + + Allow to spend + + )} + {approvals.map((approval, index) => ( + + ))} + + ) : null} + {outgoingTransfers?.length ? ( + + {showActType ? null : ( + + Sent + + )} + {outgoingTransfers.map((transfer, index) => ( + + ))} + + ) : null} + {outgoingTransfers?.length && incomingTransfers?.length ? ( + + ) : null} + {incomingTransfers?.length ? ( + + {showActType ? null : ( + + Received + + )} + {incomingTransfers.map((transfer, index) => ( + + ))} + + ) : null} +
+ ); +} + +export function ActionInfo() { + const navigate = useNavigate(); + const { act_index } = useParams(); + const { state } = useLocation(); + + invariant( + !act_index || isNumeric(act_index), + 'actIndex should be a number or be empty' + ); + const actIndex = act_index ? Number(act_index) : undefined; + const addressAction = state?.addressAction as AddressAction | undefined; + const targetObject = + actIndex != null ? addressAction?.acts?.at(actIndex) : addressAction; + + const actionDate = useMemo(() => { + return addressAction?.timestamp + ? dateFormatter.format(new Date(addressAction.timestamp)) + : null; + }, [addressAction?.timestamp]); + + if (!addressAction || !targetObject) { + return ( + + Sorry, action not found. Please, go back and select it from the history + again. + + ); + } + + const { type, status, label, transaction } = targetObject; + const isFailed = status === 'failed' || status === 'dropped'; + + return ( + + + {`${type.displayValue}${ + isFailed ? ` (${capitalize(status)})` : '' + }`} + + {actionDate} + + + } + documentTitle="Action info" + /> + + + {actIndex != null && addressAction?.acts?.at(actIndex) ? ( + + ) : addressAction.acts?.length === 1 ? ( + + ) : ( + addressAction?.acts?.map((act, index) => ( +
{ + if (isInteractiveElement(event.target)) { + return; + } + navigate(`/action/${addressAction.id}/${index}`, { + state: { addressAction }, + }); + }} + > + { + e.stopPropagation(); + navigate(`/action/${addressAction.id}/${index}`, { + state: { addressAction }, + }); + }} + /> + + +
+ )) + )} +
+ + {transaction ? : null} + {label ? : null} + {'rate' in targetObject && targetObject.rate ? ( + + ) : null} + {addressAction.fee ? : null} + +
+
+ ); +} diff --git a/src/ui/pages/History/ActionDetailedView/components/ExplorerInfo.tsx b/src/ui/pages/ActionInfo/ExplorerInfo.tsx similarity index 74% rename from src/ui/pages/History/ActionDetailedView/components/ExplorerInfo.tsx rename to src/ui/pages/ActionInfo/ExplorerInfo.tsx index ca01acf900..78dec43425 100644 --- a/src/ui/pages/History/ActionDetailedView/components/ExplorerInfo.tsx +++ b/src/ui/pages/ActionInfo/ExplorerInfo.tsx @@ -1,7 +1,5 @@ import React, { useCallback, useMemo } from 'react'; import { animated } from '@react-spring/web'; -import { createChain } from 'src/modules/networks/Chain'; -import type { Networks } from 'src/modules/networks/Networks'; import { NetworkIcon } from 'src/ui/components/NetworkIcon'; import { useTransformTrigger } from 'src/ui/components/useTransformTrigger'; import { prepareForHref } from 'src/ui/shared/prepareForHref'; @@ -14,7 +12,8 @@ import CopyIcon from 'jsx:src/ui/assets/copy.svg'; import SuccessIcon from 'jsx:src/ui/assets/checkmark-allowed.svg'; import { useCopyToClipboard } from 'src/ui/shared/useCopyToClipboard'; import { UnstyledButton } from 'src/ui/ui-kit/UnstyledButton'; -import type { AnyAddressAction } from 'src/modules/ethereum/transactions/addressAction'; +import type { ActionTransaction } from 'src/modules/zerion-api/requests/wallet-get-actions'; +import type { LocalActionTransaction } from 'src/modules/ethereum/transactions/addressAction'; const ICON_SIZE = 20; @@ -85,39 +84,28 @@ function HashButton({ hash }: { hash: string }) { } export function ExplorerInfo({ - action, - networks, + transaction, }: { - action: AnyAddressAction; - networks: Networks; + transaction: ActionTransaction | LocalActionTransaction; }) { - const chain = useMemo(() => createChain(action.transaction.chain), [action]); - const network = useMemo( - () => networks.getNetworkByName(chain), - [networks, chain] - ); - - const explorerUrl = networks.getExplorerTxUrlByName( - createChain(action.transaction.chain), - action.transaction.hash - ); - return ( - {network ? ( - - - {network.name} - + + + {transaction.chain.name} + + {transaction.explorerUrl ? ( + ) : null} - {explorerUrl ? : null} - + {transaction.hash ? : null} ); } diff --git a/src/ui/pages/ActionInfo/FeeLine.tsx b/src/ui/pages/ActionInfo/FeeLine.tsx new file mode 100644 index 0000000000..b2ff6fd7d2 --- /dev/null +++ b/src/ui/pages/ActionInfo/FeeLine.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import type { ActionFee } from 'src/modules/zerion-api/requests/wallet-get-actions'; +import { formatCurrencyValue } from 'src/shared/units/formatCurrencyValue'; +import { formatTokenValue } from 'src/shared/units/formatTokenValue'; +import { AssetLink } from 'src/ui/components/AssetLink'; +import { HStack } from 'src/ui/ui-kit/HStack'; +import { UIText } from 'src/ui/ui-kit/UIText'; + +export function FeeLine({ fee }: { fee: ActionFee }) { + return ( + + Network Fee + + {fee.free ? ( +
+ Free +
+ ) : ( + + {formatTokenValue(fee.amount.quantity, '')} + {fee.fungible ? : null} + {fee.amount.value != null ? ( + + ( + {formatCurrencyValue( + fee.amount.value, + 'en', + fee.amount.currency + )} + ) + + ) : null} + + )} +
+
+ ); +} diff --git a/src/ui/pages/History/ActionDetailedView/components/SenderReceiverLine.tsx b/src/ui/pages/ActionInfo/LabelLine.tsx similarity index 79% rename from src/ui/pages/History/ActionDetailedView/components/SenderReceiverLine.tsx rename to src/ui/pages/ActionInfo/LabelLine.tsx index f8aeab90a2..9c81c09d47 100644 --- a/src/ui/pages/History/ActionDetailedView/components/SenderReceiverLine.tsx +++ b/src/ui/pages/ActionInfo/LabelLine.tsx @@ -1,5 +1,4 @@ import React, { useCallback } from 'react'; -import type { AddressAction } from 'defi-sdk'; import { capitalize } from 'capitalize-ts'; import { useQuery } from '@tanstack/react-query'; import { animated } from '@react-spring/web'; @@ -7,7 +6,6 @@ import { HStack } from 'src/ui/ui-kit/HStack'; import { UIText } from 'src/ui/ui-kit/UIText'; import { WalletAvatar } from 'src/ui/components/WalletAvatar'; import { TokenIcon } from 'src/ui/ui-kit/TokenIcon'; -import { useProfileName } from 'src/ui/shared/useProfileName'; import { walletPort } from 'src/ui/shared/channels'; import CopyIcon from 'jsx:src/ui/assets/copy.svg'; import SuccessIcon from 'jsx:src/ui/assets/checkmark-allowed.svg'; @@ -15,17 +13,13 @@ import { useCopyToClipboard } from 'src/ui/shared/useCopyToClipboard'; import { UnstyledButton } from 'src/ui/ui-kit/UnstyledButton'; import * as helperStyles from 'src/ui/style/helpers.module.css'; import { useTransformTrigger } from 'src/ui/components/useTransformTrigger'; +import type { ActionLabel } from 'src/modules/zerion-api/requests/wallet-get-actions'; const ICON_SIZE = 20; -function SenderReceiver({ - label, -}: { - label: NonNullable; -}) { - const { icon_url, display_value, value } = label; - const address = - display_value.wallet_address || display_value.contract_address || ''; +function LabelInfo({ label }: { label: ActionLabel }) { + const { wallet, contract } = label; + const address = wallet?.address || contract?.address || ''; const { handleCopy, isSuccess } = useCopyToClipboard({ text: address }); const { style: iconStyle, trigger: hoverTrigger } = useTransformTrigger({ @@ -34,7 +28,7 @@ function SenderReceiver({ const { style: successIconStyle, trigger: successCopyTrigger } = useTransformTrigger({ x: 2 }); - const { data: wallet } = useQuery({ + const { data: localWallet } = useQuery({ queryKey: ['wallet/uiGetWalletByAddress', address], queryFn: () => walletPort.request('uiGetWalletByAddress', { address, groupId: null }), @@ -42,9 +36,9 @@ function SenderReceiver({ suspense: false, }); - const { value: walletName } = useProfileName( - wallet || { address, name: null } - ); + const walletName = localWallet?.name || wallet?.name || null; + const contractName = contract?.dapp.name || null; + const contractIconUrl = contract?.dapp.iconUrl || null; const handleClick = useCallback(() => { handleCopy(); @@ -64,12 +58,12 @@ function SenderReceiver({ alignItems="center" style={{ gridTemplateColumns: 'auto 1fr' }} > - {icon_url ? ( + {contractIconUrl ? ( ) : address ? ( @@ -83,7 +77,7 @@ function SenderReceiver({ kind="small/accent" style={{ overflow: 'hidden', textOverflow: 'ellipsis' }} > - {label.display_value.text || walletName} + {contractName || walletName} {address ? ( isSuccess ? ( @@ -114,13 +108,7 @@ function SenderReceiver({ ); } -export function SenderReceiverLine({ action }: { action: AddressAction }) { - const { label } = action; - - if (!label) { - return null; - } - +export function LabelLine({ label }: { label: ActionLabel }) { return ( - {capitalize(label.type)} - + {label.displayTitle ? ( + {capitalize(label.displayTitle)} + ) : null} + ); } diff --git a/src/ui/pages/ActionInfo/RateLine.tsx b/src/ui/pages/ActionInfo/RateLine.tsx new file mode 100644 index 0000000000..5c65728e08 --- /dev/null +++ b/src/ui/pages/ActionInfo/RateLine.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { HStack } from 'src/ui/ui-kit/HStack'; +import { UIText } from 'src/ui/ui-kit/UIText'; +import { formatTokenValue } from 'src/shared/units/formatTokenValue'; +import type { ActionRate } from 'src/modules/zerion-api/requests/wallet-get-actions'; + +export function RateLine({ rate }: { rate: ActionRate }) { + return ( + + Rate + + {`${formatTokenValue( + rate[0].value, + rate[0].symbol + )} = ${formatTokenValue(rate[1].value, rate[1].symbol)}`} + + + ); +} diff --git a/src/ui/pages/ActionInfo/index.ts b/src/ui/pages/ActionInfo/index.ts new file mode 100644 index 0000000000..e90e1ceba3 --- /dev/null +++ b/src/ui/pages/ActionInfo/index.ts @@ -0,0 +1 @@ +export { ActionInfo } from './ActionInfo'; diff --git a/src/ui/pages/ActionInfo/styles.module.css b/src/ui/pages/ActionInfo/styles.module.css new file mode 100644 index 0000000000..0cb328c82a --- /dev/null +++ b/src/ui/pages/ActionInfo/styles.module.css @@ -0,0 +1,21 @@ +.act { + position: relative; + cursor: pointer; +} + +.actBackdrop { + position: absolute; + inset: 0 0 0 0; + border-radius: 12px; +} + +.actArrow { + position: absolute; + right: 12px; + top: 8px; + color: var(--neutral-400); +} + +.act:hover .actArrow { + color: var(--neutral-600); +} diff --git a/src/ui/pages/AssetInfo/AssetHistory.tsx b/src/ui/pages/AssetInfo/AssetHistory.tsx index 201339782d..1922123438 100644 --- a/src/ui/pages/AssetInfo/AssetHistory.tsx +++ b/src/ui/pages/AssetInfo/AssetHistory.tsx @@ -1,33 +1,20 @@ -import type { ActionTransfer, AddressAction } from 'defi-sdk'; -import { useAddressActions } from 'defi-sdk'; -import React, { useCallback, useMemo, useRef } from 'react'; -import ArrowLeftIcon from 'jsx:src/ui/assets/arrow-left.svg'; +import React, { useMemo } from 'react'; import { useCurrency } from 'src/modules/currency/useCurrency'; -import type { - Asset, - AssetFullInfo, -} from 'src/modules/zerion-api/requests/asset-get-fungible-full-info'; import { Button } from 'src/ui/ui-kit/Button'; -import { CenteredDialog } from 'src/ui/ui-kit/ModalDialogs/CenteredDialog'; import { UIText } from 'src/ui/ui-kit/UIText'; import { VStack } from 'src/ui/ui-kit/VStack'; -import type { Networks } from 'src/modules/networks/Networks'; -import type { HTMLDialogElementInterface } from 'src/ui/ui-kit/ModalDialogs/HTMLDialogElementInterface'; -import { UnstyledButton } from 'src/ui/ui-kit/UnstyledButton'; import { HStack } from 'src/ui/ui-kit/HStack'; -import { useNetworks } from 'src/modules/networks/useNetworks'; -import { getFungibleAsset } from 'src/modules/ethereum/transactions/actionAsset'; import BigNumber from 'bignumber.js'; import { formatCurrencyValue } from 'src/shared/units/formatCurrencyValue'; import { minus, noValueDash } from 'src/ui/shared/typography'; import { formatTokenValue } from 'src/shared/units/formatTokenValue'; -import { getDecimals } from 'src/modules/networks/asset'; -import { baseToCommon } from 'src/shared/units/convert'; -import { createChain } from 'src/modules/networks/Chain'; import { CircleSpinner } from 'src/ui/ui-kit/CircleSpinner'; import { formatPriceValue } from 'src/shared/units/formatPriceValue'; import { PageFullBleedColumn } from 'src/ui/components/PageFullBleedColumn'; -import { ActionDetailedView } from '../History/ActionDetailedView'; +import { useWalletActions } from 'src/modules/zerion-api/hooks/useWalletActions'; +import { useHttpClientSource } from 'src/modules/zerion-api/hooks/useHttpClientSource'; +import type { AddressAction } from 'src/modules/zerion-api/requests/wallet-get-actions'; +import { UnstyledLink } from 'src/ui/ui-kit/UnstyledLink'; import * as styles from './styles.module.css'; const dateFormatter = new Intl.DateTimeFormat('en', { @@ -38,216 +25,141 @@ const dateFormatter = new Intl.DateTimeFormat('en', { minute: '2-digit', }); -function AssetHistoryItem({ - action, - asset, - address, - networks, -}: { - action: AddressAction; - asset: Asset; - address: string; - networks: Networks; -}) { +function AssetHistoryItem({ addressAction }: { addressAction: AddressAction }) { const { currency } = useCurrency(); - const dialogRef = useRef(null); - - const handleDialogOpen = useCallback(() => { - dialogRef.current?.showModal(); - }, []); - const { transfer, isIncoming } = useMemo(() => { - const incomingTransfers = action.content?.transfers?.incoming?.filter( - (item) => getFungibleAsset(item.asset)?.id === asset.id - ); - const aggregatedIncomingTransfer: ActionTransfer | undefined = - incomingTransfers?.length - ? { - ...incomingTransfers[0], - quantity: incomingTransfers - .reduce((acc, item) => acc.plus(item.quantity), new BigNumber(0)) - .toFixed(), - } - : undefined; - const outgoingTransfers = action.content?.transfers?.outgoing?.filter( - (item) => getFungibleAsset(item.asset)?.id === asset.id - ); - const aggregatedOutgoingTransfer: ActionTransfer | undefined = - outgoingTransfers?.length - ? { - ...outgoingTransfers[0], - quantity: outgoingTransfers - .reduce((acc, item) => acc.plus(item.quantity), new BigNumber(0)) - .toFixed(), - } - : undefined; - return { - transfer: aggregatedIncomingTransfer || aggregatedOutgoingTransfer, - isIncoming: Boolean(aggregatedIncomingTransfer), - }; - }, [action, asset.id]); - - const fungible = getFungibleAsset(transfer?.asset); - - if (!fungible || !transfer) { + const transfer = useMemo(() => { + const incomingTransfer = addressAction.content?.transfers + ?.filter(({ direction }) => direction === 'in') + .at(0); + const outgoingTransfer = addressAction.content?.transfers + ?.filter(({ direction }) => direction === 'out') + .at(0); + return incomingTransfer || outgoingTransfer; + }, [addressAction]); + + if (!transfer) { return null; } const actionType = - action.type.value === 'trade' - ? isIncoming + addressAction.type.value === 'trade' + ? transfer.direction === 'in' ? 'Buy' : 'Sell' - : action.type.display_value; + : addressAction.type.displayValue; - const formattedPrice = transfer.price - ? formatPriceValue(transfer.price, 'en', currency) - : noValueDash; - - const normalizedQuantity = baseToCommon( - transfer.quantity, - getDecimals({ - asset: fungible, - chain: createChain(action.transaction.chain), - }) - ); + const price = transfer?.amount?.value + ? new BigNumber(transfer.amount.value || 0).dividedBy( + transfer.amount.quantity + ) + : null; - const normalizedAmountAction = - transfer.price === null || transfer.price === undefined - ? null - : normalizedQuantity.multipliedBy(transfer.price); + const formattedPrice = price + ? formatPriceValue(price, 'en', currency) + : noValueDash; const actionTitle = `${actionType} at ${formattedPrice}`; - const actionDatetime = dateFormatter.format(new Date(action.datetime)); - const actionBalance = `${isIncoming ? '+' : minus}${formatTokenValue( - normalizedQuantity, - asset.symbol, - { notation: normalizedQuantity.gte(100000) ? 'compact' : undefined } - )}`; - const actionValue = normalizedAmountAction - ? formatCurrencyValue(normalizedAmountAction, 'en', currency) + const actionDatetime = dateFormatter.format( + new Date(addressAction.timestamp) + ); + const actionBalance = transfer.amount + ? `${transfer.direction === 'in' ? '+' : minus}${formatTokenValue( + transfer.amount?.quantity, + transfer.fungible?.symbol, + { + notation: new BigNumber(transfer.amount.quantity).gte(100000) + ? 'compact' + : undefined, + } + )}` + : null; + const actionValue = transfer.amount?.value + ? formatCurrencyValue(transfer.amount.value, 'en', currency) : null; return ( - <> - -
- - - {actionTitle} - - {actionDatetime} - - - - - {actionBalance} - - - {actionValue} - - - - - ( - <> -
event.stopPropagation()}> - -
- - - )} - /> - + +
+ + + {actionTitle} + + {actionDatetime} + + + + + {actionBalance} + + + {actionValue} + + + + ); } export function AssetHistory({ - assetId, + fungibleId, address, - assetFullInfo, }: { - assetId: string; + fungibleId: string; address: string; - assetFullInfo?: AssetFullInfo; }) { - const { networks } = useNetworks(); const { currency } = useCurrency(); - const { - value, - // TODO: this flag doesn't work, needs to be fixed - isFetching: actionsAreLoading, - hasNext, - fetchMore, - } = useAddressActions( + const { actions, queryData } = useWalletActions( { - address, + addresses: [address], currency, - actions_fungible_ids: [assetId], - }, - { + fungibleId, limit: 10, - listenForUpdates: true, - paginatedCacheMode: 'first-page', - } + }, + { source: useHttpClientSource() } ); - const asset = assetFullInfo?.fungible; + const isLoading = queryData.isLoading || queryData.isFetching; - if (!asset || !networks || !value?.length) { + if (!actions?.length && !isLoading) { return null; } return ( - + History - {value.map((action) => ( + {actions?.map((addressAction) => ( ))} - {hasNext ? ( + {queryData.hasNextPage ? ( ) : null} diff --git a/src/ui/pages/AssetInfo/AssetInfo.tsx b/src/ui/pages/AssetInfo/AssetInfo.tsx index 7e8a06a0ab..af6c3e56ce 100644 --- a/src/ui/pages/AssetInfo/AssetInfo.tsx +++ b/src/ui/pages/AssetInfo/AssetInfo.tsx @@ -234,11 +234,7 @@ export function AssetInfo() { /> - + {isWatchedAddress || !portfolioData ? null : ( diff --git a/src/ui/pages/BridgeForm/BridgeForm.tsx b/src/ui/pages/BridgeForm/BridgeForm.tsx index 6e0b75db9d..1af11cd14b 100644 --- a/src/ui/pages/BridgeForm/BridgeForm.tsx +++ b/src/ui/pages/BridgeForm/BridgeForm.tsx @@ -38,7 +38,7 @@ import { WalletAvatar } from 'src/ui/components/WalletAvatar'; import { getDecimals } from 'src/modules/networks/asset'; import { VStack } from 'src/ui/ui-kit/VStack'; import type { NetworkConfig } from 'src/modules/networks/NetworkConfig'; -import type { AddressAction, AddressPosition } from 'defi-sdk'; +import type { AddressPosition } from 'defi-sdk'; import { SignTransactionButton, type SendTxBtnHandle, @@ -98,6 +98,8 @@ import { UKDisclaimer } from 'src/ui/components/UKDisclaimer/UKDisclaimer'; import { ErrorMessage } from 'src/ui/shared/error-display/ErrorMessage'; import { getError } from 'get-error'; import { TextAnchor } from 'src/ui/ui-kit/TextAnchor'; +import type { AddressAction } from 'src/modules/zerion-api/requests/wallet-get-actions'; +import { useAssetFullInfo } from 'src/modules/zerion-api/hooks/useAssetFullInfo'; import { TransactionConfiguration } from '../SendTransaction/TransactionConfiguration'; import { ApproveHintLine } from '../SwapForm/ApproveHintLine'; import { getQuotesErrorMessage } from '../SwapForm/Quotes/getQuotesErrorMessage'; @@ -714,6 +716,12 @@ function BridgeFormComponent() { [inputChain, inputAmount, inputFungibleId] ); + const { data: inputFungibleUsdInfoForAnalytics } = useAssetFullInfo( + { fungibleId: inputPosition?.asset.id || '', currency: 'usd' }, + { source: useHttpClientSource() }, + { enabled: Boolean(inputPosition?.asset.id) } + ); + const { mutate: sendApproveTransaction, data: approveHash = null, @@ -726,6 +734,7 @@ function BridgeFormComponent() { 'Approval transaction is not configured' ); + invariant(inputNetwork, 'Network must be defined to sign the tx'); invariant(spendChain, 'Chain must be defined to sign the tx'); invariant(approveTxBtnRef.current, 'SignTransactionButton not found'); invariant(inputPosition, 'Spend position must be defined'); @@ -738,19 +747,32 @@ function BridgeFormComponent() { ? await modifyApproveAmount(evmTx, allowanceBase) : evmTx; - const inputAmountBase = commonToBase( - formState.inputAmount, - getDecimals({ asset: inputPosition.asset, chain: spendChain }) - ).toFixed(); - const fallbackAddressAction = selectedQuote.transactionApprove.evm ? createApproveAddressAction({ transaction: toIncomingTransaction( selectedQuote.transactionApprove.evm ), + hash: null, + explorerUrl: null, + amount: { + currency, + quantity: formState.inputAmount, + value: inputPosition.asset.price?.value + ? new BigNumber(formState.inputAmount) + .multipliedBy(inputPosition.asset.price.value) + .toNumber() + : null, + usdValue: inputFungibleUsdInfoForAnalytics?.data?.fungible.meta + .price + ? new BigNumber(formState.inputAmount) + .multipliedBy( + inputFungibleUsdInfoForAnalytics.data.fungible.meta.price + ) + .toNumber() + : null, + }, asset: inputPosition.asset, - quantity: inputAmountBase, - chain: spendChain, + network: inputNetwork, }) : null; @@ -843,28 +865,42 @@ function BridgeFormComponent() { selectedQuote?.transactionSwap, 'Cannot submit transaction without a quote' ); - const { inputAmount } = formState; invariant(spendChain, 'Chain must be defined to sign the tx'); - invariant(inputAmount, 'inputAmount must be set'); + invariant(inputNetwork, 'Network must be defined to sign the tx'); + invariant(outputNetwork, 'Output network must be defined to sign the tx'); + invariant(formState.inputAmount, 'inputAmount must be set'); invariant( inputPosition && outputPosition, 'Trade positions must be defined' ); invariant(sendTxBtnRef.current, 'SignTransactionButton not found'); - const inputAmountBase = commonToBase( - inputAmount, - getDecimals({ asset: inputPosition.asset, chain: spendChain }) - ).toFixed(); - const outputAmountBase = commonToBase( - selectedQuote.outputAmount.quantity, - getDecimals({ asset: outputPosition.asset, chain: spendChain }) - ).toFixed(); const fallbackAddressAction = createBridgeAddressAction({ + hash: null, address, + explorerUrl: null, + inputNetwork, + outputNetwork, + receiverAddress: to || null, + spendAsset: inputPosition.asset, + receiveAsset: outputPosition.asset, + spendAmount: { + currency, + quantity: formState.inputAmount, + value: inputPosition.asset.price?.value + ? new BigNumber(formState.inputAmount) + .multipliedBy(inputPosition.asset.price.value) + .toNumber() + : null, + usdValue: inputFungibleUsdInfoForAnalytics?.data?.fungible.meta.price + ? new BigNumber(formState.inputAmount) + .multipliedBy( + inputFungibleUsdInfoForAnalytics.data.fungible.meta.price + ) + .toNumber() + : null, + }, + receiveAmount: selectedQuote.outputAmount, transaction: toMultichainTransaction(selectedQuote.transactionSwap), - outgoing: [{ asset: inputPosition.asset, quantity: inputAmountBase }], - incoming: [{ asset: outputPosition.asset, quantity: outputAmountBase }], - chain: spendChain, }); const txResponse = await sendTxBtnRef.current.sendTransaction({ transaction: toMultichainTransaction(selectedQuote.transactionSwap), @@ -1306,7 +1342,7 @@ function BridgeFormComponent() { - + void; }) { const [view, setView] = useState<'speedup' | 'cancel' | 'default'>('default'); - const { networks } = useNetworks(); const { data: wallet, isLoading } = useQuery({ queryKey: ['wallet/uiGetCurrentWallet'], queryFn: () => walletPort.request('uiGetCurrentWallet'), @@ -47,18 +43,23 @@ function AccelerateTransactionContent({ return null; } const isAccelerated = - isLocalAddressAction(action) && action.relatedTransaction; - const isCancel = isCancelTx(action); + isLocalAddressAction(addressAction) && addressAction.relatedTransaction; + const isCancel = isCancelTx(addressAction); + const disabled = + !addressAction.transaction || + addressAction.transaction.chain.id === 'solana'; return view === 'default' ? ( <> {action.type.display_value}} + title={ + {addressAction.type.displayValue} + } closeKind="icon" /> - {action.transaction.chain === 'solana' ? null : ( + {disabled ? null : (
)} - {networks ? ( -
- + {addressAction.transaction?.hash ? ( +
+
) : null}
setView('default')} onSuccess={onDismiss} /> @@ -155,7 +152,7 @@ function AccelerateTransactionContent({ setView('default')} onSuccess={onDismiss} /> @@ -165,15 +162,18 @@ function AccelerateTransactionContent({ export const AccelerateTransactionDialog = React.forwardRef< HTMLDialogElementInterface, - { action: AnyAddressAction; onDismiss: () => void } ->(({ action, onDismiss }, ref) => { + { addressAction: LocalAddressAction; onDismiss: () => void } +>(({ addressAction, onDismiss }, ref) => { return ( ( - + )} > ); diff --git a/src/ui/pages/History/AccelerateTransactionDialog/CancelTx/CancelTx.tsx b/src/ui/pages/History/AccelerateTransactionDialog/CancelTx/CancelTx.tsx index a4a9af7931..6784d30e99 100644 --- a/src/ui/pages/History/AccelerateTransactionDialog/CancelTx/CancelTx.tsx +++ b/src/ui/pages/History/AccelerateTransactionDialog/CancelTx/CancelTx.tsx @@ -10,7 +10,7 @@ import { UIText } from 'src/ui/ui-kit/UIText'; import { VStack } from 'src/ui/ui-kit/VStack'; import { createCancelAddressAction, - type AnyAddressAction, + type LocalAddressAction, } from 'src/modules/ethereum/transactions/addressAction'; import { useGasPrices } from 'src/ui/shared/requests/useGasPrices'; import { createChain } from 'src/modules/networks/Chain'; @@ -53,15 +53,16 @@ function CancelTxContent({ onSuccess, }: { wallet: ExternallyOwnedAccount; - addressAction: AnyAddressAction; + addressAction: LocalAddressAction; transaction: IncomingTransactionWithChainId & IncomingTransactionWithFrom; onDismiss: () => void; onSuccess: () => void; }) { const { address } = wallet; const { preferences } = usePreferences(); - const { transaction: originalTransaction } = addressAction; + const { rawTransaction: originalTransaction } = addressAction; const [configuration, setConfiguration] = useState(DEFAULT_CONFIGURATION); + invariant(originalTransaction, 'Original transaction must be defined'); const chain = createChain(originalTransaction.chain); const { data: chainGasPrices = null } = useGasPrices(chain); const acceleratedGasPrices = useMemo( @@ -234,13 +235,14 @@ export function CancelTx({ onSuccess, }: { wallet: ExternallyOwnedAccount; - addressAction: AnyAddressAction; + addressAction: LocalAddressAction; onDismiss: () => void; onSuccess: () => void; }) { const { address } = wallet; - const { transaction: originalTransaction } = addressAction; + const { rawTransaction: originalTransaction } = addressAction; const { networks } = useNetworks(); + invariant(originalTransaction, 'Original transaction must be defined'); const chain = createChain(originalTransaction.chain); const chainId = networks?.getChainId(chain); const transaction = useMemo(() => { diff --git a/src/ui/pages/History/AccelerateTransactionDialog/SpeedUp/SpeedUp.tsx b/src/ui/pages/History/AccelerateTransactionDialog/SpeedUp/SpeedUp.tsx index fdaadf4623..18cadddaea 100644 --- a/src/ui/pages/History/AccelerateTransactionDialog/SpeedUp/SpeedUp.tsx +++ b/src/ui/pages/History/AccelerateTransactionDialog/SpeedUp/SpeedUp.tsx @@ -8,7 +8,7 @@ import { Button } from 'src/ui/ui-kit/Button'; import { HStack } from 'src/ui/ui-kit/HStack'; import { UIText } from 'src/ui/ui-kit/UIText'; import { VStack } from 'src/ui/ui-kit/VStack'; -import { type AnyAddressAction } from 'src/modules/ethereum/transactions/addressAction'; +import type { LocalAddressAction } from 'src/modules/ethereum/transactions/addressAction'; import { createAcceleratedAddressAction } from 'src/modules/ethereum/transactions/addressAction'; import { useGasPrices } from 'src/ui/shared/requests/useGasPrices'; import { createChain } from 'src/modules/networks/Chain'; @@ -44,14 +44,15 @@ export function SpeedUp({ onSuccess, }: { wallet: ExternallyOwnedAccount; - addressAction: AnyAddressAction; + addressAction: LocalAddressAction; onDismiss: () => void; onSuccess: () => void; }) { const { address } = wallet; const { preferences } = usePreferences(); - const { transaction: originalTransaction } = addressAction; + const { rawTransaction: originalTransaction } = addressAction; const [configuration, setConfiguration] = useState(DEFAULT_CONFIGURATION); + invariant(originalTransaction, 'Original transaction must be defined'); const transaction = useMemo(() => { const tx = removeGasPrice( fromAddressActionTransaction(originalTransaction) diff --git a/src/ui/pages/History/AccelerateTransactionDialog/shared/accelerate-helpers.test.ts b/src/ui/pages/History/AccelerateTransactionDialog/shared/accelerate-helpers.test.ts deleted file mode 100644 index a4e4e79da5..0000000000 --- a/src/ui/pages/History/AccelerateTransactionDialog/shared/accelerate-helpers.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import dotenv from 'dotenv'; -dotenv.config(); -import type { AnyAddressAction } from 'src/modules/ethereum/transactions/addressAction'; -import { invariant } from 'src/shared/invariant'; -import { isCancelTx } from './accelerate-helpers'; - -const testAddress = process.env.TEST_WALLET_ADDRESS; - -invariant(testAddress, 'TEST_WALLET_ADDRESS Env var not found'); - -const cancelTxSample = { - id: '0xd188a791d2538e6a4dca6377300362fe1e196b00161321ecf5f345e2196739a1', - address: testAddress, - transaction: { - accessList: [], - chainId: 137, - confirmations: 0, - data: '0x', - from: testAddress, - gasLimit: { - _hex: '0x5a3c', - _isBigNumber: true, - }, - gasPrice: null, - hash: '0xd188a791d2538e6a4dca6377300362fe1e196b00161321ecf5f345e2196739a1', - maxFeePerGas: { - _hex: '0x24501f821d', - _isBigNumber: true, - }, - maxPriorityFeePerGas: { - _hex: '0x09634297af', - _isBigNumber: true, - }, - nonce: 946, - to: testAddress, - type: 2, - value: { - _hex: '0x00', - _isBigNumber: true, - }, - chain: 'polygon', - status: 'pending', - fee: null, - sponsored: false, - }, - datetime: '2023-12-17T17:24:16.396Z', - label: { - type: 'to', - value: testAddress, - display_value: { - text: '', - wallet_address: testAddress, - }, - }, - type: { - display_value: 'Send', - value: 'send', - }, - content: null, - local: true, - relatedTransaction: - '0x7852c04e7a7e00b09e5900146d0ac4dc2939da89f556a530e332278a4da21f68', -}; - -const otherTxSample = { - ...cancelTxSample, - address: '0x064bd35c9064fc3e628a3be3310a1cf65488103d', -}; - -test('isCancelTx', () => { - expect(isCancelTx(cancelTxSample as AnyAddressAction)).toBe(true); - expect(isCancelTx(otherTxSample as AnyAddressAction)).toBe(false); -}); diff --git a/src/ui/pages/History/AccelerateTransactionDialog/shared/accelerate-helpers.ts b/src/ui/pages/History/AccelerateTransactionDialog/shared/accelerate-helpers.ts index 81818c507a..b8c54e29c7 100644 --- a/src/ui/pages/History/AccelerateTransactionDialog/shared/accelerate-helpers.ts +++ b/src/ui/pages/History/AccelerateTransactionDialog/shared/accelerate-helpers.ts @@ -2,10 +2,11 @@ import { produce } from 'immer'; import type { BigNumberish } from 'ethers'; import { BigNumber } from '@ethersproject/bignumber'; import omit from 'lodash/omit'; -import { - isLocalAddressAction, - type AnyAddressAction, +import type { + AnyAddressAction, + LocalAddressAction, } from 'src/modules/ethereum/transactions/addressAction'; +import { isLocalAddressAction } from 'src/modules/ethereum/transactions/addressAction'; import type { ChainGasPrice } from 'src/modules/ethereum/transactions/gasPrices/types'; import type { TransactionObject } from 'src/modules/ethereum/transactions/types'; import type { IncomingTransaction } from 'src/modules/ethereum/types/IncomingTransaction'; @@ -15,7 +16,7 @@ import { normalizeAddress } from 'src/shared/normalizeAddress'; export function fromAddressActionTransaction( transaction: ( | TransactionObject['transaction'] - | AnyAddressAction['transaction'] + | LocalAddressAction['rawTransaction'] ) & { gasLimit?: BigNumberish; gasPrice?: BigNumberish; @@ -100,7 +101,7 @@ function restoreValue(value: BigNumberish) { export function isCancelTx(addressAction: AnyAddressAction) { if (isLocalAddressAction(addressAction)) { const { address } = addressAction; - const { value, data, from } = addressAction.transaction; + const { value, data, from } = addressAction.rawTransaction || {}; if (!from || normalizeAddress(from) !== normalizeAddress(address)) { return false; } diff --git a/src/ui/pages/History/ActionDetailedView/ActionDetailedView.tsx b/src/ui/pages/History/ActionDetailedView/ActionDetailedView.tsx deleted file mode 100644 index 3ef87a1147..0000000000 --- a/src/ui/pages/History/ActionDetailedView/ActionDetailedView.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import React, { useMemo } from 'react'; -import type { AddressAction } from 'defi-sdk'; -import { capitalize } from 'capitalize-ts'; -import type { Networks } from 'src/modules/networks/Networks'; -import { VStack } from 'src/ui/ui-kit/VStack'; -import { UIText } from 'src/ui/ui-kit/UIText'; -import { Surface } from 'src/ui/ui-kit/Surface'; -import { createChain } from 'src/modules/networks/Chain'; -import type { ClientTransactionStatus } from 'src/modules/ethereum/transactions/addressAction'; -import { ApprovalInfo, TransferInfo } from './components/TransferInfo'; -import { CollectionLine } from './components/CollectionLine'; -import { RateLine } from './components/RateLine'; -import { SenderReceiverLine } from './components/SenderReceiverLine'; -import { FeeLine } from './components/FeeLine'; -import { ExplorerInfo } from './components/ExplorerInfo'; - -const dateFormatter = new Intl.DateTimeFormat('en', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', -}); - -export function ActionDetailedView({ - action, - address, - networks, -}: { - action: AddressAction; - address?: string; - networks: Networks; -}) { - const chain = useMemo(() => createChain(action.transaction.chain), [action]); - - const actionDate = useMemo(() => { - return dateFormatter.format(new Date(action.datetime)); - }, [action.datetime]); - - const outgoingTransfers = action.content?.transfers?.outgoing; - const incomingTransfers = action.content?.transfers?.incoming; - - const isFailed = - action.transaction.status === 'failed' || - (action.transaction.status as ClientTransactionStatus) === 'dropped'; - - const hasTransferInfo = - outgoingTransfers?.length || - incomingTransfers?.length || - action.content?.single_asset; - - return ( - - - - {`${action.type.display_value}${ - isFailed ? ` (${capitalize(action.transaction.status)})` : '' - }`} - - - {actionDate} - - - {hasTransferInfo ? ( - - {outgoingTransfers?.length ? ( - - ) : null} - {incomingTransfers?.length ? ( - - ) : null} - {action.content?.single_asset ? ( - - ) : null} - - ) : null} - - - - - - - - - - - - - ); -} diff --git a/src/ui/pages/History/ActionDetailedView/components/CollectionLine.tsx b/src/ui/pages/History/ActionDetailedView/components/CollectionLine.tsx deleted file mode 100644 index c98e582b2a..0000000000 --- a/src/ui/pages/History/ActionDetailedView/components/CollectionLine.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import type { AddressAction } from 'defi-sdk'; -import React, { useMemo } from 'react'; -import { getNftAsset } from 'src/modules/ethereum/transactions/actionAsset'; -import { HStack } from 'src/ui/ui-kit/HStack'; -import { TokenIcon } from 'src/ui/ui-kit/TokenIcon'; -import { UIText } from 'src/ui/ui-kit/UIText'; - -export function CollectionLine({ action }: { action: AddressAction }) { - const { content } = action; - - const nftCollection = useMemo(() => { - const incomingNftCollection = getNftAsset( - content?.transfers?.incoming?.find(({ asset }) => 'nft' in asset)?.asset - )?.collection; - const outgoingNftCollection = getNftAsset( - content?.transfers?.outgoing?.find(({ asset }) => 'nft' in asset)?.asset - )?.collection; - return incomingNftCollection || outgoingNftCollection; - }, [content]); - - if (!nftCollection) { - return null; - } - - return ( - - Collection - - - - {nftCollection.name} - - - - ); -} diff --git a/src/ui/pages/History/ActionDetailedView/components/FeeLine.tsx b/src/ui/pages/History/ActionDetailedView/components/FeeLine.tsx deleted file mode 100644 index c5891020d5..0000000000 --- a/src/ui/pages/History/ActionDetailedView/components/FeeLine.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import type { AddressAction } from 'defi-sdk'; -import React from 'react'; -import { useCurrency } from 'src/modules/currency/useCurrency'; -import { createChain } from 'src/modules/networks/Chain'; -import { NetworkId } from 'src/modules/networks/NetworkId'; -import type { Networks } from 'src/modules/networks/Networks'; -import { baseToCommon } from 'src/shared/units/convert'; -import { formatCurrencyValue } from 'src/shared/units/formatCurrencyValue'; -import { formatTokenValue } from 'src/shared/units/formatTokenValue'; -import { AssetLink } from 'src/ui/components/AssetLink'; -import { HStack } from 'src/ui/ui-kit/HStack'; -import { UIText } from 'src/ui/ui-kit/UIText'; - -export function FeeLine({ - action, - address, - networks, -}: { - action: AddressAction; - address?: string; - networks: Networks; -}) { - const { fee, chain, sponsored } = action.transaction; - const { currency } = useCurrency(); - - const feeEth = baseToCommon( - fee?.quantity || 0, - networks.getNetworkByName(createChain(chain))?.native_asset?.decimals || 18 - ); - const feeCurrency = feeEth.times(Number(fee?.price)); - const nativeAsset = networks.getNetworkByName( - createChain(chain || NetworkId.Ethereum) - )?.native_asset; - - const noFeeData = !sponsored && !fee; - if (noFeeData || !nativeAsset) { - return null; - } - - return ( - - Network Fee - - {sponsored ? ( -
- Free -
- ) : ( - - {formatTokenValue(feeEth, '')} - {nativeAsset.id ? ( - - ) : ( - nativeAsset.symbol?.toUpperCase() - )} - ({formatCurrencyValue(feeCurrency, 'en', currency)}) - - )} -
-
- ); -} diff --git a/src/ui/pages/History/ActionDetailedView/components/RateLine.tsx b/src/ui/pages/History/ActionDetailedView/components/RateLine.tsx deleted file mode 100644 index 1464b40b95..0000000000 --- a/src/ui/pages/History/ActionDetailedView/components/RateLine.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React, { useMemo } from 'react'; -import type { AddressAction } from 'defi-sdk'; -import BigNumber from 'bignumber.js'; -import { HStack } from 'src/ui/ui-kit/HStack'; -import { UIText } from 'src/ui/ui-kit/UIText'; -import { getFungibleAsset } from 'src/modules/ethereum/transactions/actionAsset'; -import { formatTokenValue } from 'src/shared/units/formatTokenValue'; -import { AssetLink } from 'src/ui/components/AssetLink'; - -export function RateLine({ - action, - address, -}: { - action: AddressAction; - address?: string; -}) { - const rate = useMemo(() => { - const { content } = action; - const type = action.type.value; - const incomingFungible = getFungibleAsset( - content?.transfers?.incoming?.[0]?.asset - ); - const outgoingFungible = getFungibleAsset( - content?.transfers?.outgoing?.[0]?.asset - ); - - const incomingPrice = content?.transfers?.incoming?.[0]?.price || 0; - const outgoingPrice = content?.transfers?.outgoing?.[0]?.price || 0; - - return type === 'trade' && - content?.transfers?.incoming?.length === 1 && - incomingFungible && - content?.transfers?.outgoing?.length === 1 && - outgoingFungible - ? incomingFungible.type === 'stablecoin' || outgoingPrice > incomingPrice - ? { - asset1: outgoingFungible, - price1: outgoingPrice, - asset2: incomingFungible, - price2: incomingPrice, - } - : { - asset1: incomingFungible, - price1: incomingPrice, - asset2: outgoingFungible, - price2: outgoingPrice, - } - : null; - }, [action]); - - if (!rate?.price1 || !rate.price2) { - return null; - } - - const ratio = new BigNumber(rate.price1).div(rate.price2); - - return ( - - Rate - - - 1 - - = - {formatTokenValue(ratio, '')} - - - - - ); -} diff --git a/src/ui/pages/History/ActionDetailedView/components/TransferInfo.tsx b/src/ui/pages/History/ActionDetailedView/components/TransferInfo.tsx deleted file mode 100644 index 9d0d569290..0000000000 --- a/src/ui/pages/History/ActionDetailedView/components/TransferInfo.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import type { ActionAsset, ActionTransfer, ActionType } from 'defi-sdk'; -import React, { useMemo } from 'react'; -import { - getFungibleAsset, - getNftAsset, -} from 'src/modules/ethereum/transactions/actionAsset'; -import { invariant } from 'src/shared/invariant'; -import { HStack } from 'src/ui/ui-kit/HStack'; -import { Surface } from 'src/ui/ui-kit/Surface'; -import { TokenIcon } from 'src/ui/ui-kit/TokenIcon'; -import { UIText } from 'src/ui/ui-kit/UIText'; -import { VStack } from 'src/ui/ui-kit/VStack'; -import { Media } from 'src/ui/ui-kit/Media'; -import { getCommonQuantity } from 'src/modules/networks/asset'; -import type { Chain } from 'src/modules/networks/Chain'; -import { almostEqual, minus } from 'src/ui/shared/typography'; -import { formatTokenValue } from 'src/shared/units/formatTokenValue'; -import { formatCurrencyValue } from 'src/shared/units/formatCurrencyValue'; -import ChevronRightIcon from 'jsx:src/ui/assets/chevron-right.svg'; -import { UnstyledAnchor } from 'src/ui/ui-kit/UnstyledAnchor'; -import { NetworkId } from 'src/modules/networks/NetworkId'; -import * as helperStyles from 'src/ui/style/helpers.module.css'; -import { AssetLink } from 'src/ui/components/AssetLink'; -import { useCurrency } from 'src/modules/currency/useCurrency'; -import { isUnlimitedApproval } from '../../isUnlimitedApproval'; - -type Direction = 'incoming' | 'outgoing'; -const ICON_SIZE = 36; - -export function ApprovalInfo({ - singleTransfer, - actionType, - address, - chain, -}: { - singleTransfer: { asset: ActionAsset; quantity: string }; - actionType: ActionType; - address?: string; - chain: Chain; -}) { - const fungible = getFungibleAsset(singleTransfer.asset); - - const tokenQuantity = useMemo( - () => - fungible - ? getCommonQuantity({ - asset: fungible, - chain, - baseQuantity: singleTransfer.quantity, - }) - : null, - [singleTransfer, fungible, chain] - ); - - if (!fungible) { - return null; - } - - const isUnlimited = isUnlimitedApproval(singleTransfer.quantity); - - return ( - - - } - text={ - - - - } - detailText={ - actionType === 'revoke' && singleTransfer.quantity === '0' ? ( - - {fungible.symbol || null} - - ) : tokenQuantity ? ( - - {isUnlimited - ? 'Unlimited' - : formatTokenValue(tokenQuantity, fungible.symbol)} - - ) : null - } - /> - - ); -} - -function FungibleTransfer({ - transfer, - address, - direction, - chain, -}: { - transfer: ActionTransfer; - address?: string; - direction: Direction; - chain: Chain; -}) { - const { currency } = useCurrency(); - const fungible = getFungibleAsset(transfer.asset); - invariant(fungible, 'Transfer with fungible asset should contain one'); - const balance = useMemo( - () => - getCommonQuantity({ - asset: fungible, - chain, - baseQuantity: transfer.quantity, - }), - [transfer, fungible, chain] - ); - - return ( - - } - text={ - - - - {direction === 'incoming' ? '+' : minus} - {formatTokenValue(balance, '')} - - - - - } - detailText={ - - {almostEqual} - {formatCurrencyValue( - balance.times(transfer.price || 0), - 'en', - currency - )} - - } - /> - ); -} - -function NFTTransfer({ - transfer, - address, - direction, - chain, -}: { - transfer: ActionTransfer; - address?: string; - direction: Direction; - chain: Chain; -}) { - const nft = getNftAsset(transfer.asset); - invariant(nft, 'Transfer with non-fungible asset should contain one'); - const title = nft.name || nft.collection_info?.name; - const nftContent = ( - - - - {title} - - - ); - - return direction === 'incoming' ? ( - - - {nftContent} - - - - ) : ( - nftContent - ); -} - -export function TransferInfo({ - transfers, - title, - address, - direction, - chain, -}: { - transfers: ActionTransfer[]; - title?: 'Send' | 'Receive'; - address?: string; - direction: Direction; - chain: Chain; -}) { - return ( - - - {title ? ( - - {title} - - ) : null} - {transfers.map((transfer, index) => { - const fungible = getFungibleAsset(transfer.asset); - const nft = getNftAsset(transfer.asset); - if (nft) { - return ( - - ); - } - if (fungible) { - return ( - - ); - } - return null; - })} - - - ); -} diff --git a/src/ui/pages/History/ActionDetailedView/index.ts b/src/ui/pages/History/ActionDetailedView/index.ts deleted file mode 100644 index 82988f3f07..0000000000 --- a/src/ui/pages/History/ActionDetailedView/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ActionDetailedView } from './ActionDetailedView'; diff --git a/src/ui/pages/History/ActionItem/ActionItem.tsx b/src/ui/pages/History/ActionItem/ActionItem.tsx index c28c02819c..751fd31326 100644 --- a/src/ui/pages/History/ActionItem/ActionItem.tsx +++ b/src/ui/pages/History/ActionItem/ActionItem.tsx @@ -1,17 +1,12 @@ import React, { useCallback, useMemo, useRef } from 'react'; -import type { AddressAction } from 'defi-sdk'; import { useNetworks } from 'src/modules/networks/useNetworks'; import { CircleSpinner } from 'src/ui/ui-kit/CircleSpinner'; import { Media } from 'src/ui/ui-kit/Media'; import { UIText } from 'src/ui/ui-kit/UIText'; import FailedIcon from 'jsx:src/ui/assets/failed.svg'; -import ArrowLeftIcon from 'jsx:src/ui/assets/arrow-left.svg'; -import type { Networks } from 'src/modules/networks/Networks'; -import { createChain } from 'src/modules/networks/Chain'; import { HStack } from 'src/ui/ui-kit/HStack'; import { VStack } from 'src/ui/ui-kit/VStack'; import { TextAnchor } from 'src/ui/ui-kit/TextAnchor'; -import { useAddressParams } from 'src/ui/shared/user-address/useAddressParams'; import { NetworkIcon } from 'src/ui/components/NetworkIcon'; import ZerionIcon from 'jsx:src/ui/assets/zerion-squircle.svg'; import { normalizeAddress } from 'src/shared/normalizeAddress'; @@ -19,31 +14,25 @@ import type { AnyAddressAction, LocalAddressAction, } from 'src/modules/ethereum/transactions/addressAction'; -import { - getActionAddress, - getActionAsset, -} from 'src/modules/ethereum/transactions/addressAction'; -import { getFungibleAsset } from 'src/modules/ethereum/transactions/actionAsset'; +import { isLocalAddressAction } from 'src/modules/ethereum/transactions/addressAction'; import { truncateAddress } from 'src/ui/shared/truncateAddress'; import type { HTMLDialogElementInterface } from 'src/ui/ui-kit/ModalDialogs/HTMLDialogElementInterface'; -import { Button } from 'src/ui/ui-kit/Button'; import { UnstyledButton } from 'src/ui/ui-kit/UnstyledButton'; -import { KeyboardShortcut } from 'src/ui/components/KeyboardShortcut'; -import { CenteredDialog } from 'src/ui/ui-kit/ModalDialogs/CenteredDialog'; import { prepareForHref } from 'src/ui/shared/prepareForHref'; -import { AssetLink } from 'src/ui/components/AssetLink'; import { DNA_MINT_CONTRACT_ADDRESS } from 'src/ui/DNA/shared/constants'; import { isInteractiveElement } from 'src/ui/shared/isInteractiveElement'; import { useCurrency } from 'src/modules/currency/useCurrency'; -import { ActionDetailedView } from '../ActionDetailedView'; -import { isUnlimitedApproval } from '../isUnlimitedApproval'; +import type { AddressAction } from 'src/modules/zerion-api/requests/wallet-get-actions'; +import { useNavigate } from 'react-router-dom'; import { AccelerateTransactionDialog } from '../AccelerateTransactionDialog'; import { + HistoryApprovalValue, HistoryItemValue, + HistoryNFTValue, + HistoryTokenValue, TransactionCurrencyValue, } from './TransactionItemValue'; import { - HistoryAssetIcon, transactionIconStyle, TransactionItemIcon, TRANSACTION_ICON_SIZE, @@ -52,22 +41,23 @@ import * as styles from './styles.module.css'; function checkIsDnaMint(action: AnyAddressAction) { return ( - normalizeAddress(action.label?.value || '') === DNA_MINT_CONTRACT_ADDRESS + normalizeAddress(action.label?.contract?.address || '') === + DNA_MINT_CONTRACT_ADDRESS ); } function ActionTitle({ - action, + addressAction, explorerUrl, }: { - action: AnyAddressAction; + addressAction: AnyAddressAction; explorerUrl?: string | null; }) { - const isMintingDna = checkIsDnaMint(action); - const titlePrefix = action.transaction.status === 'failed' ? 'Failed ' : ''; + const isMintingDna = checkIsDnaMint(addressAction); + const titlePrefix = addressAction.status === 'failed' ? 'Failed ' : ''; const actionTitle = isMintingDna ? 'Mint DNA' - : `${titlePrefix}${action.type.display_value}`; + : `${titlePrefix}${addressAction.type.displayValue}`; const explorerUrlPrepared = useMemo( () => (explorerUrl ? prepareForHref(explorerUrl)?.toString() : undefined), @@ -105,52 +95,48 @@ function AddressTruncated({ value }: { value: string }) { ); } -function ActionLabel({ action }: { action: AnyAddressAction }) { - const address = getActionAddress(action); - const text = action.label?.display_value.text; - if (address) { - return ; - } else if (text) { +function ActionLabel({ addressAction }: { addressAction: AnyAddressAction }) { + const address = + addressAction.label?.wallet?.address || + addressAction.label?.contract?.address; + + const text = + addressAction.label?.wallet?.name || + addressAction.label?.contract?.dapp.name; + + if (text && text !== address) { return ( {text} ); - } else { - return ; + } else if (address) { + return ; + } else if (addressAction.transaction?.hash) { + return ; } + return null; } -function ActionDetail({ - action, - networks, -}: { - action: AnyAddressAction; - networks: Networks; -}) { - const { chain: chainStr } = action.transaction; - const chain = chainStr ? createChain(chainStr) : null; - const network = useMemo( - () => (chain ? networks.getNetworkByName(chain) : null), - [chain, networks] - ); +function ActionDetail({ addressAction }: { addressAction: AnyAddressAction }) { + const chainInfo = addressAction.transaction?.chain; return ( - {action.transaction.status === 'pending' ? ( + {addressAction.status === 'pending' ? ( Pending - ) : action.transaction.status === 'failed' ? ( + ) : addressAction.status === 'failed' ? ( Failed - ) : action.transaction.status === 'dropped' ? ( + ) : addressAction.status === 'dropped' ? ( Dropped ) : ( - + )} @@ -158,260 +144,208 @@ function ActionDetail({ } function ActionItemBackend({ - action, - networks, + addressAction, testnetMode, }: { - action: AddressAction; - networks: Networks; + addressAction: AddressAction; testnetMode: boolean; }) { const { currency } = useCurrency(); - const { params, ready } = useAddressParams(); - const dialogRef = useRef(null); + const navigate = useNavigate(); - const handleDialogOpen = useCallback(() => { - dialogRef.current?.showModal(); - }, []); - - const handleDialogDismiss = useCallback(() => { - dialogRef.current?.close(); - }, []); - - if (!ready) { - return null; - } - - const address = 'address' in params ? params.address : undefined; - const singleTransfer = action.content?.single_asset; - const incomingTransfers = action.content?.transfers?.incoming; - const outgoingTransfers = action.content?.transfers?.outgoing; + const incomingTransfers = useMemo( + () => + addressAction.content?.transfers?.filter( + (transfer) => transfer.direction === 'in' + ), + [addressAction.content?.transfers] + ); + const outgoingTransfers = useMemo( + () => + addressAction.content?.transfers?.filter( + (transfer) => transfer.direction === 'out' + ), + [addressAction.content?.transfers] + ); + const approvals = useMemo( + () => addressAction.content?.approvals || [], + [addressAction] + ); - const shouldUsePositiveColor = - incomingTransfers?.length === 1 && - Boolean(getFungibleAsset(incomingTransfers[0].asset)); - const maybeSingleAsset = getFungibleAsset(singleTransfer?.asset); - const chain = action.transaction.chain - ? createChain(action.transaction.chain) - : null; + const shouldUsePositiveColor = incomingTransfers?.length === 1; return ( - <> - { + if (isInteractiveElement(event.target)) { + return; + } + navigate(`/action/${addressAction.id}`, { state: { addressAction } }); + }} + > + { + e.stopPropagation(); + navigate(`/action/${addressAction.id}`, { state: { addressAction } }); + }} /> - + ) : addressAction.status === 'pending' ? ( + + ) : ( + + ) + } + text={} + detailText={} + /> + { - if (isInteractiveElement(event.target)) { - return; - } - handleDialogOpen(); + justifyItems: 'end', + overflow: 'hidden', + textAlign: 'left', }} > - { - e.stopPropagation(); - handleDialogOpen(); - }} - /> - - ) : action.transaction.status === 'pending' ? ( - - ) : ( - - ) + } - detailText={} - /> - - - {maybeSingleAsset ? ( - - ) : incomingTransfers?.length && chain ? ( - + ) : outgoingTransfers?.length ? ( + + ) : approvals.length ? ( + + ) : null} + + + {incomingTransfers?.length && !outgoingTransfers?.length ? ( + + ) : outgoingTransfers?.length && !incomingTransfers?.length ? ( + + ) : outgoingTransfers?.length ? ( + + ) : approvals.length === 1 && approvals[0].unlimited ? ( + 'Unlimited' + ) : approvals.length === 1 ? ( + approvals[0].nft ? ( + - ) : outgoingTransfers?.length && chain ? ( - - ) : null} - - {chain ? ( - - {incomingTransfers?.length && !outgoingTransfers?.length ? ( - - ) : outgoingTransfers?.length && !incomingTransfers?.length ? ( - - ) : outgoingTransfers?.length ? ( - - ) : isUnlimitedApproval( - action.content?.single_asset?.quantity - ) ? ( - 'Unlimited' - ) : action.content?.single_asset?.asset ? ( - - ) : null} - + ) : null ) : null} - - - ( - <> - - - - )} - > - + + + ); } function ActionItemLocal({ - action, - networks, + addressAction, }: { - action: LocalAddressAction; - networks: Networks; + addressAction: LocalAddressAction; }) { - const asset = getActionAsset(action); - - const { params, ready } = useAddressParams(); - const dialogRef = useRef(null); const handleDialogOpen = useCallback(() => { dialogRef.current?.showModal(); }, []); - if (!ready) { - return null; - } - - const address = 'address' in params ? params.address : undefined; - - const isMintingDna = checkIsDnaMint(action); - - const { chain: chainStr } = action.transaction; - const chain = chainStr ? createChain(chainStr) : null; + const isMintingDna = checkIsDnaMint(addressAction); + const isPending = addressAction.status === 'pending'; - const explorerUrl = chain - ? networks.getExplorerTxUrlByName(chain, action.transaction.hash) - : null; - - const isPending = action.transaction.status === 'pending'; + const incomingTransfers = useMemo( + () => + addressAction.content?.transfers?.filter( + (transfer) => transfer.direction === 'in' + ), + [addressAction.content?.transfers] + ); + const outgoingTransfers = useMemo( + () => + addressAction.content?.transfers?.filter( + (transfer) => transfer.direction === 'out' + ), + [addressAction.content?.transfers] + ); + const approvals = useMemo( + () => addressAction.content?.approvals || [], + [addressAction] + ); + const shouldUsePositiveColor = incomingTransfers?.length === 1; return ( <> {isPending ? ( dialogRef.current?.close()} /> ) : null} @@ -464,28 +398,43 @@ function ActionItemLocal({ height={TRANSACTION_ICON_SIZE} /> ) : ( - + )}
} - text={} - detailText={} + text={ + + } + detailText={} /> - - {asset ? ( - + {incomingTransfers?.length ? ( + + ) : outgoingTransfers?.length ? ( + + ) : approvals.length ? ( + ) : null}
@@ -505,12 +454,11 @@ export function ActionItem({ if (!networks || !addressAction) { return null; } - return 'local' in addressAction && addressAction.local ? ( - + return isLocalAddressAction(addressAction) ? ( + ) : ( ); diff --git a/src/ui/pages/History/ActionItem/TransactionItemValue.tsx b/src/ui/pages/History/ActionItem/TransactionItemValue.tsx index 041350ae0f..5ffc68303e 100644 --- a/src/ui/pages/History/ActionItem/TransactionItemValue.tsx +++ b/src/ui/pages/History/ActionItem/TransactionItemValue.tsx @@ -1,105 +1,80 @@ -import React, { useMemo } from 'react'; -import type { - NFTAsset, - Asset, - Direction, - ActionTransfer, - ActionType, -} from 'defi-sdk'; +import React from 'react'; import { minus } from 'src/ui/shared/typography'; import { HStack } from 'src/ui/ui-kit/HStack'; -import type { Chain } from 'src/modules/networks/Chain'; -import { getCommonQuantity } from 'src/modules/networks/asset'; -import { - getFungibleAsset, - getNftAsset, -} from 'src/modules/ethereum/transactions/actionAsset'; import type BigNumber from 'bignumber.js'; import { formatCurrencyValue } from 'src/shared/units/formatCurrencyValue'; import { AssetQuantity } from 'src/ui/components/AssetQuantity'; import { AssetLink } from 'src/ui/components/AssetLink'; import { NFTLink } from 'src/ui/components/NFTLink'; +import type { + ActionDirection, + ActionType, + Amount, + Approval, + NFTPreview, + Transfer, +} from 'src/modules/zerion-api/requests/wallet-get-actions'; +import type { Fungible } from 'src/modules/zerion-api/types/Fungible'; function getSign( decimaledValue?: number | BigNumber | string, - direction?: Direction + direction?: ActionDirection | null ) { - if (!decimaledValue || !direction || direction === 'self') { + if (!decimaledValue || !direction) { return ''; } return direction === 'in' ? '+' : minus; } -function HistoryTokenValue({ +export function HistoryTokenValue({ actionType, - value, - asset, - chain, + amount, + fungible, direction, - address, withLink, }: { actionType: ActionType; - value: number | string; - asset: Asset; - chain: Chain; - direction: Direction; - address?: string; + amount: Amount | null; + fungible: Fungible; + direction: ActionDirection | null; withLink: boolean; }) { - const sign = getSign(value, direction); - const commonQuantity = useMemo( - () => - value === '0' && actionType === 'revoke' - ? null - : getCommonQuantity({ - asset, - chain, - baseQuantity: value, - }), - [chain, actionType, asset, value] - ); + const sign = getSign(amount?.value || 0, direction); + const quantity = actionType === 'revoke' ? null : amount?.quantity; return ( - {commonQuantity ? ( - - ) : null} + {quantity ? : null} {withLink ? ( - + ) : ( - asset.symbol?.toUpperCase() || asset.name + fungible.symbol || fungible.name )} ); } export function HistoryNFTValue({ - quantity = 0, - nftAsset, - chain, - name, + amount, + nft, direction, - address, withLink, }: { - quantity?: number; - nftAsset?: NFTAsset | null; - chain?: Chain; - name?: string; - direction?: Direction; - address?: string; + amount: Amount | null; + nft: NFTPreview; + direction: ActionDirection | null; withLink?: boolean; }) { return ( @@ -108,17 +83,13 @@ export function HistoryNFTValue({ alignItems="center" style={{ gridTemplateColumns: 'minmax(40px, 1fr) auto' }} > - {quantity > 1 ? ( + {(Number(amount?.quantity) || 0) > 1 ? ( - {getSign(quantity, direction)} - {quantity} + {getSign(amount?.quantity, direction)} + {amount?.quantity} ) : null} - {(!quantity || quantity === 1) && nftAsset?.asset_code && withLink ? ( - - ) : ( - name - )} + {withLink ? : nft?.metadata?.name || 'NFT'}
); } @@ -126,16 +97,10 @@ export function HistoryNFTValue({ export function HistoryItemValue({ actionType, transfers, - direction, - chain, - address, withLink, }: { actionType: ActionType; - transfers?: Pick[]; - direction: Direction; - chain: Chain; - address?: string; + transfers?: Transfer[]; withLink: boolean; }) { if (!transfers?.length) { @@ -145,65 +110,80 @@ export function HistoryItemValue({ if (transfers.length > 1) { return ( - {direction === 'out' ? minus : '+'} + {transfers[0].direction === 'out' ? minus : '+'} {transfers.length} assets ); } - const nftAsset = getNftAsset(transfers[0].asset); - const fungibleAsset = getFungibleAsset(transfers[0].asset); + const transfer = transfers[0]; - return nftAsset ? ( + return transfer.nft ? ( - ) : fungibleAsset ? ( + ) : transfer.fungible ? ( ) : null; } +export function HistoryApprovalValue({ + approvals, + withLink, +}: { + approvals: Approval[]; + withLink: boolean; +}) { + if (!approvals.length) { + return null; + } + + if (approvals.length > 1) { + return {approvals.length} assets; + } + + const approval = approvals[0]; + + return approval.nft ? ( + withLink ? ( + + ) : ( + {approval.nft.metadata?.name || 'NFT'} + ) + ) : approval.fungible ? ( + withLink ? ( + + ) : ( + {approval.fungible.name || approval.fungible.symbol} + ) + ) : approval.collection ? ( + {approval.collection.name} + ) : null; +} + export function TransactionCurrencyValue({ transfers, - chain, currency, }: { - transfers?: ActionTransfer[]; - chain: Chain; + transfers?: Transfer[]; currency: string; }) { if (transfers?.length !== 1) { return null; } const transfer = transfers[0]; - const asset = getFungibleAsset(transfer.asset); - if (!asset) { + if (transfer.amount?.value == null) { return null; } - - const commonQuantity = getCommonQuantity({ - asset, - chain, - baseQuantity: transfer.quantity, - }); - const value = formatCurrencyValue( - commonQuantity.times(transfer.price || 0), - 'en', - currency - ); + const value = formatCurrencyValue(transfer.amount.value, 'en', currency); return <>{value}; } diff --git a/src/ui/pages/History/ActionItem/TransactionTypeIcon.tsx b/src/ui/pages/History/ActionItem/TransactionTypeIcon.tsx index 6b4dedbc9c..8b402e4ebd 100644 --- a/src/ui/pages/History/ActionItem/TransactionTypeIcon.tsx +++ b/src/ui/pages/History/ActionItem/TransactionTypeIcon.tsx @@ -1,10 +1,4 @@ import React, { useMemo } from 'react'; -import type { - ActionAsset, - ActionTransfer, - ActionType, - AddressAction, -} from 'defi-sdk'; import ApproveIcon from 'jsx:src/ui/assets/actionTypes/approve.svg'; import BorrowIcon from 'jsx:src/ui/assets/actionTypes/borrow.svg'; import BurnIcon from 'jsx:src/ui/assets/actionTypes/burn.svg'; @@ -27,6 +21,15 @@ import ChangeAssets2 from 'jsx:src/ui/assets/changed-assets-2.svg'; import ChangeAssets3 from 'jsx:src/ui/assets/changed-assets-3.svg'; import ChangeAssetsMore from 'jsx:src/ui/assets/changed-assets-more.svg'; import { AssetIcon } from 'src/ui/components/AssetIcon'; +import type { + ActionType, + Approval, + Collection, + NFTPreview, + Transfer, +} from 'src/modules/zerion-api/requests/wallet-get-actions'; +import type { AnyAddressAction } from 'src/modules/ethereum/transactions/addressAction'; +import type { Fungible } from 'src/modules/zerion-api/types/Fungible'; export const TRANSACTION_ICON_SIZE = 36; export const TRANSACTION_SMALL_ICON_SIZE = 27; @@ -129,33 +132,68 @@ function TransactionMultipleAssetsIcon({ } export function HistoryAssetIcon({ - asset, + fungible, + nft, + collection, type, size, }: { - asset?: ActionAsset; + fungible: Fungible | null; + nft: NFTPreview | null; + collection: Collection | null; type: ActionType; size: number; }) { - if (!asset) { + if (!fungible && !nft && !collection) { return ; } return ( } /> ); } +function ApprovalIcon({ + approvals, + type, + size, +}: { + approvals: Approval[]; + type: ActionType; + size: number; +}) { + if (!approvals.length) { + return null; + } + if (approvals.length > 1) { + return ( + + ); + } + + return ( + + ); +} + function TransferIcon({ transfers, type, size, }: { - transfers: ActionTransfer[]; + transfers: Transfer[]; type: ActionType; size: number; }) { @@ -168,24 +206,31 @@ function TransferIcon({ ); } return ( - + ); } -export function TransactionItemIcon({ action }: { action: AddressAction }) { - const singleTransfer = action.content?.single_asset; - const incomingTransfers = action.content?.transfers?.incoming; - const outgoingTransfers = action.content?.transfers?.outgoing; - - if (singleTransfer) { - return ( - - ); - } +export function TransactionItemIcon({ + addressAction, +}: { + addressAction: AnyAddressAction; +}) { + const approvals = addressAction.content?.approvals; + const incomingTransfers = useMemo( + () => addressAction.content?.transfers?.filter((t) => t.direction === 'in'), + [addressAction] + ); + const outgoingTransfers = useMemo( + () => + addressAction.content?.transfers?.filter((t) => t.direction === 'out'), + [addressAction] + ); if (incomingTransfers?.length && outgoingTransfers?.length) { return ( @@ -199,14 +244,14 @@ export function TransactionItemIcon({ action }: { action: AddressAction }) {
@@ -218,7 +263,7 @@ export function TransactionItemIcon({ action }: { action: AddressAction }) { return ( ); @@ -228,11 +273,21 @@ export function TransactionItemIcon({ action }: { action: AddressAction }) { return ( + ); + } + + if (approvals) { + return ( + ); } - return ; + return ; } diff --git a/src/ui/pages/History/ActionsList/ActionsList.tsx b/src/ui/pages/History/ActionsList/ActionsList.tsx index ed4a7d64c5..eca9937fbe 100644 --- a/src/ui/pages/History/ActionsList/ActionsList.tsx +++ b/src/ui/pages/History/ActionsList/ActionsList.tsx @@ -6,11 +6,8 @@ import { UIText } from 'src/ui/ui-kit/UIText'; import { SurfaceList } from 'src/ui/ui-kit/SurfaceList'; import { ViewLoading } from 'src/ui/components/ViewLoading'; import { HStack } from 'src/ui/ui-kit/HStack'; -import { - isLocalAddressAction, - type AnyAddressAction, -} from 'src/modules/ethereum/transactions/addressAction'; -import { DelayedRender } from 'src/ui/components/DelayedRender'; +import type { AnyAddressAction } from 'src/modules/ethereum/transactions/addressAction'; +import { isLocalAddressAction } from 'src/modules/ethereum/transactions/addressAction'; import { usePreferences } from 'src/ui/features/preferences'; import { ActionItem } from '../ActionItem'; @@ -29,7 +26,7 @@ export function ActionsList({ const groupedByDate = useMemo( () => groupBy(actions, (item) => - startOfDate(new Date(item.datetime).getTime() || Date.now()).getTime() + startOfDate(new Date(item.timestamp).getTime() || Date.now()).getTime() ), [actions] ); @@ -52,7 +49,10 @@ export function ActionsList({ { - const hash = addressAction.transaction.hash; + const hash = + addressAction.transaction?.hash || + addressAction.acts?.at(0)?.transaction.hash || + ''; return { key: isLocalAddressAction(addressAction) ? `local-${addressAction.relatedTransaction || hash}` @@ -71,15 +71,14 @@ export function ActionsList({
{actions.length && (isLoading || hasMore) ? ( - - + ) : ( Show More diff --git a/src/ui/pages/History/History.tsx b/src/ui/pages/History/History.tsx index 4dacba0ff7..7f7ad7f157 100644 --- a/src/ui/pages/History/History.tsx +++ b/src/ui/pages/History/History.tsx @@ -1,9 +1,5 @@ import React, { useMemo, useState } from 'react'; -import type { AddressAction } from 'defi-sdk'; -import { Client, useAddressActions } from 'defi-sdk'; -import { hashQueryKey, useQuery } from '@tanstack/react-query'; import { useAddressParams } from 'src/ui/shared/user-address/useAddressParams'; -import { useLocalAddressTransactions } from 'src/ui/transactions/useLocalAddressTransactions'; import type { Chain } from 'src/modules/networks/Chain'; import { createChain } from 'src/modules/networks/Chain'; import { useNetworks } from 'src/modules/networks/useNetworks'; @@ -12,12 +8,9 @@ import { VStack } from 'src/ui/ui-kit/VStack'; import { UnstyledButton } from 'src/ui/ui-kit/UnstyledButton'; import * as helperStyles from 'src/ui/style/helpers.module.css'; import { NetworkSelectValue } from 'src/modules/networks/NetworkSelectValue'; -import type { AnyAddressAction } from 'src/modules/ethereum/transactions/addressAction'; -import { pendingTransactionToAddressAction } from 'src/modules/ethereum/transactions/addressAction/creators'; import { ViewLoading } from 'src/ui/components/ViewLoading'; import { CenteredFillViewportView } from 'src/ui/components/FillView/FillView'; import { useStore } from '@store-unit/react'; -import { useDefiSdkClient } from 'src/modules/defi-sdk/useDefiSdkClient'; import { useCurrency } from 'src/modules/currency/useCurrency'; import { EmptyView } from 'src/ui/components/EmptyView'; import { NetworkBalance } from 'src/ui/pages/Overview/Positions/NetworkBalance'; @@ -27,14 +20,28 @@ import { offsetValues, } from 'src/ui/pages/Overview/getTabsOffset'; import { getAddressType } from 'src/shared/wallet/classifiers'; +import { useWalletActions } from 'src/modules/zerion-api/hooks/useWalletActions'; +import { useHttpClientSource } from 'src/modules/zerion-api/hooks/useHttpClientSource'; +import type { AnyAddressAction } from 'src/modules/ethereum/transactions/addressAction'; +import type { AddressAction } from 'src/modules/zerion-api/requests/wallet-get-actions'; +import { useLocalAddressTransactions } from 'src/ui/transactions/useLocalAddressTransactions'; +import { useDefiSdkClient } from 'src/modules/defi-sdk/useDefiSdkClient'; +import { hashQueryKey, useQuery } from '@tanstack/react-query'; +import { pendingTransactionToAddressAction } from 'src/modules/ethereum/transactions/addressAction/creators'; +import { Client } from 'defi-sdk'; +import SyncIcon from 'jsx:src/ui/assets/sync.svg'; +import { HStack } from 'src/ui/ui-kit/HStack'; +import { Button } from 'src/ui/ui-kit/Button'; +import { KeyboardShortcut } from 'src/ui/components/KeyboardShortcut'; import { ActionsList } from './ActionsList'; import { ActionSearch } from './ActionSearch'; import { isMatchForAllWords } from './matchSearcQuery'; +import * as styles from './styles.module.css'; -function sortActions(actions: T[]) { +function sortActions(actions: T[]) { return actions.sort((a, b) => { - const aDate = a.datetime ? new Date(a.datetime).getTime() : Date.now(); - const bDate = b.datetime ? new Date(b.datetime).getTime() : Date.now(); + const aDate = a.timestamp || Date.now(); + const bDate = b.timestamp || Date.now(); return bDate - aDate; }); } @@ -44,9 +51,14 @@ function mergeLocalAndBackendActions( backend: AddressAction[], hasMoreBackendActions: boolean ) { - const backendHashes = new Set(backend.map((tx) => tx.transaction.hash)); + const backendHashes = new Set( + backend.flatMap( + (tx) => + tx.transaction?.hash || tx.acts?.map((act) => act.transaction.hash) + ) + ); - const lastBackendActionDatetime = backend.at(-1)?.datetime; + const lastBackendActionDatetime = backend.at(-1)?.timestamp; const lastBackendTimestamp = lastBackendActionDatetime && hasMoreBackendActions ? new Date(lastBackendActionDatetime).getTime() @@ -55,8 +67,13 @@ function mergeLocalAndBackendActions( const merged = local .filter( (tx) => + tx.transaction?.hash && backendHashes.has(tx.transaction.hash) === false && - new Date(tx.datetime).getTime() >= lastBackendTimestamp + !tx.acts?.some( + (act) => + act.transaction.hash && backendHashes.has(act.transaction.hash) + ) && + tx.timestamp >= lastBackendTimestamp ) .concat(backend); return sortActions(merged); @@ -98,7 +115,7 @@ function useMinedAndPendingAddressActions({ ); if (chain) { items = items.filter( - (item) => item.transaction.chain === chain.toString() + (item) => item.transaction?.chain.id === chain.toString() ); } if (searchQuery) { @@ -109,32 +126,23 @@ function useMinedAndPendingAddressActions({ useErrorBoundary: true, }); - const { - value, - isFetching: actionsIsLoading, - hasNext, - fetchMore, - } = useAddressActions( + const { actions, queryData, refetch } = useWalletActions( { - ...params, + addresses: [params.address], currency, - actions_chains: - chain && isSupportedByBackend ? [chain.toString()] : undefined, - actions_search_query: searchQuery, - }, - { + chain: chain && isSupportedByBackend ? chain.toString() : undefined, + searchQuery, limit: 10, - listenForUpdates: true, - paginatedCacheMode: 'first-page', - enabled: isSupportedByBackend, - } + }, + { source: useHttpClientSource() }, + { enabled: isSupportedByBackend } ); return useMemo(() => { - const backendItems = isSupportedByBackend && value ? value : []; - const hasMore = Boolean(isSupportedByBackend && hasNext); + const backendItems = isSupportedByBackend && actions ? actions : []; + const hasMore = Boolean(isSupportedByBackend && queryData.hasNextPage); return { - value: localAddressActions + actions: localAddressActions ? mergeLocalAndBackendActions( localAddressActions, backendItems, @@ -142,18 +150,20 @@ function useMinedAndPendingAddressActions({ ) : null, ...localActionsQuery, - isLoading: actionsIsLoading || localActionsQuery.isLoading, - hasMore, - fetchMore, + isLoading: + queryData.isLoading || + queryData.isFetching || + localActionsQuery.isLoading, + queryData, + refetch, }; }, [ isSupportedByBackend, - value, + actions, localAddressActions, localActionsQuery, - actionsIsLoading, - hasNext, - fetchMore, + queryData, + refetch, ]); } @@ -203,12 +213,11 @@ export function HistoryList({ : null; const [searchQuery, setSearchQuery] = useState(); - const { - value: transactions, - isLoading, - fetchMore, - hasMore, - } = useMinedAndPendingAddressActions({ chain, searchQuery }); + const { actions, isLoading, queryData, refetch } = + useMinedAndPendingAddressActions({ + chain, + searchQuery, + }); const actionFilters = (
@@ -222,21 +231,61 @@ export function HistoryList({ value={null} /> ) : null} - { - window.scrollTo({ - behavior: 'smooth', - top: getCurrentTabsOffset(offsetValuesState), - }); - }} - /> + + { + window.scrollTo({ + behavior: 'smooth', + top: getCurrentTabsOffset(offsetValuesState), + }); + }} + /> + + { + if (!isLoading) { + refetch(); + } + }} + /> + { + if (!isLoading) { + refetch(); + } + }} + /> +
); - if (!transactions?.length) { + if (!actions?.length) { return ( ); diff --git a/src/ui/pages/History/isUnlimitedApproval.ts b/src/ui/pages/History/isUnlimitedApproval.ts deleted file mode 100644 index 0cc4095dc9..0000000000 --- a/src/ui/pages/History/isUnlimitedApproval.ts +++ /dev/null @@ -1,6 +0,0 @@ -import BigNumber from 'bignumber.js'; -import { UNLIMITED_APPROVAL_AMOUNT } from 'src/modules/ethereum/constants'; - -export function isUnlimitedApproval(value?: BigNumber.Value | null) { - return new BigNumber(value?.toString() || 0).gte(UNLIMITED_APPROVAL_AMOUNT); -} diff --git a/src/ui/pages/History/matchSearcQuery.ts b/src/ui/pages/History/matchSearcQuery.ts index 27d312387f..bff96366e3 100644 --- a/src/ui/pages/History/matchSearcQuery.ts +++ b/src/ui/pages/History/matchSearcQuery.ts @@ -1,24 +1,36 @@ import { isTruthy } from 'is-truthy-ts'; -import { - getFungibleAsset, - getNftAsset, -} from 'src/modules/ethereum/transactions/actionAsset'; import type { LocalAddressAction } from 'src/modules/ethereum/transactions/addressAction'; +import type { + Collection, + NFTPreview, +} from 'src/modules/zerion-api/requests/wallet-get-actions'; +import type { Fungible } from 'src/modules/zerion-api/types/Fungible'; -interface Asset { - asset_code: string; - name: string | null; - symbol: string; +function fungibleMatches(query: string, fungible: Fungible | null) { + if (!fungible) { + return false; + } + return [fungible.name, fungible.symbol, fungible.id] + .filter(isTruthy) + .map((s) => s.toLowerCase()) + .some((s) => s.includes(query)); } -function assetMatches( - query: string, - asset?: Asset | Record | null -) { - if (!asset || !('asset_code' in asset)) { +function nftMatches(query: string, nft: NFTPreview | null) { + if (!nft) { return false; } - return [asset.name, asset.symbol, asset.asset_code] + return [nft.metadata?.name, nft.contractAddress, nft.tokenId] + .filter(isTruthy) + .map((s) => s.toLowerCase()) + .some((s) => s.includes(query)); +} + +function collectionMatches(query: string, collection: Collection | null) { + if (!collection) { + return false; + } + return [collection.name, collection.id] .filter(isTruthy) .map((s) => s.toLowerCase()) .some((s) => s.includes(query)); @@ -26,43 +38,38 @@ function assetMatches( function isMatchForQuery(query: string, action: LocalAddressAction) { if ( - action.type.display_value.toLowerCase().includes(query) || + action.type.displayValue.toLowerCase().includes(query) || action.type.value.toLowerCase().includes(query) ) { return true; } - if (action.transaction.status.includes(query)) { - return true; - } - - if ( - action.label?.display_value.contract_address?.includes(query) || - action.label?.display_value.wallet_address?.includes(query) - ) { + if (action.status.includes(query)) { return true; } if ( - assetMatches(query, getFungibleAsset(action.content?.single_asset?.asset)) + action.label?.contract?.address.includes(query) || + action.label?.wallet?.address.includes(query) ) { return true; } if ( - action.content?.transfers?.incoming?.some( + action.content?.transfers?.some( (transfer) => - assetMatches(query, getFungibleAsset(transfer.asset)) || - assetMatches(query, getNftAsset(transfer.asset)) + fungibleMatches(query, transfer.fungible) || + nftMatches(query, transfer.nft) ) ) { return true; } if ( - action.content?.transfers?.outgoing?.some( + action.content?.approvals?.some( (transfer) => - assetMatches(query, getFungibleAsset(transfer.asset)) || - assetMatches(query, getNftAsset(transfer.asset)) + fungibleMatches(query, transfer.fungible) || + nftMatches(query, transfer.nft) || + collectionMatches(query, transfer.collection) ) ) { return true; diff --git a/src/ui/pages/History/styles.module.css b/src/ui/pages/History/styles.module.css new file mode 100644 index 0000000000..9a61ba4ff7 --- /dev/null +++ b/src/ui/pages/History/styles.module.css @@ -0,0 +1,9 @@ +@keyframes spin { + 100% { + transform: rotate(-360deg); + } +} + +.updateIconLoading { + animation: spin 1s linear infinite; +} diff --git a/src/ui/pages/SendForm/SendForm.tsx b/src/ui/pages/SendForm/SendForm.tsx index 0fa7df1950..e93594fce9 100644 --- a/src/ui/pages/SendForm/SendForm.tsx +++ b/src/ui/pages/SendForm/SendForm.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useId, useMemo, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { hashQueryKey, useMutation, useQuery } from '@tanstack/react-query'; -import type { AddressAction, AddressPosition } from 'defi-sdk'; +import type { AddressPosition } from 'defi-sdk'; import { Client } from 'defi-sdk'; import { sortPositionsByValue } from '@zeriontech/transactions'; import { useAddressParams } from 'src/ui/shared/user-address/useAddressParams'; @@ -36,7 +36,10 @@ import { ViewLoadingSuspense } from 'src/ui/components/ViewLoading/ViewLoading'; import type { SendTxBtnHandle } from 'src/ui/components/SignTransactionButton'; import { SignTransactionButton } from 'src/ui/components/SignTransactionButton'; import { useWindowSizeStore } from 'src/ui/shared/useWindowSizeStore'; -import { createSendAddressAction } from 'src/modules/ethereum/transactions/addressAction'; +import { + createSendTokenAddressAction, + createSendNFTAddressAction, +} from 'src/modules/ethereum/transactions/addressAction'; import { HiddenValidationInput } from 'src/ui/shared/forms/HiddenValidationInput'; import { useDefiSdkClient } from 'src/modules/defi-sdk/useDefiSdkClient'; import { DisableTestnetShortcuts } from 'src/ui/features/testnet-mode/DisableTestnetShortcuts'; @@ -45,8 +48,6 @@ import { useHttpClientSource } from 'src/modules/zerion-api/hooks/useHttpClientS import { useGasbackEstimation } from 'src/modules/ethereum/account-abstraction/rewards'; import { useHttpAddressPositions } from 'src/modules/zerion-api/hooks/useWalletPositions'; import { usePositionsRefetchInterval } from 'src/ui/transactions/usePositionsRefetchInterval'; -import { commonToBase } from 'src/shared/units/convert'; -import { getDecimals } from 'src/modules/networks/asset'; import type { SignTransactionResult } from 'src/shared/types/SignTransactionResult'; import { ensureSolanaResult } from 'src/modules/shared/transactions/helpers'; import { getAddressType } from 'src/shared/wallet/classifiers'; @@ -56,6 +57,9 @@ import { getDefaultChain } from 'src/ui/shared/forms/trading/getDefaultChain'; import { isMatchForEcosystem } from 'src/shared/wallet/shared'; import { ErrorMessage } from 'src/ui/shared/error-display/ErrorMessage'; import { getError } from 'get-error'; +import type { AddressAction } from 'src/modules/zerion-api/requests/wallet-get-actions'; +import BigNumber from 'bignumber.js'; +import { useAssetFullInfo } from 'src/modules/zerion-api/hooks/useAssetFullInfo'; import { TransactionConfiguration } from '../SendTransaction/TransactionConfiguration'; import { NetworkSelect } from '../Networks/NetworkSelect'; import { NetworkFeeLineInfo } from '../SendTransaction/TransactionConfiguration/TransactionConfiguration'; @@ -213,32 +217,72 @@ function SendFormComponent() { snapshotRef.current = { state: { ...formState } }; }; + const { data: inputFungibleUsdInfoForAnalytics } = useAssetFullInfo( + { fungibleId: currentPosition?.asset.id || '', currency: 'usd' }, + { source: useHttpClientSource() }, + { enabled: Boolean(currentPosition?.asset.id) } + ); + const sendTxMutation = useMutation({ mutationFn: async ( interpretationAction: AddressAction | null ): Promise => { + invariant(sendData?.network, 'Network must be defined to sign the tx'); invariant(sendData?.transaction, 'Send Form parameters missing'); invariant(currentPosition, 'Current asset position is undefined'); const feeValueCommon = feeValueCommonRef.current || null; invariant(signTxBtnRef.current, 'SignTransactionButton not found'); - - const chain = sendData.networkId; - const valueInBaseUnits = commonToBase( - tokenValue, - getDecimals({ asset: currentPosition.asset, chain }) - ).toFixed(); - const fallbackAddressAction = createSendAddressAction({ - address, - transaction: sendData.transaction, - asset: currentPosition.asset, - quantity: valueInBaseUnits, - chain, - }); + const fallbackAddressAction = + type === 'token' + ? createSendTokenAddressAction({ + address, + hash: null, + explorerUrl: null, + transaction: sendData.transaction, + network: sendData.network, + receiverAddress: to, + sendAsset: currentPosition.asset, + sendAmount: { + currency, + quantity: tokenValue, + value: currentPosition.asset.price?.value + ? new BigNumber(tokenValue) + .multipliedBy(currentPosition.asset.price.value) + .toNumber() + : null, + usdValue: inputFungibleUsdInfoForAnalytics?.data?.fungible.meta + .price + ? new BigNumber(tokenValue) + .multipliedBy( + inputFungibleUsdInfoForAnalytics.data.fungible.meta + .price + ) + .toNumber() + : null, + }, + }) + : sendData.nftPosition + ? createSendNFTAddressAction({ + address, + hash: null, + explorerUrl: null, + transaction: sendData.transaction, + network: sendData.network, + receiverAddress: to, + sendAsset: sendData.nftPosition, + sendAmount: { + currency, + quantity: formState.nftAmount, + usdValue: null, + value: null, + }, + }) + : null; const txResponse = await signTxBtnRef.current.sendTransaction({ transaction: sendData.transaction, - chain: sendData.networkId.toString(), + chain: sendData.network.id, initiator: INTERNAL_ORIGIN, clientScope: 'Send', feeValueCommon, diff --git a/src/ui/pages/SendForm/shared/prepareSendData.ts b/src/ui/pages/SendForm/shared/prepareSendData.ts index 626ec15096..7f7ddd7e83 100644 --- a/src/ui/pages/SendForm/shared/prepareSendData.ts +++ b/src/ui/pages/SendForm/shared/prepareSendData.ts @@ -129,11 +129,11 @@ async function getPaymasterTx(transaction: IncomingTransaction) { type SendSubmitData = ( | { - networkId: null; + network: null; transaction: null; } | { - networkId: Chain; + network: NetworkConfig; transaction: MultichainTransaction< PartiallyRequired >; @@ -144,6 +144,7 @@ type SendSubmitData = ( ReturnType >; networkFee: null | NetworkFeeType; + nftPosition: null | AddressNFT; }; export async function prepareSendData( @@ -153,11 +154,12 @@ export async function prepareSendData( client: Client ): Promise { const EMPTY_SEND_DATA = { - networkId: null, + network: null, paymasterPossible: false, paymasterEligibility: null, transaction: null, networkFee: null, + nftPosition: null, }; const { type, @@ -182,11 +184,12 @@ export async function prepareSendData( const chainId = Networks.getChainId(network); let tx: IncomingTransaction; + let nftPosition: AddressNFT | null = null; if (type === 'nft') { if (!nftAmount || !nftId) { return EMPTY_SEND_DATA; } - const nftPosition = await getNftPosition(client, from, formState); + nftPosition = await getNftPosition(client, from, formState); tx = createSendNFTTransaction({ chainId, from, @@ -256,11 +259,12 @@ export async function prepareSendData( assertProp(tx, 'chainId'); assertProp(tx, 'from'); return { - networkId: chain, + network, paymasterPossible: network.supports_sponsored_transactions, paymasterEligibility: eligibility, transaction: { evm: tx }, networkFee: null, // TODO: Currently calculated in UI, calculate here instead + nftPosition, }; } else { if (type === 'nft') { @@ -277,11 +281,12 @@ export async function prepareSendData( network ); return { - networkId: chain, + network, paymasterPossible: false, paymasterEligibility: null, networkFee: fee != null ? createNetworkFee(fee, network) : null, transaction: { solana: solToBase64(tx) }, + nftPosition: null, }; } } diff --git a/src/ui/pages/SendTransaction/SendTransaction.tsx b/src/ui/pages/SendTransaction/SendTransaction.tsx index bf265c6441..64b5677a47 100644 --- a/src/ui/pages/SendTransaction/SendTransaction.tsx +++ b/src/ui/pages/SendTransaction/SendTransaction.tsx @@ -1,9 +1,12 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import { hashQueryKey, useMutation, useQuery } from '@tanstack/react-query'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { Client, type AddressAction } from 'defi-sdk'; +import { Client } from 'defi-sdk'; import type { CustomConfiguration } from '@zeriontech/transactions'; -import type { AnyAddressAction } from 'src/modules/ethereum/transactions/addressAction'; +import { + getActionApproval, + type AnyAddressAction, +} from 'src/modules/ethereum/transactions/addressAction'; import { incomingTxToIncomingAddressAction } from 'src/modules/ethereum/transactions/addressAction/creators'; import type { IncomingTransaction, @@ -47,7 +50,6 @@ import type { Networks } from 'src/modules/networks/Networks'; import type { TransactionAction } from 'src/modules/ethereum/transactions/describeTransaction'; import { describeTransaction } from 'src/modules/ethereum/transactions/describeTransaction'; import { AllowanceView } from 'src/ui/components/AllowanceView'; -import { getFungibleAsset } from 'src/modules/ethereum/transactions/actionAsset'; import type { ExternallyOwnedAccount } from 'src/shared/types/ExternallyOwnedAccount'; import { useEvent } from 'src/ui/shared/useEvent'; import { NavigationTitle } from 'src/ui/components/NavigationTitle'; @@ -61,7 +63,6 @@ import { CenteredDialog } from 'src/ui/ui-kit/ModalDialogs/CenteredDialog'; import type { HTMLDialogElementInterface } from 'src/ui/ui-kit/ModalDialogs/HTMLDialogElementInterface'; import { DialogTitle } from 'src/ui/ui-kit/ModalDialogs/DialogTitle'; import { TextLink } from 'src/ui/ui-kit/TextLink'; -import type { InterpretResponse } from 'src/modules/ethereum/transactions/types'; import { normalizeChainId } from 'src/shared/normalizeChainId'; import type { NetworksSource } from 'src/modules/zerion-api/shared'; import { ZerionAPI } from 'src/modules/zerion-api/zerion-api.client'; @@ -101,6 +102,10 @@ import { SiteFaviconImg } from 'src/ui/components/SiteFaviconImg'; import { NetworkId } from 'src/modules/networks/NetworkId'; import { getError } from 'get-error'; import { ErrorMessage } from 'src/ui/shared/error-display/ErrorMessage'; +import { baseToCommon, commonToBase } from 'src/shared/units/convert'; +import type { InterpretResponse } from 'src/modules/zerion-api/requests/wallet-simulate-transaction'; +import { getDecimals } from 'src/modules/networks/asset'; +import { UNLIMITED_APPROVAL_AMOUNT } from 'src/modules/ethereum/constants'; import type { PopoverToastHandle } from '../Settings/PopoverToast'; import { PopoverToast } from '../Settings/PopoverToast'; import { TransactionConfiguration } from './TransactionConfiguration'; @@ -236,7 +241,7 @@ function usePreparedTx(transaction: IncomingTransaction, origin: string) { }; } -function useLocalAddressAction({ +function useLocalAction({ address: from, transactionAction, transaction, @@ -292,8 +297,8 @@ function TransactionDefaultView({ origin, wallet, addressAction, - singleAsset, - allowanceQuantityBase, + allowanceQuantityCommon, + customAllowanceQuantityBase, interpretation, interpretQuery, populatedTransaction, @@ -310,8 +315,8 @@ function TransactionDefaultView({ origin: string; wallet: ExternallyOwnedAccount; addressAction: AnyAddressAction; - singleAsset: NonNullable['single_asset']; - allowanceQuantityBase: string | null; + allowanceQuantityCommon: string | null; + customAllowanceQuantityBase: string | null; interpretation: InterpretResponse | null | undefined; interpretQuery: { isInitialLoading: boolean; @@ -335,8 +340,8 @@ function TransactionDefaultView({ [params] ); - const recipientAddress = addressAction.label?.display_value.wallet_address; - const actionTransfers = addressAction.content?.transfers; + const network = networks.getByNetworkId(chain); + invariant(network, 'Network should be known to show transaction details'); return ( <> @@ -360,7 +365,7 @@ function TransactionDefaultView({ url={origin} alt={`Logo for ${origin}`} /> - {addressAction.type.display_value} + {addressAction.type.displayValue} {origin === INTERNAL_ORIGIN ? ( 'Zerion' @@ -396,16 +401,13 @@ function TransactionDefaultView({ > @@ -493,8 +495,8 @@ function TransactionDefaultView({ paymasterPossible={paymasterPossible} paymasterWaiting={paymasterWaiting} gasback={ - interpretation?.action?.transaction.gasback != null - ? { value: interpretation.action.transaction.gasback } + interpretation?.data.action?.gasback != null + ? { value: interpretation.data.action.gasback } : null } listViewTransitions={true} @@ -546,7 +548,7 @@ function SendTransactionContent({ }); const { data: localAddressAction, ...localAddressActionQuery } = - useLocalAddressAction({ + useLocalAction({ address: singleAddress, transactionAction, transaction: populatedTransaction, @@ -674,19 +676,15 @@ function SendTransactionContent({ eligibilityQueryStatus: eligibilityQuery.status, currency, origin, - client, }); }, }); const interpretationHasCriticalWarning = hasCriticalWarning( - interpretQuery.data?.warnings + interpretQuery.data?.data.warnings ); - const requestedAllowanceQuantityBase = - interpretQuery.data?.action?.content?.single_asset?.quantity || - localAddressAction?.content?.single_asset?.quantity; - const interpretAddressAction = interpretQuery.data?.action; + const interpretAddressAction = interpretQuery.data?.data.action; const addressAction = interpretAddressAction || localAddressAction || null; @@ -748,12 +746,42 @@ function SendTransactionContent({ throw new Error('Unexpected missing localAddressAction'); } + const maybeApproval = interpretQuery.data?.data.action + ? getActionApproval(interpretQuery.data.data.action) + : null; + const maybeLocalApproval = localAddressAction + ? getActionApproval(localAddressAction) + : null; + + const fungibleDecimals = maybeApproval?.fungible + ? getDecimals({ + asset: maybeApproval.fungible, + chain, + }) + : null; + + const requestedAllowanceQuantityCommon = + maybeApproval?.amount?.quantity ?? + maybeLocalApproval?.amount?.quantity ?? + UNLIMITED_APPROVAL_AMOUNT.toFixed(); + + const requestedAllowanceQuantityBase = + requestedAllowanceQuantityCommon && fungibleDecimals + ? commonToBase( + requestedAllowanceQuantityCommon, + fungibleDecimals + ).toFixed() + : null; + + const allowanceQuantityCommon = + fungibleDecimals && allowanceQuantityBase + ? baseToCommon(allowanceQuantityBase, fungibleDecimals).toFixed() + : requestedAllowanceQuantityCommon; + if (!addressAction) { return null; } - const singleAsset = addressAction?.content?.single_asset; - const handleChangeAllowance = (value: string) => { setAllowanceQuantityBase(value); navigate(-1); @@ -787,10 +815,8 @@ function SendTransactionContent({ origin={origin} wallet={wallet} addressAction={addressAction} - singleAsset={singleAsset} - allowanceQuantityBase={ - allowanceQuantityBase || requestedAllowanceQuantityBase || null - } + allowanceQuantityCommon={allowanceQuantityCommon || null} + customAllowanceQuantityBase={allowanceQuantityBase || null} interpretation={interpretQuery.data} interpretQuery={interpretQuery} populatedTransaction={populatedTransaction} @@ -806,30 +832,35 @@ function SendTransactionContent({ ( - <> - Details} - closeKind="icon" - /> - toastRef.current?.showToast()} - /> - - )} - > - {view === View.customAllowance ? ( + renderWhenOpen={() => { + invariant( + network, + 'Network should be known to show transaction details' + ); + return ( + <> + Details
} + closeKind="icon" + /> + toastRef.current?.showToast()} + /> + + ); + }} + /> + {view === View.customAllowance && network ? ( ) : null} @@ -952,14 +983,13 @@ function SolDefaultView({ }) { const originForHref = useMemo(() => prepareForHref(origin), [origin]); - const recipientAddress = addressAction.label?.display_value.wallet_address; - const actionTransfers = addressAction.content?.transfers; - const singleAsset = addressAction?.content?.single_asset; - const advancedDialogRef = useRef(null); const toastRef = useRef(null); + const network = networks.getByNetworkId(createChain(NetworkId.Solana)); + invariant(network, 'Network should be known to show transaction details'); + return ( <> @@ -982,7 +1012,7 @@ function SolDefaultView({ url={origin} alt={`Logo for ${origin}`} /> - {addressAction.type.display_value} + {addressAction.type.displayValue} {origin === INTERNAL_ORIGIN ? ( 'Zerion' @@ -1018,14 +1048,11 @@ function SolDefaultView({ >
@@ -1061,10 +1088,9 @@ function SolDefaultView({
- {addressAction.transaction.fee ? ( + {addressAction.fee ? ( ) : null} @@ -1080,8 +1106,7 @@ function SolDefaultView({ closeKind="icon" /> parseSolanaTransaction(wallet.address, solFromBase64(firstTx)), - [firstTx, wallet.address] + () => + parseSolanaTransaction(wallet.address, solFromBase64(firstTx), currency), + [firstTx, wallet.address, currency] ); // TODO: support multiple transactions in simulation @@ -1231,12 +1257,11 @@ function SolSendTransaction() { eligibilityQueryStatus: 'success', currency, origin, - client, }); }, }); - const addressAction = interpretQuery.data?.action || localAddressAction; + const addressAction = interpretQuery.data?.data.action || localAddressAction; const { mutate: sendTransaction, ...sendTransactionMutation } = useMutation({ mutationFn: async () => { diff --git a/src/ui/pages/SendTransaction/TransactionAdvancedView/TransactionAdvancedView.tsx b/src/ui/pages/SendTransaction/TransactionAdvancedView/TransactionAdvancedView.tsx index c0d9e7b739..1c7e2c0cf0 100644 --- a/src/ui/pages/SendTransaction/TransactionAdvancedView/TransactionAdvancedView.tsx +++ b/src/ui/pages/SendTransaction/TransactionAdvancedView/TransactionAdvancedView.tsx @@ -5,9 +5,7 @@ import { UIText } from 'src/ui/ui-kit/UIText'; import ArrowLeftTop from 'jsx:src/ui/assets/arrow-left-top.svg'; import type { IncomingTransaction } from 'src/modules/ethereum/types/IncomingTransaction'; import { noValueDash } from 'src/ui/shared/typography'; -import type { Networks } from 'src/modules/networks/Networks'; import CopyIcon from 'jsx:src/ui/assets/copy.svg'; -import type { Chain } from 'src/modules/networks/Chain'; import { truncateAddress } from 'src/ui/shared/truncateAddress'; import { VStack } from 'src/ui/ui-kit/VStack'; import { HStack } from 'src/ui/ui-kit/HStack'; @@ -15,8 +13,6 @@ import { TextAnchor } from 'src/ui/ui-kit/TextAnchor'; import { openInNewWindow } from 'src/ui/shared/openInNewWindow'; import { toUtf8String } from 'src/modules/ethereum/message-signing/toUtf8String'; import type { BigNumberish } from 'ethers'; -import type { InterpretResponse } from 'src/modules/ethereum/transactions/types'; -import { getInterpretationFunctionName } from 'src/modules/ethereum/transactions/interpret'; import { PageTop } from 'src/ui/components/PageTop'; import { TextLine } from 'src/ui/components/address-action/TextLine'; import { Button } from 'src/ui/ui-kit/Button'; @@ -28,24 +24,27 @@ import { DialogButtonValue } from 'src/ui/ui-kit/ModalDialogs/DialogTitle'; import { Spacer } from 'src/ui/ui-kit/Spacer'; import { PageBottom } from 'src/ui/components/PageBottom'; import type { MultichainTransaction } from 'src/shared/types/MultichainTransaction'; +import type { NetworkConfig } from 'src/modules/networks/NetworkConfig'; +import { Networks } from 'src/modules/networks/Networks'; +import { RecipientLine } from 'src/ui/components/address-action/RecipientLine'; +import type { InterpretResponse } from 'src/modules/zerion-api/requests/wallet-simulate-transaction'; function maybeHexValue(value?: BigNumberish | null): string | null { return value ? valueToHex(value) : null; } function AddressLine({ - networks, - chain, + network, label, address, }: { - networks: Networks; - chain: Chain; + network: NetworkConfig; label: React.ReactNode; address: string; }) { const truncatedAddress = truncateAddress(address, 16); - const explorerUrl = networks.getExplorerAddressUrlByName(chain, address); + const explorerUrl = Networks.getExplorerAddressUrl(network, address); + return ( @@ -74,22 +73,13 @@ function AddressLine({ } function TransactionDetails({ - networks, - chain, + network, transaction, - interpretation, }: { - networks: Networks; - chain: Chain; + network: NetworkConfig; transaction: IncomingTransaction; interpretation?: InterpretResponse | null; }) { - const functionName = useMemo( - () => - interpretation ? getInterpretationFunctionName(interpretation) : null, - [interpretation] - ); - const accessList = useMemo( () => transaction.accessList ? accessListify(transaction.accessList) : null, @@ -98,24 +88,11 @@ function TransactionDetails({ return ( - {functionName ? ( - <> - - - Function - - - {functionName} - - - - ) : null} {transaction.from ? ( @@ -124,8 +101,7 @@ function TransactionDetails({ )} {transaction.to ? ( @@ -199,15 +175,13 @@ function TransactionDetails({ } export function TransactionAdvancedView({ - networks, - chain, + network, transaction, interpretation, addressAction, onCopyData, }: { - networks: Networks; - chain: Chain; + network: NetworkConfig; transaction: MultichainTransaction; interpretation?: InterpretResponse | null; addressAction: AnyAddressAction; @@ -235,15 +209,20 @@ export function TransactionAdvancedView({ ['--surface-background-color' as string]: 'var(--neutral-100)', }} > - + {addressAction.label?.wallet ? ( + + ) : null} + {addressAction.label?.contract ? ( + + ) : null} {transaction.evm ? ( diff --git a/src/ui/pages/SendTransaction/TransactionConfiguration/TransactionConfiguration.tsx b/src/ui/pages/SendTransaction/TransactionConfiguration/TransactionConfiguration.tsx index 0f70eb20f0..387f1301c4 100644 --- a/src/ui/pages/SendTransaction/TransactionConfiguration/TransactionConfiguration.tsx +++ b/src/ui/pages/SendTransaction/TransactionConfiguration/TransactionConfiguration.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import type { AddressAction } from 'defi-sdk'; import { flushSync } from 'react-dom'; import type { CustomConfiguration } from '@zeriontech/transactions'; import QuestionHintIcon from 'jsx:src/ui/assets/question-hint.svg'; @@ -7,7 +6,7 @@ import type { IncomingTransaction, IncomingTransactionWithFrom, } from 'src/modules/ethereum/types/IncomingTransaction'; -import { createChain, type Chain } from 'src/modules/networks/Chain'; +import { type Chain } from 'src/modules/networks/Chain'; import { usePreferences } from 'src/ui/features/preferences'; import { VStack } from 'src/ui/ui-kit/VStack'; import { useGasPrices } from 'src/ui/shared/requests/useGasPrices'; @@ -30,9 +29,7 @@ import { formatTokenValue } from 'src/shared/units/formatTokenValue'; import { formatCurrencyValueExtra } from 'src/shared/units/formatCurrencyValue'; import { useCurrency } from 'src/modules/currency/useCurrency'; import { isEthereumAddress } from 'src/shared/isEthereumAddress'; -import { baseToCommon } from 'src/shared/units/convert'; -import { getDecimals } from 'src/modules/networks/asset'; -import { getFungibleAsset } from 'src/modules/ethereum/transactions/actionAsset'; +import type { ActionFee } from 'src/modules/zerion-api/requests/wallet-get-actions'; import { NetworkFee } from '../NetworkFee'; import { NonceLine } from '../NonceLine'; import { useTransactionFee } from './useTransactionFee'; @@ -120,35 +117,13 @@ export function NetworkFeeLineInfo({ export function AddressActionNetworkFee({ label, - networkFee, - chain, + fee, isLoading, }: { label?: React.ReactNode; - networkFee: NonNullable; + fee: ActionFee; isLoading: boolean; - chain: string; }) { - const { currency } = useCurrency(); - const { asset, fiatCost, assetCost } = useMemo(() => { - const asset = getFungibleAsset(networkFee.asset); - - if (!asset) { - return { asset: null, fiatCost: null, assetCost: null }; - } - const decimals = getDecimals({ asset, chain: createChain(chain) }); - const commonQuantity = baseToCommon(networkFee.quantity, decimals); - const fiatCost = asset.price - ? commonQuantity.times(asset.price?.value) - : null; - const assetCost = commonQuantity; - return { asset, fiatCost, assetCost }; - }, [chain, networkFee.asset, networkFee.quantity]); - - if (!fiatCost && !assetCost) { - return null; - } - return ( {label !== undefined ? ( @@ -161,11 +136,16 @@ export function AddressActionNetworkFee({ {isLoading ? : null} - {fiatCost != null - ? formatCurrencyValueExtra(fiatCost.toNumber(), 'en', currency, { - zeroRoundingFallback: 0.01, - }) - : formatTokenValue(assetCost.toNumber(), asset.symbol)} + {fee.amount.value != null + ? formatCurrencyValueExtra( + fee.amount.value, + 'en', + fee.amount.currency, + { + zeroRoundingFallback: 0.01, + } + ) + : formatTokenValue(fee.amount.quantity, fee.fungible?.symbol)} diff --git a/src/ui/pages/SendTransaction/TransactionWarnings/InsufficientFundsWarning.tsx b/src/ui/pages/SendTransaction/TransactionWarnings/InsufficientFundsWarning.tsx index 46df0b4404..e9c4c93df8 100644 --- a/src/ui/pages/SendTransaction/TransactionWarnings/InsufficientFundsWarning.tsx +++ b/src/ui/pages/SendTransaction/TransactionWarnings/InsufficientFundsWarning.tsx @@ -1,23 +1,25 @@ import React from 'react'; import type { NetworkFeeConfiguration } from '@zeriontech/transactions'; import type { IncomingTransaction } from 'src/modules/ethereum/types/IncomingTransaction'; -import type { Chain } from 'src/modules/networks/Chain'; +import { createChain } from 'src/modules/networks/Chain'; import { useNetworks } from 'src/modules/networks/useNetworks'; import { useGasPrices } from 'src/ui/shared/requests/useGasPrices'; +import type { NetworkConfig } from 'src/modules/networks/NetworkConfig'; import { useTransactionFee } from '../TransactionConfiguration/useTransactionFee'; import { TransactionWarning } from './TransactionWarning'; function useInsufficientFundsWarning({ address, transaction, - chain, + network, networkFeeConfiguration, }: { address: string; transaction: IncomingTransaction; - chain: Chain; + network: NetworkConfig; networkFeeConfiguration: NetworkFeeConfiguration; }) { + const chain = createChain(network.id); const { data: chainGasPrices = null } = useGasPrices(chain, { suspense: true, }); @@ -36,12 +38,12 @@ function useInsufficientFundsWarning({ export function InsufficientFundsWarning({ address, transaction, - chain, + network, networkFeeConfiguration, }: { address: string; transaction: IncomingTransaction; - chain: Chain; + network: NetworkConfig; networkFeeConfiguration: NetworkFeeConfiguration; }) { const { networks } = useNetworks(); @@ -49,7 +51,7 @@ export function InsufficientFundsWarning({ const isInsufficientFundsWarning = useInsufficientFundsWarning({ address, transaction, - chain, + network, networkFeeConfiguration, }); @@ -61,8 +63,7 @@ export function InsufficientFundsWarning({ ); diff --git a/src/ui/pages/SendTransaction/TransactionWarnings/TransactionWarnings.tsx b/src/ui/pages/SendTransaction/TransactionWarnings/TransactionWarnings.tsx index 4c94feff09..909c9de78b 100644 --- a/src/ui/pages/SendTransaction/TransactionWarnings/TransactionWarnings.tsx +++ b/src/ui/pages/SendTransaction/TransactionWarnings/TransactionWarnings.tsx @@ -1,10 +1,10 @@ import React from 'react'; import type { NetworkFeeConfiguration } from '@zeriontech/transactions'; import type { IncomingTransaction } from 'src/modules/ethereum/types/IncomingTransaction'; -import type { Chain } from 'src/modules/networks/Chain'; import type { AnyAddressAction } from 'src/modules/ethereum/transactions/addressAction'; import { ZStack } from 'src/ui/ui-kit/ZStack'; import { RenderArea } from 'react-area'; +import type { NetworkConfig } from 'src/modules/networks/NetworkConfig'; import { InsufficientFundsWarning } from './InsufficientFundsWarning'; import { TransactionWarning } from './TransactionWarning'; @@ -12,21 +12,21 @@ export function TransactionWarnings({ address, transaction, addressAction, - chain, + network, networkFeeConfiguration, paymasterEligible, }: { address: string; transaction: IncomingTransaction; addressAction: AnyAddressAction; - chain: Chain; + network: NetworkConfig; networkFeeConfiguration: NetworkFeeConfiguration; paymasterEligible: boolean; }) { return ( - {addressAction.transaction.status === 'failed' ? ( + {addressAction.status === 'failed' ? ( <> )} diff --git a/src/ui/pages/SignTypedData/SignTypedData.tsx b/src/ui/pages/SignTypedData/SignTypedData.tsx index 01b90c225d..1abc35f25f 100644 --- a/src/ui/pages/SignTypedData/SignTypedData.tsx +++ b/src/ui/pages/SignTypedData/SignTypedData.tsx @@ -19,20 +19,16 @@ import { isPermit, toTypedData, } from 'src/modules/ethereum/message-signing/prepareTypedData'; -import type { Chain } from 'src/modules/networks/Chain'; import { useNetworks } from 'src/modules/networks/useNetworks'; import { setURLSearchParams } from 'src/ui/shared/setURLSearchParams'; import { AddressActionDetails } from 'src/ui/components/address-action/AddressActionDetails'; import { focusNode } from 'src/ui/shared/focusNode'; -import { interpretSignature } from 'src/modules/ethereum/transactions/interpret'; +import { interpretSignature } from 'src/ui/shared/requests/interpret'; import { Content, RenderArea } from 'react-area'; import { PageBottom } from 'src/ui/components/PageBottom'; -import type { InterpretResponse } from 'src/modules/ethereum/transactions/types'; -import type { Networks } from 'src/modules/networks/Networks'; import { PageTop } from 'src/ui/components/PageTop'; import { AllowanceView } from 'src/ui/components/AllowanceView'; import { produce } from 'immer'; -import { getFungibleAsset } from 'src/modules/ethereum/transactions/actionAsset'; import type { ExternallyOwnedAccount } from 'src/shared/types/ExternallyOwnedAccount'; import { NavigationTitle } from 'src/ui/components/NavigationTitle'; import { requestChainForOrigin } from 'src/ui/shared/requests/requestChainForOrigin'; @@ -66,8 +62,13 @@ import { import { INTERNAL_ORIGIN } from 'src/background/constants'; import { getError } from 'get-error'; import { ErrorMessage } from 'src/ui/shared/error-display/ErrorMessage'; -import { PopoverToast } from '../Settings/PopoverToast'; +import type { NetworkConfig } from 'src/modules/networks/NetworkConfig'; +import { getActionApproval } from 'src/modules/ethereum/transactions/addressAction'; +import { baseToCommon } from 'src/shared/units/convert'; +import type { SignatureInterpretResponse } from 'src/modules/zerion-api/requests/wallet-simulate-signature'; +import { getDecimals } from 'src/modules/networks/asset'; import type { PopoverToastHandle } from '../Settings/PopoverToast'; +import { PopoverToast } from '../Settings/PopoverToast'; import { TypedDataAdvancedView } from './TypedDataAdvancedView'; enum View { @@ -94,12 +95,12 @@ function TypedDataDefaultView({ origin, clientScope: clientScopeParam, wallet, - chain, - networks, + network, typedDataRaw, typedData, interpretQuery, interpretation, + allowanceQuantityCommon, allowanceQuantityBase, onSignSuccess, onReject, @@ -108,8 +109,7 @@ function TypedDataDefaultView({ origin: string; clientScope: string | null; wallet: ExternallyOwnedAccount; - chain: Chain; - networks: Networks; + network: NetworkConfig; typedDataRaw: string; typedData: TypedData; interpretQuery: { @@ -117,8 +117,9 @@ function TypedDataDefaultView({ isError: boolean; isFetched: boolean; }; - interpretation?: InterpretResponse | null; - allowanceQuantityBase?: string; + interpretation?: SignatureInterpretResponse | null; + allowanceQuantityCommon: string | null; + allowanceQuantityBase: string | null; onSignSuccess: (signature: string) => void; onReject: () => void; onOpenAdvancedView: () => void; @@ -128,11 +129,10 @@ function TypedDataDefaultView({ const [params] = useSearchParams(); const { preferences } = usePreferences(); - const addressAction = interpretation?.action; - const recipientAddress = addressAction?.label?.display_value.wallet_address; + const addressAction = interpretation?.data.action; const title = - addressAction?.type.display_value || + addressAction?.type.displayValue || (isPermit(typedData) ? 'Permit' : 'Sign Message'); const typedDataFormatted = useMemo( @@ -183,7 +183,7 @@ function TypedDataDefaultView({ ); const interpretationHasCriticalWarning = hasCriticalWarning( - interpretation?.warnings + interpretation?.data.warnings ); const showRawTypedData = !addressAction; @@ -291,16 +291,13 @@ function TypedDataDefaultView({ - chainId - ? interpretSignature({ - address: wallet.address, - chainId, - typedData, - currency, - origin, - }) + chain + ? interpretSignature( + { + address: wallet.address, + chain: chain.toString(), + typedData, + currency, + origin, + }, + { source } + ) : null, enabled: Boolean(chainId && network?.supports_simulations), suspense: false, @@ -550,9 +554,19 @@ function SignTypedDataContent({ windowPort.confirm(windowId, signature); const handleReject = () => windowPort.reject(windowId); - const singleAsset = interpretation?.action?.content?.single_asset; + const maybeApproval = interpretation?.data.action + ? getActionApproval(interpretation.data.action) + : null; + + const allowanceQuantityCommon = + maybeApproval?.fungible && chain + ? baseToCommon( + allowanceQuantityBase || requestedAllowanceQuantityBase, + getDecimals({ asset: maybeApproval.fungible, chain }) + ).toFixed() + : null; - if (!networks || !chain) { + if (!network) { return null; } @@ -569,8 +583,7 @@ function SignTypedDataContent({ origin={origin} clientScope={clientScope} wallet={wallet} - chain={chain} - networks={networks} + network={network} typedDataRaw={typedDataRaw} typedData={typedData} interpretQuery={interpretQuery} @@ -578,6 +591,7 @@ function SignTypedDataContent({ allowanceQuantityBase={ allowanceQuantityBase || requestedAllowanceQuantityBase } + allowanceQuantityCommon={allowanceQuantityCommon} onSignSuccess={handleSignSuccess} onOpenAdvancedView={openAdvancedView} onReject={handleReject} @@ -591,19 +605,17 @@ function SignTypedDataContent({ title={Details} closeKind="icon" /> - {interpretation?.input ? ( - - ) : null} + )} /> {view === View.customAllowance ? ( ) : null} diff --git a/src/ui/pages/SignTypedData/TypedDataAdvancedView/TypedDataAdvancedView.tsx b/src/ui/pages/SignTypedData/TypedDataAdvancedView/TypedDataAdvancedView.tsx index 366cbe8e01..c1b9e37d48 100644 --- a/src/ui/pages/SignTypedData/TypedDataAdvancedView/TypedDataAdvancedView.tsx +++ b/src/ui/pages/SignTypedData/TypedDataAdvancedView/TypedDataAdvancedView.tsx @@ -1,27 +1,21 @@ import React from 'react'; -import { TextLine } from 'src/ui/components/address-action/TextLine'; import { Surface } from 'src/ui/ui-kit/Surface'; -import { VStack } from 'src/ui/ui-kit/VStack'; -import type { InterpretInput } from 'src/modules/ethereum/transactions/types'; import { PageTop } from 'src/ui/components/PageTop'; +import type { TypedData } from 'src/modules/ethereum/message-signing/TypedData'; -export function TypedDataAdvancedView({ data }: { data: InterpretInput }) { +export function TypedDataAdvancedView({ typedData }: { typedData: TypedData }) { return ( <> - - - {data.sections.flatMap(({ blocks }, index) => - blocks.map(({ name, value }) => ( - - )) - )} - + + {JSON.stringify(typedData, null, 2)} ); diff --git a/src/ui/pages/SwapForm/SwapForm.tsx b/src/ui/pages/SwapForm/SwapForm.tsx index 0119b53e73..aeea51b148 100644 --- a/src/ui/pages/SwapForm/SwapForm.tsx +++ b/src/ui/pages/SwapForm/SwapForm.tsx @@ -5,7 +5,7 @@ import { useNavigationType, useSearchParams, } from 'react-router-dom'; -import type { AddressAction, AddressPosition } from 'defi-sdk'; +import type { AddressPosition } from 'defi-sdk'; import type { EmptyAddressPosition } from '@zeriontech/transactions'; import { sortPositionsByValue } from '@zeriontech/transactions'; import React, { @@ -98,6 +98,8 @@ import { UKDisclaimer } from 'src/ui/components/UKDisclaimer/UKDisclaimer'; import { ErrorMessage } from 'src/ui/shared/error-display/ErrorMessage'; import { getError } from 'get-error'; import { PremiumFormBanner } from 'src/ui/features/premium/banners/FormBanner'; +import type { AddressAction } from 'src/modules/zerion-api/requests/wallet-get-actions'; +import { useAssetFullInfo } from 'src/modules/zerion-api/hooks/useAssetFullInfo'; import { NetworkSelect } from '../Networks/NetworkSelect'; import { TransactionConfiguration } from '../SendTransaction/TransactionConfiguration'; import { fromConfiguration, toConfiguration } from '../SendForm/shared/helpers'; @@ -515,6 +517,12 @@ function SwapFormComponent() { useEffect(() => setAllowanceBase(null), [inputAmount, inputFungibleId]); + const { data: inputFungibleUsdInfoForAnalytics } = useAssetFullInfo( + { fungibleId: inputPosition?.asset.id || '', currency: 'usd' }, + { source: useHttpClientSource() }, + { enabled: Boolean(inputPosition?.asset.id) } + ); + const { mutate: sendApproveTransaction, data: approveHash = null, @@ -527,6 +535,7 @@ function SwapFormComponent() { 'Approval transaction is not configured' ); + invariant(network, 'Network must be defined to sign the tx'); invariant(spendChain, 'Chain must be defined to sign the tx'); invariant(approveTxBtnRef.current, 'SignTransactionButton not found'); invariant(inputPosition, 'Spend position must be defined'); @@ -539,19 +548,32 @@ function SwapFormComponent() { ? await modifyApproveAmount(evmTx, allowanceBase) : evmTx; - const inputAmountBase = commonToBase( - formState.inputAmount, - getDecimals({ asset: inputPosition.asset, chain: spendChain }) - ).toFixed(); - const fallbackAddressAction = selectedQuote.transactionApprove.evm ? createApproveAddressAction({ transaction: toIncomingTransaction( selectedQuote.transactionApprove.evm ), + hash: null, + explorerUrl: null, + amount: { + currency, + quantity: formState.inputAmount, + value: inputPosition.asset.price?.value + ? new BigNumber(formState.inputAmount) + .multipliedBy(inputPosition.asset.price.value) + .toNumber() + : null, + usdValue: inputFungibleUsdInfoForAnalytics?.data?.fungible.meta + .price + ? new BigNumber(formState.inputAmount) + .multipliedBy( + inputFungibleUsdInfoForAnalytics.data.fungible.meta.price + ) + .toNumber() + : null, + }, asset: inputPosition.asset, - quantity: inputAmountBase, - chain: spendChain, + network, }) : null; @@ -656,28 +678,40 @@ function SwapFormComponent() { selectedQuote?.transactionSwap, 'Cannot submit transaction without a quote' ); - const { inputAmount } = formState; invariant(spendChain, 'Chain must be defined to sign the tx'); - invariant(inputAmount, 'inputAmount must be set'); + invariant(network, 'Network must be defined to sign the tx'); + invariant(formState.inputAmount, 'inputAmount must be set'); invariant( inputPosition && outputPosition, 'Trade positions must be defined' ); invariant(sendTxBtnRef.current, 'SignTransactionButton not found'); - const inputAmountBase = commonToBase( - inputAmount, - getDecimals({ asset: inputPosition.asset, chain: spendChain }) - ).toFixed(); - const outputAmountBase = commonToBase( - selectedQuote.outputAmount.quantity, - getDecimals({ asset: outputPosition.asset, chain: spendChain }) - ).toFixed(); const fallbackAddressAction = createTradeAddressAction({ + hash: null, address, + explorerUrl: null, + network, + rate: selectedQuote.rate, + spendAsset: inputPosition.asset, + receiveAsset: outputPosition.asset, + spendAmount: { + currency, + quantity: formState.inputAmount, + value: inputPosition.asset.price?.value + ? new BigNumber(formState.inputAmount) + .multipliedBy(inputPosition.asset.price.value) + .toNumber() + : null, + usdValue: inputFungibleUsdInfoForAnalytics?.data?.fungible.meta.price + ? new BigNumber(formState.inputAmount) + .multipliedBy( + inputFungibleUsdInfoForAnalytics.data.fungible.meta.price + ) + .toNumber() + : null, + }, + receiveAmount: selectedQuote.outputAmount, transaction: toMultichainTransaction(selectedQuote.transactionSwap), - outgoing: [{ asset: inputPosition.asset, quantity: inputAmountBase }], - incoming: [{ asset: outputPosition.asset, quantity: outputAmountBase }], - chain: spendChain, }); const txResponse = await sendTxBtnRef.current.sendTransaction({ @@ -1118,7 +1152,7 @@ function SwapFormComponent() {
- + (null); + const lastNonNullableStatus = useRef(null); if (localStatus) { lastNonNullableStatus.current = localStatus; } diff --git a/src/ui/components/TiltCard/TiltCard.tsx b/src/ui/shared/getOS.ts similarity index 100% rename from src/ui/components/TiltCard/TiltCard.tsx rename to src/ui/shared/getOS.ts diff --git a/src/ui/shared/requests/interpret.ts b/src/ui/shared/requests/interpret.ts new file mode 100644 index 0000000000..dccb397be8 --- /dev/null +++ b/src/ui/shared/requests/interpret.ts @@ -0,0 +1,114 @@ +import { valueToHex } from 'src/shared/units/valueToHex'; +import type { MultichainTransaction } from 'src/shared/types/MultichainTransaction'; +import type { BackendSourceParams } from 'src/modules/zerion-api/shared'; +import type { InterpretResponse } from 'src/modules/zerion-api/requests/wallet-simulate-transaction'; +import type { SignatureInterpretResponse } from 'src/modules/zerion-api/requests/wallet-simulate-signature'; +import { ZerionAPI } from 'src/modules/zerion-api/zerion-api.client'; +import { invariant } from 'src/shared/invariant'; +import type { TransactionEVM } from 'src/shared/types/Quote'; +import type { TypedData } from '../../../modules/ethereum/message-signing/TypedData'; +import { getGas } from '../../../modules/ethereum/transactions/getGas'; + +export function interpretTransaction( + { + address, + chain, + transaction, + origin, + currency, + }: { + address: string; + chain: string; + transaction: MultichainTransaction; + origin: string; + currency: string; + }, + { source }: BackendSourceParams +): Promise { + let normalizedEvmTx: TransactionEVM | undefined = undefined; + if (transaction.evm) { + invariant( + transaction.evm.nonce, + 'EVM transaction nonce is required for simulation' + ); + invariant( + transaction.evm.from, + 'EVM transaction from is required for simulation' + ); + invariant( + transaction.evm.to, + 'EVM transaction to is required for simulation' + ); + const gas = getGas(transaction.evm); + invariant(gas, 'EVM transaction gas is required for simulation'); + normalizedEvmTx = { + ...transaction.evm, + from: transaction.evm.from, + to: transaction.evm.to, + gas: valueToHex(gas), + gasPrice: + transaction.evm.gasPrice != null + ? valueToHex(transaction.evm.gasPrice) + : null, + chainId: valueToHex(transaction.evm.chainId), + type: + transaction.evm.type != null ? valueToHex(transaction.evm.type) : '0x2', + nonce: valueToHex(transaction.evm.nonce), + maxFee: + transaction.evm.maxFeePerGas != null + ? valueToHex(transaction.evm.maxFeePerGas) + : null, + maxPriorityFee: + transaction.evm.maxPriorityFeePerGas != null + ? valueToHex(transaction.evm.maxPriorityFeePerGas) + : null, + value: + transaction.evm.value != null + ? valueToHex(transaction.evm.value) + : '0x0', + data: transaction.evm.data != null ? transaction.evm.data : '0x', + customData: transaction.evm.customData || null, + }; + } + return ZerionAPI.walletSimulateTransaction( + { + address, + chain, + currency, + domain: origin, + transaction: { + evm: normalizedEvmTx, + solana: transaction.solana, + }, + }, + { source } + ); +} + +export function interpretSignature( + { + address, + chain, + typedData, + currency, + origin, + }: { + address: string; + chain: string; + typedData: TypedData; + currency: string; + origin: string; + }, + { source }: BackendSourceParams +): Promise { + return ZerionAPI.walletSimulateSignature( + { + address, + chain, + currency, + domain: origin, + signature: { typedData }, + }, + { source } + ); +} diff --git a/src/ui/shared/requests/uiInterpretTransaction.ts b/src/ui/shared/requests/uiInterpretTransaction.ts index d3ba4a538a..16cc0696f1 100644 --- a/src/ui/shared/requests/uiInterpretTransaction.ts +++ b/src/ui/shared/requests/uiInterpretTransaction.ts @@ -1,8 +1,8 @@ -import { hashQueryKey, useQuery } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { interpretSignature, interpretTransaction, -} from 'src/modules/ethereum/transactions/interpret'; +} from 'src/ui/shared/requests/interpret'; import { getNetworksStore } from 'src/modules/networks/networks-store.client'; import { invariant } from 'src/shared/invariant'; import { normalizeChainId } from 'src/shared/normalizeChainId'; @@ -11,15 +11,12 @@ import { usePreferences, } from 'src/ui/features/preferences/usePreferences'; import { fetchAndAssignPaymaster } from 'src/modules/ethereum/account-abstraction/fetchAndAssignPaymaster'; -import { Client } from 'defi-sdk'; import { ZerionAPI } from 'src/modules/zerion-api/zerion-api.client'; -import { useDefiSdkClient } from 'src/modules/defi-sdk/useDefiSdkClient'; import { useCurrency } from 'src/modules/currency/useCurrency'; import type { EligibilityQuery } from 'src/ui/components/address-action/EligibilityQuery'; import type { MultichainTransaction } from 'src/shared/types/MultichainTransaction'; import type { NetworkConfig } from 'src/modules/networks/NetworkConfig'; import { NetworkId } from 'src/modules/networks/NetworkId'; -import { createChain } from 'src/modules/networks/Chain'; import { walletPort } from '../channels'; /** @@ -33,7 +30,6 @@ export async function interpretTxBasedOnEligibility({ eligibilityQueryStatus, currency, origin, - client, }: { address: string; transaction: MultichainTransaction; @@ -41,7 +37,6 @@ export async function interpretTxBasedOnEligibility({ eligibilityQueryStatus: 'error' | 'success' | 'loading'; currency: string; origin: string; - client: Client; }) { const preferences = await getPreferences(); const source = preferences?.testnetMode?.on ? 'testnet' : 'mainnet'; @@ -70,14 +65,16 @@ export async function interpretTxBasedOnEligibility({ eligibilityQueryStatus === 'error'; if (shouldDoRegularInterpret) { - return interpretTransaction({ - address, - chain: createChain(network.id), - transaction, - origin, - currency, - client, - }); + return interpretTransaction( + { + address, + chain: network.id, + transaction, + origin, + currency, + }, + { source } + ); } else if (network.supports_sponsored_transactions && eligibilityQueryData) { invariant( transaction.evm, @@ -90,14 +87,16 @@ export async function interpretTxBasedOnEligibility({ const typedData = await walletPort.request('uiGetEip712Transaction', { transaction: toSign, }); - return interpretSignature({ - address, - chainId: normalizeChainId(toSign.chainId), - typedData, - currency, - origin, - client, - }); + return interpretSignature( + { + address, + chain: network.id, + typedData, + currency, + origin, + }, + { source } + ); } else { return null; } @@ -114,7 +113,6 @@ export function useInterpretTxBasedOnEligibility({ eligibilityQuery: EligibilityQuery; origin: string; }) { - const client = useDefiSdkClient(); const { currency } = useCurrency(); const { preferences } = usePreferences(); const source = preferences?.testnetMode?.on ? 'testnet' : 'mainnet'; @@ -130,7 +128,6 @@ export function useInterpretTxBasedOnEligibility({ queryKey: [ 'interpretSignature', address, - client, currency, transaction, source, @@ -138,10 +135,6 @@ export function useInterpretTxBasedOnEligibility({ eligibilityQuery.data?.data.eligible, eligibilityQuery.status, ], - queryKeyHashFn: (queryKey) => { - const key = queryKey.map((x) => (x instanceof Client ? x.url : x)); - return hashQueryKey(key); - }, queryFn: () => { return interpretTxBasedOnEligibility({ address, @@ -150,7 +143,6 @@ export function useInterpretTxBasedOnEligibility({ eligibilityQueryStatus: eligibilityQuery.status, currency, origin, - client, }); }, }); diff --git a/src/ui/shared/security-check/InterpertationSecurityCheck.tsx b/src/ui/shared/security-check/InterpertationSecurityCheck.tsx index 3838d87f3a..cdc1302910 100644 --- a/src/ui/shared/security-check/InterpertationSecurityCheck.tsx +++ b/src/ui/shared/security-check/InterpertationSecurityCheck.tsx @@ -1,9 +1,5 @@ import React, { useMemo, useRef } from 'react'; import { Content } from 'react-area'; -import type { - InterpretResponse, - WarningSeverity, -} from 'src/modules/ethereum/transactions/types'; import { DelayedRender } from 'src/ui/components/DelayedRender'; import { PortalToRootNode } from 'src/ui/components/PortalToRootNode'; import { TransactionWarning } from 'src/ui/pages/SendTransaction/TransactionWarnings/TransactionWarning'; @@ -16,6 +12,11 @@ import { VStack } from 'src/ui/ui-kit/VStack'; import { ZStack } from 'src/ui/ui-kit/ZStack'; import ShieldIcon from 'jsx:src/ui/assets/shield-filled.svg'; import WarningIcon from 'jsx:src/ui/assets/warning-triangle.svg'; +import type { + InterpretResponse, + Warning, + WarningSeverity, +} from 'src/modules/zerion-api/requests/wallet-simulate-transaction'; import { SecurityStatusButton } from './SecurityStatusButton'; import type { SecurityButtonKind } from './SecurityStatusButton'; @@ -26,10 +27,7 @@ const WarningSeverityPriority: Record = { Red: 3, }; -function warningComparator( - a: InterpretResponse['warnings'][0], - b: InterpretResponse['warnings'][0] -) { +function warningComparator(a: Warning, b: Warning) { // We have fallback value here in case backend introduces new severity value return ( (WarningSeverityPriority[b.severity] || 0) - @@ -37,16 +35,14 @@ function warningComparator( ); } -function sortWarnings(warnings?: InterpretResponse['warnings']) { +function sortWarnings(warnings?: Warning[]) { if (!warnings) { return null; } return warnings.sort(warningComparator); } -export function hasCriticalWarning( - warnings?: InterpretResponse['warnings'] | null -) { +export function hasCriticalWarning(warnings?: Warning[] | null) { if (!warnings) { return false; } @@ -204,7 +200,7 @@ export function InterpretationSecurityCheck({ : 'success'; const warnings = useMemo( - () => sortWarnings(interpretation?.warnings), + () => sortWarnings(interpretation?.data.warnings), [interpretation] ); const warningSeverity = warnings?.at(0)?.severity;