From 1ed70233fe0ca1e7d81f63dbe44caa220c67b11e Mon Sep 17 00:00:00 2001 From: Javier Bueno Date: Tue, 17 Sep 2024 19:05:50 +0200 Subject: [PATCH 001/113] feature(tx-review): initial commit --- .../src/components/Icon/Direction.tsx | 7 +- .../src/components/Warning/Warning.tsx | 16 +- .../ReviewTransaction/ReviewTransaction.tsx | 26 +- .../useCases/ReviewTransaction/types.ts | 846 +++++++++++++++++ .../ReviewTransactionNavigator.tsx | 39 + .../ReviewTransaction/common/Divider.tsx | 21 + .../common/formattedTransaction.tsx | 122 +++ .../ReviewTransaction/common/mocks.ts | 148 +++ .../ReviewTransaction/common/types.ts | 855 ++++++++++++++++++ .../Overview/OverviewTab.tsx | 469 ++++++++++ .../ReviewTransactionScreen.tsx | 59 ++ .../useCases/ConfirmTx/ConfirmTxScreen.tsx | 8 + .../Transactions/TxHistoryNavigator.tsx | 5 +- apps/wallet-mobile/src/kernel/navigation.tsx | 10 + .../src/yoroi-wallets/hooks/index.ts | 15 + .../messages/src/WalletNavigator.json | 48 +- .../Transactions/TxHistoryNavigator.json | 176 ++-- .../theme/src/base-palettes/dark-palette.ts | 1 + .../theme/src/base-palettes/light-palette.ts | 1 + packages/theme/src/types.ts | 1 + 20 files changed, 2736 insertions(+), 137 deletions(-) create mode 100644 apps/wallet-mobile/src/features/Discover/useCases/ReviewTransaction/types.ts create mode 100644 apps/wallet-mobile/src/features/ReviewTransaction/ReviewTransactionNavigator.tsx create mode 100644 apps/wallet-mobile/src/features/ReviewTransaction/common/Divider.tsx create mode 100644 apps/wallet-mobile/src/features/ReviewTransaction/common/formattedTransaction.tsx create mode 100644 apps/wallet-mobile/src/features/ReviewTransaction/common/mocks.ts create mode 100644 apps/wallet-mobile/src/features/ReviewTransaction/common/types.ts create mode 100644 apps/wallet-mobile/src/features/ReviewTransaction/useCases/ReviewTransactionScreen/Overview/OverviewTab.tsx create mode 100644 apps/wallet-mobile/src/features/ReviewTransaction/useCases/ReviewTransactionScreen/ReviewTransactionScreen.tsx diff --git a/apps/wallet-mobile/src/components/Icon/Direction.tsx b/apps/wallet-mobile/src/components/Icon/Direction.tsx index a704140b92..45c37b73ea 100644 --- a/apps/wallet-mobile/src/components/Icon/Direction.tsx +++ b/apps/wallet-mobile/src/components/Icon/Direction.tsx @@ -1,6 +1,6 @@ import {ThemedPalette, useTheme} from '@yoroi/theme' import React from 'react' -import {StyleSheet, View} from 'react-native' +import {StyleSheet, View, ViewStyle} from 'react-native' import {TransactionDirection, TransactionInfo} from '../../yoroi-wallets/types' import {Received} from '../Icon/Received' @@ -11,9 +11,10 @@ import {MultiParty} from './MultiParty' type Props = { transaction: TransactionInfo size?: number + containerStyle?: ViewStyle } -export const Direction = ({transaction, size = defaultSize}: Props) => { +export const Direction = ({transaction, size = defaultSize, containerStyle}: Props) => { const {color} = useTheme() const {direction} = transaction @@ -21,7 +22,7 @@ export const Direction = ({transaction, size = defaultSize}: Props) => { const IconComponent = iconMap[direction] return ( - + ) diff --git a/apps/wallet-mobile/src/components/Warning/Warning.tsx b/apps/wallet-mobile/src/components/Warning/Warning.tsx index 320ee76732..44c27f3558 100644 --- a/apps/wallet-mobile/src/components/Warning/Warning.tsx +++ b/apps/wallet-mobile/src/components/Warning/Warning.tsx @@ -5,14 +5,18 @@ import {StyleSheet, Text, View} from 'react-native' import {Icon} from '../Icon' import {Space} from '../Space/Space' -type Props = {content: ReactNode; iconSize?: number} +type Props = { + content: ReactNode + iconSize?: number + blue?: boolean +} -export const Warning = ({content, iconSize = 30}: Props) => { +export const Warning = ({content, iconSize = 30, blue = false}: Props) => { const {styles, colors} = useStyles() return ( - - + + @@ -29,6 +33,9 @@ const useStyles = () => { padding: 12, borderRadius: 8, }, + blueNotice: { + backgroundColor: color.sys_cyan_100, + }, text: { ...atoms.body_2_md_regular, color: color.gray_max, @@ -37,6 +44,7 @@ const useStyles = () => { const colors = { yellow: color.sys_orange_500, + blue: color.primary_500, } return {colors, styles} diff --git a/apps/wallet-mobile/src/features/Discover/useCases/ReviewTransaction/ReviewTransaction.tsx b/apps/wallet-mobile/src/features/Discover/useCases/ReviewTransaction/ReviewTransaction.tsx index a56aa691b5..ea9f350d73 100644 --- a/apps/wallet-mobile/src/features/Discover/useCases/ReviewTransaction/ReviewTransaction.tsx +++ b/apps/wallet-mobile/src/features/Discover/useCases/ReviewTransaction/ReviewTransaction.tsx @@ -22,6 +22,7 @@ import {useSelectedWallet} from '../../../WalletManager/common/hooks/useSelected import {useConfirmHWConnectionModal} from '../../common/ConfirmHWConnectionModal' import {usePromptRootKey} from '../../common/hooks' import {useStrings} from '../../common/useStrings' +import {TransactionBodyJSON, TransactionJSON} from './types' export type ReviewTransactionParams = | { @@ -45,7 +46,10 @@ export const ReviewTransaction = () => { const [outputsOpen, setOutputsOpen] = React.useState(true) const [scrollbarShown, setScrollbarShown] = React.useState(false) const strings = useStrings() - const formattedTX = useFormattedTransaction(params.cbor) + const {data} = useTxDetails(params.cbor) + + if (!data) throw new Error('') + const formattedTX = useFormattedTransaction(data.body) const {styles} = useStyles() @@ -163,16 +167,7 @@ const paramsSchema = z.union([ const isParams = createTypeGuardFromSchema(paramsSchema) -type TxDetails = { - body: { - inputs: Array<{transaction_id: string; index: number}> - outputs: Array<{address: string; amount: {coin: number; multiasset: null | Record>}}> - fee: string - ttl: string - } -} - -const getTxDetails = async (cbor: string): Promise => { +const getTxDetails = async (cbor: string): Promise => { const {csl, release} = wrappedCsl() try { const tx = await csl.Transaction.fromHex(cbor) @@ -187,12 +182,11 @@ const useTxDetails = (cbor: string) => { return useQuery({queryFn: () => getTxDetails(cbor), useErrorBoundary: true, queryKey: ['useTxDetails', cbor]}) } -const useFormattedTransaction = (cbor: string) => { +export const useFormattedTransaction = (data: TransactionBodyJSON) => { const {wallet} = useSelectedWallet() - const {data} = useTxDetails(cbor) - const inputs = data?.body.inputs ?? [] - const outputs = data?.body.outputs ?? [] + const inputs = data?.inputs ?? [] + const outputs = data?.outputs ?? [] const getUtxoByTxIdAndIndex = (txId: string, index: number) => { return wallet.utxos.find((u) => u.tx_hash === txId && u.tx_index === index) @@ -265,7 +259,7 @@ const useFormattedTransaction = (cbor: string) => { return {assets, address, ownAddress: address != null && isOwnedAddress(address)} }) - const formattedFee = formatAdaWithText(asQuantity(data?.body?.fee ?? '0'), wallet.primaryToken) + const formattedFee = formatAdaWithText(asQuantity(data?.fee ?? '0'), wallet.primaryToken) return {inputs: formattedInputs, outputs: formattedOutputs, fee: formattedFee} } diff --git a/apps/wallet-mobile/src/features/Discover/useCases/ReviewTransaction/types.ts b/apps/wallet-mobile/src/features/Discover/useCases/ReviewTransaction/types.ts new file mode 100644 index 0000000000..c65b394738 --- /dev/null +++ b/apps/wallet-mobile/src/features/Discover/useCases/ReviewTransaction/types.ts @@ -0,0 +1,846 @@ +export type AddressJSON = string +export type URLJSON = string + +export interface AnchorJSON { + anchor_data_hash: string + anchor_url: URLJSON +} +export type AnchorDataHashJSON = string +export type AssetNameJSON = string +export type AssetNamesJSON = string[] +export interface AssetsJSON { + [k: string]: string +} +export type NativeScriptJSON = + | { + ScriptPubkey: ScriptPubkeyJSON + } + | { + ScriptAll: ScriptAllJSON + } + | { + ScriptAny: ScriptAnyJSON + } + | { + ScriptNOfK: ScriptNOfKJSON + } + | { + TimelockStart: TimelockStartJSON + } + | { + TimelockExpiry: TimelockExpiryJSON + } +export type NativeScriptsJSON = NativeScriptJSON[] +export type PlutusScriptsJSON = string[] + +export interface AuxiliaryDataJSON { + metadata?: { + [k: string]: string + } | null + native_scripts?: NativeScriptsJSON | null + plutus_scripts?: PlutusScriptsJSON | null + prefer_alonzo_format: boolean +} +export interface ScriptPubkeyJSON { + addr_keyhash: string +} +export interface ScriptAllJSON { + native_scripts: NativeScriptsJSON +} +export interface ScriptAnyJSON { + native_scripts: NativeScriptsJSON +} +export interface ScriptNOfKJSON { + n: number + native_scripts: NativeScriptsJSON +} +export interface TimelockStartJSON { + slot: string +} +export interface TimelockExpiryJSON { + slot: string +} +export type AuxiliaryDataHashJSON = string +export interface AuxiliaryDataSetJSON { + [k: string]: AuxiliaryDataJSON +} +export type BigIntJSON = string +export type BigNumJSON = string +export type VkeyJSON = string +export type HeaderLeaderCertEnumJSON = + | { + /** + * @minItems 2 + * @maxItems 2 + */ + NonceAndLeader: [VRFCertJSON, VRFCertJSON] + } + | { + VrfResult: VRFCertJSON + } +export type CertificateJSON = + | { + StakeRegistration: StakeRegistrationJSON + } + | { + StakeDeregistration: StakeDeregistrationJSON + } + | { + StakeDelegation: StakeDelegationJSON + } + | { + PoolRegistration: PoolRegistrationJSON + } + | { + PoolRetirement: PoolRetirementJSON + } + | { + GenesisKeyDelegation: GenesisKeyDelegationJSON + } + | { + MoveInstantaneousRewardsCert: MoveInstantaneousRewardsCertJSON + } + | { + CommitteeHotAuth: CommitteeHotAuthJSON + } + | { + CommitteeColdResign: CommitteeColdResignJSON + } + | { + DRepDeregistration: DRepDeregistrationJSON + } + | { + DRepRegistration: DRepRegistrationJSON + } + | { + DRepUpdate: DRepUpdateJSON + } + | { + StakeAndVoteDelegation: StakeAndVoteDelegationJSON + } + | { + StakeRegistrationAndDelegation: StakeRegistrationAndDelegationJSON + } + | { + StakeVoteRegistrationAndDelegation: StakeVoteRegistrationAndDelegationJSON + } + | { + VoteDelegation: VoteDelegationJSON + } + | { + VoteRegistrationAndDelegation: VoteRegistrationAndDelegationJSON + } +export type CredTypeJSON = + | { + Key: string + } + | { + Script: string + } +export type RelayJSON = + | { + SingleHostAddr: SingleHostAddrJSON + } + | { + SingleHostName: SingleHostNameJSON + } + | { + MultiHostName: MultiHostNameJSON + } +/** + * @minItems 4 + * @maxItems 4 + */ +export type Ipv4JSON = [number, number, number, number] +/** + * @minItems 16 + * @maxItems 16 + */ +export type Ipv6JSON = [ + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, +] +export type DNSRecordAorAAAAJSON = string +export type DNSRecordSRVJSON = string +export type RelaysJSON = RelayJSON[] +export type MIRPotJSON = 'Reserves' | 'Treasury' +export type MIREnumJSON = + | { + ToOtherPot: string + } + | { + ToStakeCredentials: StakeToCoinJSON[] + } +export type DRepJSON = + | ('AlwaysAbstain' | 'AlwaysNoConfidence') + | { + KeyHash: string + } + | { + ScriptHash: string + } +export type DataOptionJSON = + | { + DataHash: string + } + | { + Data: string + } +export type ScriptRefJSON = + | { + NativeScript: NativeScriptJSON + } + | { + PlutusScript: string + } +export type MintJSON = [string, MintAssetsJSON][] +export type NetworkIdJSON = 'Testnet' | 'Mainnet' +export type TransactionOutputsJSON = TransactionOutputJSON[] +export type CostModelJSON = string[] +export type VoterJSON = + | { + ConstitutionalCommitteeHotCred: CredTypeJSON + } + | { + DRep: CredTypeJSON + } + | { + StakingPool: string + } +export type VoteKindJSON = 'No' | 'Yes' | 'Abstain' +export type GovernanceActionJSON = + | { + ParameterChangeAction: ParameterChangeActionJSON + } + | { + HardForkInitiationAction: HardForkInitiationActionJSON + } + | { + TreasuryWithdrawalsAction: TreasuryWithdrawalsActionJSON + } + | { + NoConfidenceAction: NoConfidenceActionJSON + } + | { + UpdateCommitteeAction: UpdateCommitteeActionJSON + } + | { + NewConstitutionAction: NewConstitutionActionJSON + } + | { + InfoAction: InfoActionJSON + } +/** + * @minItems 0 + * @maxItems 0 + */ +export type InfoActionJSON = [] +export type TransactionBodiesJSON = TransactionBodyJSON[] +export type RedeemerTagJSON = 'Spend' | 'Mint' | 'Cert' | 'Reward' | 'Vote' | 'VotingProposal' +export type TransactionWitnessSetsJSON = TransactionWitnessSetJSON[] + +export interface BlockJSON { + auxiliary_data_set: { + [k: string]: AuxiliaryDataJSON + } + header: HeaderJSON + invalid_transactions: number[] + transaction_bodies: TransactionBodiesJSON + transaction_witness_sets: TransactionWitnessSetsJSON +} +export interface HeaderJSON { + body_signature: string + header_body: HeaderBodyJSON +} +export interface HeaderBodyJSON { + block_body_hash: string + block_body_size: number + block_number: number + issuer_vkey: VkeyJSON + leader_cert: HeaderLeaderCertEnumJSON + operational_cert: OperationalCertJSON + prev_hash?: string | null + protocol_version: ProtocolVersionJSON + slot: string + vrf_vkey: string +} +export interface VRFCertJSON { + output: number[] + proof: number[] +} +export interface OperationalCertJSON { + hot_vkey: string + kes_period: number + sequence_number: number + sigma: string +} +export interface ProtocolVersionJSON { + major: number + minor: number +} +export interface TransactionBodyJSON { + auxiliary_data_hash?: string | null + certs?: CertificateJSON[] | null + collateral?: TransactionInputJSON[] | null + collateral_return?: TransactionOutputJSON | null + current_treasury_value?: string | null + donation?: string | null + fee: string + inputs: TransactionInputJSON[] + mint?: MintJSON | null + network_id?: NetworkIdJSON | null + outputs: TransactionOutputsJSON + reference_inputs?: TransactionInputJSON[] | null + required_signers?: string[] | null + script_data_hash?: string | null + total_collateral?: string | null + ttl?: string | null + update?: UpdateJSON | null + validity_start_interval?: string | null + voting_procedures?: VoterVotesJSON[] | null + voting_proposals?: VotingProposalJSON[] | null + withdrawals?: { + [k: string]: string + } | null +} +export interface StakeRegistrationJSON { + coin?: string | null + stake_credential: CredTypeJSON +} +export interface StakeDeregistrationJSON { + coin?: string | null + stake_credential: CredTypeJSON +} +export interface StakeDelegationJSON { + pool_keyhash: string + stake_credential: CredTypeJSON +} +export interface PoolRegistrationJSON { + pool_params: PoolParamsJSON +} +export interface PoolParamsJSON { + cost: string + margin: UnitIntervalJSON + operator: string + pledge: string + pool_metadata?: PoolMetadataJSON | null + pool_owners: string[] + relays: RelaysJSON + reward_account: string + vrf_keyhash: string +} +export interface UnitIntervalJSON { + denominator: string + numerator: string +} +export interface PoolMetadataJSON { + pool_metadata_hash: string + url: URLJSON +} +export interface SingleHostAddrJSON { + ipv4?: Ipv4JSON | null + ipv6?: Ipv6JSON | null + port?: number | null +} +export interface SingleHostNameJSON { + dns_name: DNSRecordAorAAAAJSON + port?: number | null +} +export interface MultiHostNameJSON { + dns_name: DNSRecordSRVJSON +} +export interface PoolRetirementJSON { + epoch: number + pool_keyhash: string +} +export interface GenesisKeyDelegationJSON { + genesis_delegate_hash: string + genesishash: string + vrf_keyhash: string +} +export interface MoveInstantaneousRewardsCertJSON { + move_instantaneous_reward: MoveInstantaneousRewardJSON +} +export interface MoveInstantaneousRewardJSON { + pot: MIRPotJSON + variant: MIREnumJSON +} +export interface StakeToCoinJSON { + amount: string + stake_cred: CredTypeJSON +} +export interface CommitteeHotAuthJSON { + committee_cold_credential: CredTypeJSON + committee_hot_credential: CredTypeJSON +} +export interface CommitteeColdResignJSON { + anchor?: AnchorJSON | null + committee_cold_credential: CredTypeJSON +} +export interface DRepDeregistrationJSON { + coin: string + voting_credential: CredTypeJSON +} +export interface DRepRegistrationJSON { + anchor?: AnchorJSON | null + coin: string + voting_credential: CredTypeJSON +} +export interface DRepUpdateJSON { + anchor?: AnchorJSON | null + voting_credential: CredTypeJSON +} +export interface StakeAndVoteDelegationJSON { + drep: DRepJSON + pool_keyhash: string + stake_credential: CredTypeJSON +} +export interface StakeRegistrationAndDelegationJSON { + coin: string + pool_keyhash: string + stake_credential: CredTypeJSON +} +export interface StakeVoteRegistrationAndDelegationJSON { + coin: string + drep: DRepJSON + pool_keyhash: string + stake_credential: CredTypeJSON +} +export interface VoteDelegationJSON { + drep: DRepJSON + stake_credential: CredTypeJSON +} +export interface VoteRegistrationAndDelegationJSON { + coin: string + drep: DRepJSON + stake_credential: CredTypeJSON +} +export interface TransactionInputJSON { + index: number + transaction_id: string +} +export interface TransactionOutputJSON { + address: string + amount: ValueJSON + plutus_data?: DataOptionJSON | null + script_ref?: ScriptRefJSON | null +} +export interface ValueJSON { + coin: string + multiasset?: MultiAssetJSON | null +} +export interface MultiAssetJSON { + [k: string]: AssetsJSON +} +export interface MintAssetsJSON { + [k: string]: string +} +export interface UpdateJSON { + epoch: number + proposed_protocol_parameter_updates: { + [k: string]: ProtocolParamUpdateJSON + } +} +export interface ProtocolParamUpdateJSON { + ada_per_utxo_byte?: string | null + collateral_percentage?: number | null + committee_term_limit?: number | null + cost_models?: CostmdlsJSON | null + d?: UnitIntervalJSON | null + drep_deposit?: string | null + drep_inactivity_period?: number | null + drep_voting_thresholds?: DRepVotingThresholdsJSON | null + execution_costs?: ExUnitPricesJSON | null + expansion_rate?: UnitIntervalJSON | null + extra_entropy?: NonceJSON | null + governance_action_deposit?: string | null + governance_action_validity_period?: number | null + key_deposit?: string | null + max_block_body_size?: number | null + max_block_ex_units?: ExUnitsJSON | null + max_block_header_size?: number | null + max_collateral_inputs?: number | null + max_epoch?: number | null + max_tx_ex_units?: ExUnitsJSON | null + max_tx_size?: number | null + max_value_size?: number | null + min_committee_size?: number | null + min_pool_cost?: string | null + minfee_a?: string | null + minfee_b?: string | null + n_opt?: number | null + pool_deposit?: string | null + pool_pledge_influence?: UnitIntervalJSON | null + pool_voting_thresholds?: PoolVotingThresholdsJSON | null + protocol_version?: ProtocolVersionJSON | null + ref_script_coins_per_byte?: UnitIntervalJSON | null + treasury_growth_rate?: UnitIntervalJSON | null +} +export interface CostmdlsJSON { + [k: string]: CostModelJSON +} +export interface DRepVotingThresholdsJSON { + committee_no_confidence: UnitIntervalJSON + committee_normal: UnitIntervalJSON + hard_fork_initiation: UnitIntervalJSON + motion_no_confidence: UnitIntervalJSON + pp_economic_group: UnitIntervalJSON + pp_governance_group: UnitIntervalJSON + pp_network_group: UnitIntervalJSON + pp_technical_group: UnitIntervalJSON + treasury_withdrawal: UnitIntervalJSON + update_constitution: UnitIntervalJSON +} +export interface ExUnitPricesJSON { + mem_price: UnitIntervalJSON + step_price: UnitIntervalJSON +} +export interface NonceJSON { + /** + * @minItems 32 + * @maxItems 32 + */ + hash?: + | [ + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + ] + | null +} +export interface ExUnitsJSON { + mem: string + steps: string +} +export interface PoolVotingThresholdsJSON { + committee_no_confidence: UnitIntervalJSON + committee_normal: UnitIntervalJSON + hard_fork_initiation: UnitIntervalJSON + motion_no_confidence: UnitIntervalJSON + security_relevant_threshold: UnitIntervalJSON +} +export interface VoterVotesJSON { + voter: VoterJSON + votes: VoteJSON[] +} +export interface VoteJSON { + action_id: GovernanceActionIdJSON + voting_procedure: VotingProcedureJSON +} +export interface GovernanceActionIdJSON { + index: number + transaction_id: string +} +export interface VotingProcedureJSON { + anchor?: AnchorJSON | null + vote: VoteKindJSON +} +export interface VotingProposalJSON { + anchor: AnchorJSON + deposit: string + governance_action: GovernanceActionJSON + reward_account: string +} +export interface ParameterChangeActionJSON { + gov_action_id?: GovernanceActionIdJSON | null + policy_hash?: string | null + protocol_param_updates: ProtocolParamUpdateJSON +} +export interface HardForkInitiationActionJSON { + gov_action_id?: GovernanceActionIdJSON | null + protocol_version: ProtocolVersionJSON +} +export interface TreasuryWithdrawalsActionJSON { + policy_hash?: string | null + withdrawals: TreasuryWithdrawalsJSON +} +export interface TreasuryWithdrawalsJSON { + [k: string]: string +} +export interface NoConfidenceActionJSON { + gov_action_id?: GovernanceActionIdJSON | null +} +export interface UpdateCommitteeActionJSON { + committee: CommitteeJSON + gov_action_id?: GovernanceActionIdJSON | null + members_to_remove: CredTypeJSON[] +} +export interface CommitteeJSON { + members: CommitteeMemberJSON[] + quorum_threshold: UnitIntervalJSON +} +export interface CommitteeMemberJSON { + stake_credential: CredTypeJSON + term_limit: number +} +export interface NewConstitutionActionJSON { + constitution: ConstitutionJSON + gov_action_id?: GovernanceActionIdJSON | null +} +export interface ConstitutionJSON { + anchor: AnchorJSON + script_hash?: string | null +} +export interface TransactionWitnessSetJSON { + bootstraps?: BootstrapWitnessJSON[] | null + native_scripts?: NativeScriptsJSON | null + plutus_data?: PlutusListJSON | null + plutus_scripts?: PlutusScriptsJSON | null + redeemers?: RedeemerJSON[] | null + vkeys?: VkeywitnessJSON[] | null +} +export interface BootstrapWitnessJSON { + attributes: number[] + chain_code: number[] + signature: string + vkey: VkeyJSON +} +export interface PlutusListJSON { + definite_encoding?: boolean | null + elems: string[] +} +export interface RedeemerJSON { + data: string + ex_units: ExUnitsJSON + index: string + tag: RedeemerTagJSON +} +export interface VkeywitnessJSON { + signature: string + vkey: VkeyJSON +} +export type BlockHashJSON = string +export type BootstrapWitnessesJSON = BootstrapWitnessJSON[] + +export type CertificateEnumJSON = + | { + StakeRegistration: StakeRegistrationJSON + } + | { + StakeDeregistration: StakeDeregistrationJSON + } + | { + StakeDelegation: StakeDelegationJSON + } + | { + PoolRegistration: PoolRegistrationJSON + } + | { + PoolRetirement: PoolRetirementJSON + } + | { + GenesisKeyDelegation: GenesisKeyDelegationJSON + } + | { + MoveInstantaneousRewardsCert: MoveInstantaneousRewardsCertJSON + } + | { + CommitteeHotAuth: CommitteeHotAuthJSON + } + | { + CommitteeColdResign: CommitteeColdResignJSON + } + | { + DRepDeregistration: DRepDeregistrationJSON + } + | { + DRepRegistration: DRepRegistrationJSON + } + | { + DRepUpdate: DRepUpdateJSON + } + | { + StakeAndVoteDelegation: StakeAndVoteDelegationJSON + } + | { + StakeRegistrationAndDelegation: StakeRegistrationAndDelegationJSON + } + | { + StakeVoteRegistrationAndDelegation: StakeVoteRegistrationAndDelegationJSON + } + | { + VoteDelegation: VoteDelegationJSON + } + | { + VoteRegistrationAndDelegation: VoteRegistrationAndDelegationJSON + } +export type CertificatesJSON = CertificateJSON[] + +export type CredentialJSON = CredTypeJSON +export type CredentialsJSON = CredTypeJSON[] +export type DRepEnumJSON = + | ('AlwaysAbstain' | 'AlwaysNoConfidence') + | { + KeyHash: string + } + | { + ScriptHash: string + } +export type DataHashJSON = string +export type Ed25519KeyHashJSON = string +export type Ed25519KeyHashesJSON = string[] +export type Ed25519SignatureJSON = string +export interface GeneralTransactionMetadataJSON { + [k: string]: string +} +export type GenesisDelegateHashJSON = string +export type GenesisHashJSON = string +export type GenesisHashesJSON = string[] +export type GovernanceActionEnumJSON = + | { + ParameterChangeAction: ParameterChangeActionJSON + } + | { + HardForkInitiationAction: HardForkInitiationActionJSON + } + | { + TreasuryWithdrawalsAction: TreasuryWithdrawalsActionJSON + } + | { + NoConfidenceAction: NoConfidenceActionJSON + } + | { + UpdateCommitteeAction: UpdateCommitteeActionJSON + } + | { + NewConstitutionAction: NewConstitutionActionJSON + } + | { + InfoAction: InfoActionJSON + } +export type GovernanceActionIdsJSON = GovernanceActionIdJSON[] + +export type IntJSON = string +/** + * @minItems 4 + * @maxItems 4 + */ +export type KESVKeyJSON = string +export type LanguageJSON = LanguageKindJSON +export type LanguageKindJSON = 'PlutusV1' | 'PlutusV2' | 'PlutusV3' +export type LanguagesJSON = LanguageJSON[] +export type MIRToStakeCredentialsJSON = StakeToCoinJSON[] + +export type MintsAssetsJSON = MintAssetsJSON[] + +export type NetworkIdKindJSON = 'Testnet' | 'Mainnet' +export type PlutusScriptJSON = string +export type PoolMetadataHashJSON = string +export interface ProposedProtocolParameterUpdatesJSON { + [k: string]: ProtocolParamUpdateJSON +} +export type PublicKeyJSON = string +export type RedeemerTagKindJSON = 'Spend' | 'Mint' | 'Cert' | 'Reward' | 'Vote' | 'VotingProposal' +export type RedeemersJSON = RedeemerJSON[] + +export type RelayEnumJSON = + | { + SingleHostAddr: SingleHostAddrJSON + } + | { + SingleHostName: SingleHostNameJSON + } + | { + MultiHostName: MultiHostNameJSON + } +/** + * @minItems 4 + * @maxItems 4 + */ +export type RewardAddressJSON = string +export type RewardAddressesJSON = string[] +export type ScriptDataHashJSON = string +export type ScriptHashJSON = string +export type ScriptHashesJSON = string[] +export type ScriptRefEnumJSON = + | { + NativeScript: NativeScriptJSON + } + | { + PlutusScript: string + } +export interface TransactionJSON { + auxiliary_data?: AuxiliaryDataJSON | null + body: TransactionBodyJSON + is_valid: boolean + witness_set: TransactionWitnessSetJSON +} +export type TransactionHashJSON = string +export type TransactionInputsJSON = TransactionInputJSON[] + +export type TransactionMetadatumJSON = string +export interface TransactionUnspentOutputJSON { + input: TransactionInputJSON + output: TransactionOutputJSON +} +export type TransactionUnspentOutputsJSON = TransactionUnspentOutputJSON[] + +export type VRFKeyHashJSON = string +export type VRFVKeyJSON = string +export interface VersionedBlockJSON { + block: BlockJSON + era_code: number +} +export type VkeywitnessesJSON = VkeywitnessJSON[] + +export type VoterEnumJSON = + | { + ConstitutionalCommitteeHotCred: CredTypeJSON + } + | { + DRep: CredTypeJSON + } + | { + StakingPool: string + } +export type VotersJSON = VoterJSON[] +export type VotingProceduresJSON = VoterVotesJSON[] + +export type VotingProposalsJSON = VotingProposalJSON[] + +export interface WithdrawalsJSON { + [k: string]: string +} diff --git a/apps/wallet-mobile/src/features/ReviewTransaction/ReviewTransactionNavigator.tsx b/apps/wallet-mobile/src/features/ReviewTransaction/ReviewTransactionNavigator.tsx new file mode 100644 index 0000000000..c2c50d55b4 --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTransaction/ReviewTransactionNavigator.tsx @@ -0,0 +1,39 @@ +import {createStackNavigator} from '@react-navigation/stack' +import {useTheme} from '@yoroi/theme' +import * as React from 'react' +import {StyleSheet} from 'react-native' + +import {KeyboardAvoidingView} from '../../components' +import {defaultStackNavigationOptions, ReviewTransactionRoutes} from '../../kernel/navigation' +import {ReviewTransactionScreen} from './useCases/ReviewTransactionScreen/ReviewTransactionScreen' + +const Stack = createStackNavigator() + +export const ReviewTransactionNavigator = () => { + const {atoms, color} = useTheme() + const styles = useStyles() + + return ( + + + + + + ) +} + +const useStyles = () => { + const {color, atoms} = useTheme() + const styles = StyleSheet.create({ + root: { + ...atoms.flex_1, + backgroundColor: color.bg_color_max, + }, + }) + return styles +} diff --git a/apps/wallet-mobile/src/features/ReviewTransaction/common/Divider.tsx b/apps/wallet-mobile/src/features/ReviewTransaction/common/Divider.tsx new file mode 100644 index 0000000000..c53f95c9cd --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTransaction/common/Divider.tsx @@ -0,0 +1,21 @@ +import {useTheme} from '@yoroi/theme' +import * as React from 'react' +import {StyleSheet, View} from 'react-native' + +export const Divider = () => { + const {styles} = useStyles() + return +} + +const useStyles = () => { + const {atoms, color} = useTheme() + const styles = StyleSheet.create({ + divider: { + height: 1, + ...atoms.align_stretch, + backgroundColor: color.gray_200, + }, + }) + + return {styles} as const +} diff --git a/apps/wallet-mobile/src/features/ReviewTransaction/common/formattedTransaction.tsx b/apps/wallet-mobile/src/features/ReviewTransaction/common/formattedTransaction.tsx new file mode 100644 index 0000000000..8f185bff89 --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTransaction/common/formattedTransaction.tsx @@ -0,0 +1,122 @@ +import {isNonNullable} from '@yoroi/common' +import * as _ from 'lodash' + +import {useTokenInfos} from '../../../yoroi-wallets/hooks' +import {asQuantity} from '../../../yoroi-wallets/utils' +import {formatAdaWithText, formatTokenWithText} from '../../../yoroi-wallets/utils/format' +import {useSelectedWallet} from '../../WalletManager/common/hooks/useSelectedWallet' +import {TransactionBody} from './types' + +export const useFormattedTransaction = (data: TransactionBody) => { + const {wallet} = useSelectedWallet() + + const inputs = data?.inputs ?? [] + const outputs = data?.outputs ?? [] + + const getUtxoByTxIdAndIndex = (txId: string, index: number) => { + return wallet.utxos.find((u) => u.tx_hash === txId && u.tx_index === index) + } + + const isOwnedAddress = (bech32Address: string) => { + return wallet.internalAddresses.includes(bech32Address) || wallet.externalAddresses.includes(bech32Address) + } + + const inputTokenIds = inputs.flatMap((i) => { + const receiveUTxO = getUtxoByTxIdAndIndex(i.transaction_id, i.index) + return receiveUTxO?.assets.map((a) => `${a.policyId}.${a.assetId}`) ?? [] + }) + + const outputTokenIds = outputs.flatMap((o) => { + if (!o.amount.multiasset) return [] + const policyIds = Object.keys(o.amount.multiasset) + const tokenIds = policyIds.flatMap((policyId) => { + const assetIds = Object.keys(o.amount.multiasset?.[policyId] ?? {}) + return assetIds.map((assetId) => `${policyId}.${assetId}`) + }) + return tokenIds + }) + + const tokenIds = _.uniq([...inputTokenIds, ...outputTokenIds]) + const tokenInfos = useTokenInfos({wallet, tokenIds}) + + const formattedInputs = inputs.map((input) => { + const receiveUTxO = getUtxoByTxIdAndIndex(input.transaction_id, input.index) + const address = receiveUTxO?.receiver + const coin = receiveUTxO?.amount != null ? asQuantity(receiveUTxO.amount) : null + const coinText = coin != null ? formatAdaWithText(coin, wallet.primaryToken) : null + + const primaryAssets = + coinText != null + ? [ + { + label: coinText, + quantity: coin, + isPrimary: true, + }, + ] + : [] + + const multiAssets = + receiveUTxO?.assets + .map((a) => { + const tokenInfo = tokenInfos.find((t) => t.id === a.assetId) + if (!tokenInfo) return null + const quantity = asQuantity(a.amount) + return { + label: formatTokenWithText(quantity, tokenInfo), + quantity, + isPrimary: false, + } + }) + .filter(Boolean) ?? [] + + return { + assets: [...primaryAssets, ...multiAssets].filter(isNonNullable), + address, + ownAddress: address != null && isOwnedAddress(address), + txIndex: input.index, + txHash: input.transaction_id, + } + }) + + const formattedOutputs = outputs.map((output) => { + const address = output.address + const coin = asQuantity(output.amount.coin) + const coinText = formatAdaWithText(coin, wallet.primaryToken) + + const primaryAssets = + coinText != null + ? [ + { + label: coinText, + quantity: coin, + isPrimary: true, + }, + ] + : [] + + const multiAssets = output.amount.multiasset + ? Object.entries(output.amount.multiasset).map(([policyId, assets]) => { + return Object.entries(assets).map(([assetId, amount]) => { + const tokenInfo = tokenInfos.find((t) => t.id === `${policyId}.${assetId}`) + if (tokenInfo == null) return null + const quantity = asQuantity(amount) + return { + label: formatTokenWithText(quantity, tokenInfo), + quantity, + isPrimary: false, + } + }) + }) + : [] + + const assets = [...primaryAssets, ...multiAssets.flat()].filter(isNonNullable) + return {assets, address, ownAddress: address != null && isOwnedAddress(address)} + }) + + const formattedFee = formatAdaWithText(asQuantity(data?.fee ?? '0'), wallet.primaryToken) + + return {inputs: formattedInputs, outputs: formattedOutputs, fee: formattedFee} +} + +export type formattedTx = ReturnType diff --git a/apps/wallet-mobile/src/features/ReviewTransaction/common/mocks.ts b/apps/wallet-mobile/src/features/ReviewTransaction/common/mocks.ts new file mode 100644 index 0000000000..311b526bcf --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTransaction/common/mocks.ts @@ -0,0 +1,148 @@ +import {TransactionBody} from './types' + +export const adaTransactionSingleReceiver: TransactionBody = { + inputs: [ + { + transaction_id: '46fe71d85a733d970fe7bb8e6586624823803936d18c7e14601713d05b5b287a', + index: 0, + }, + { + transaction_id: '9638640d421875f068d10a0125023601bbd7e83e7f17b721c9c06c97cc29ff66', + index: 1, + }, + ], + outputs: [ + { + address: + 'addr1qyf4x8lvcyrwcxzkyz3lykyzfu7s7x307dlafgsu89qzge8lfl229ahk888cgakug24y86qtduvn065c3gw7dg5002cqdskm74', + amount: { + coin: '12000000', + multiasset: null, + }, + plutus_data: null, + script_ref: null, + }, + { + address: + 'addr1q9xy5p0cz2zsjrzpg4tl59mltjmfh07yc28alchxjlanygk0ppwv8x4ylafdu84xqmh9sx4vrk4czekksv884xmvanwqde82xg', + amount: { + coin: '23464562', + multiasset: null, + }, + plutus_data: null, + script_ref: null, + }, + ], + fee: '174345', + ttl: '220373661', + certs: null, + withdrawals: null, + update: null, + auxiliary_data_hash: null, + validity_start_interval: null, + mint: null, + script_data_hash: null, + collateral: null, + required_signers: null, + network_id: null, + collateral_return: null, + total_collateral: null, + reference_inputs: null, + voting_procedures: null, + voting_proposals: null, + donation: null, + current_treasury_value: null, +} + +export const multiAssetsOneReceiver: TransactionBody = { + inputs: [ + { + transaction_id: '46fe71d85a733d970fe7bb8e6586624823803936d18c7e14601713d05b5b287a', + index: 0, + }, + { + transaction_id: 'bddd3e0b43b9b93f6d49190a9d4d55c3cd28e3d270b0f1bbc0f83b8ecc3e373a', + index: 1, + }, + ], + outputs: [ + { + address: + 'addr1qyf4x8lvcyrwcxzkyz3lykyzfu7s7x307dlafgsu89qzge8lfl229ahk888cgakug24y86qtduvn065c3gw7dg5002cqdskm74', + amount: { + coin: '10000000', + multiasset: { + cdaaee586376139ee8c3cc4061623968810d177ca5c300afb890b48a: { + '43415354': '10', + }, + f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a: { + '000de1406a6176696275656e6f': '1', + }, + }, + }, + plutus_data: null, + script_ref: null, + }, + { + address: + 'addr1q9xy5p0cz2zsjrzpg4tl59mltjmfh07yc28alchxjlanygk0ppwv8x4ylafdu84xqmh9sx4vrk4czekksv884xmvanwqde82xg', + amount: { + coin: '2228270', + multiasset: { + '2441ab3351c3b80213a98f4e09ddcf7dabe4879c3c94cc4e7205cb63': { + '46495245': '2531', + }, + '279c909f348e533da5808898f87f9a14bb2c3dfbbacccd631d927a3f': { + '534e454b': '204', + }, + '4cb48d60d1f7823d1307c61b9ecf472ff78cf22d1ccc5786d59461f8': { + '4144414d4f4f4e': '4983996', + }, + a0028f350aaabe0545fdcb56b039bfb08e4bb4d8c4d7c3c7d481c235: { + '484f534b59': '115930085', + }, + cdaaee586376139ee8c3cc4061623968810d177ca5c300afb890b48a: { + '43415354': '4498', + }, + e0c4c2d7c4a0ed2cf786753fd845dee82c45512cee03e92adfd3fb8d: { + '6a6176696275656e6f2e616461': '1', + }, + fc411f546d01e88a822200243769bbc1e1fbdde8fa0f6c5179934edb: { + '6a6176696275656e6f': '1', + }, + }, + }, + plutus_data: null, + script_ref: null, + }, + { + address: + 'addr1q9xy5p0cz2zsjrzpg4tl59mltjmfh07yc28alchxjlanygk0ppwv8x4ylafdu84xqmh9sx4vrk4czekksv884xmvanwqde82xg', + amount: { + coin: '2300311', + multiasset: null, + }, + plutus_data: null, + script_ref: null, + }, + ], + fee: '189349', + ttl: '220396208', + certs: null, + withdrawals: null, + update: null, + auxiliary_data_hash: null, + validity_start_interval: null, + mint: null, + script_data_hash: null, + collateral: null, + required_signers: null, + network_id: null, + collateral_return: null, + total_collateral: null, + reference_inputs: null, + voting_procedures: null, + voting_proposals: null, + donation: null, + current_treasury_value: null, +} diff --git a/apps/wallet-mobile/src/features/ReviewTransaction/common/types.ts b/apps/wallet-mobile/src/features/ReviewTransaction/common/types.ts new file mode 100644 index 0000000000..0c6847d747 --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTransaction/common/types.ts @@ -0,0 +1,855 @@ +export type TransactionDetails = { + id: string + walletPlate: React.ReactNode + walletName: string + createdBy: string | null + fee: string + txBody: TransactionBody +} + +export type Address = string +export type URL = string + +export interface Anchor { + anchor_data_hash: string + anchor_url: URL +} +export type AnchorDataHash = string +export type AssetName = string +export type AssetNames = string[] +export interface Assets { + [k: string]: string +} +export type NativeScript = + | { + ScriptPubkey: ScriptPubkey + } + | { + ScriptAll: ScriptAll + } + | { + ScriptAny: ScriptAny + } + | { + ScriptNOfK: ScriptNOfK + } + | { + TimelockStart: TimelockStart + } + | { + TimelockExpiry: TimelockExpiry + } +export type NativeScripts = NativeScript[] +export type PlutusScripts = string[] + +export interface AuxiliaryData { + metadata?: { + [k: string]: string + } | null + native_scripts?: NativeScripts | null + plutus_scripts?: PlutusScripts | null + prefer_alonzo_format: boolean +} +export interface ScriptPubkey { + addr_keyhash: string +} +export interface ScriptAll { + native_scripts: NativeScripts +} +export interface ScriptAny { + native_scripts: NativeScripts +} +export interface ScriptNOfK { + n: number + native_scripts: NativeScripts +} +export interface TimelockStart { + slot: string +} +export interface TimelockExpiry { + slot: string +} +export type AuxiliaryDataHash = string +export interface AuxiliaryDataSet { + [k: string]: AuxiliaryData +} +export type BigInt = string +export type BigNum = string +export type Vkey = string +export type HeaderLeaderCertEnum = + | { + /** + * @minItems 2 + * @maxItems 2 + */ + NonceAndLeader: [VRFCert, VRFCert] + } + | { + VrfResult: VRFCert + } +export type Certificate = + | { + StakeRegistration: StakeRegistration + } + | { + StakeDeregistration: StakeDeregistration + } + | { + StakeDelegation: StakeDelegation + } + | { + PoolRegistration: PoolRegistration + } + | { + PoolRetirement: PoolRetirement + } + | { + GenesisKeyDelegation: GenesisKeyDelegation + } + | { + MoveInstantaneousRewardsCert: MoveInstantaneousRewardsCert + } + | { + CommitteeHotAuth: CommitteeHotAuth + } + | { + CommitteeColdResign: CommitteeColdResign + } + | { + DRepDeregistration: DRepDeregistration + } + | { + DRepRegistration: DRepRegistration + } + | { + DRepUpdate: DRepUpdate + } + | { + StakeAndVoteDelegation: StakeAndVoteDelegation + } + | { + StakeRegistrationAndDelegation: StakeRegistrationAndDelegation + } + | { + StakeVoteRegistrationAndDelegation: StakeVoteRegistrationAndDelegation + } + | { + VoteDelegation: VoteDelegation + } + | { + VoteRegistrationAndDelegation: VoteRegistrationAndDelegation + } +export type CredType = + | { + Key: string + } + | { + Script: string + } +export type Relay = + | { + SingleHostAddr: SingleHostAddr + } + | { + SingleHostName: SingleHostName + } + | { + MultiHostName: MultiHostName + } +/** + * @minItems 4 + * @maxItems 4 + */ +export type Ipv4 = [number, number, number, number] +/** + * @minItems 16 + * @maxItems 16 + */ +export type Ipv6 = [ + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, +] +export type DNSRecordAorAAAA = string +export type DNSRecordSRV = string +export type Relays = Relay[] +export type MIRPot = 'Reserves' | 'Treasury' +export type MIREnum = + | { + ToOtherPot: string + } + | { + ToStakeCredentials: StakeToCoin[] + } +export type DRep = + | ('AlwaysAbstain' | 'AlwaysNoConfidence') + | { + KeyHash: string + } + | { + ScriptHash: string + } +export type DataOption = + | { + DataHash: string + } + | { + Data: string + } +export type ScriptRef = + | { + NativeScript: NativeScript + } + | { + PlutusScript: string + } +export type Mint = [string, MintAssets][] +export type NetworkId = 'Testnet' | 'Mainnet' +export type TransactionOutputs = TransactionOutput[] +export type CostModel = string[] +export type Voter = + | { + ConstitutionalCommitteeHotCred: CredType + } + | { + DRep: CredType + } + | { + StakingPool: string + } +export type VoteKind = 'No' | 'Yes' | 'Abstain' +export type GovernanceAction = + | { + ParameterChangeAction: ParameterChangeAction + } + | { + HardForkInitiationAction: HardForkInitiationAction + } + | { + TreasuryWithdrawalsAction: TreasuryWithdrawalsAction + } + | { + NoConfidenceAction: NoConfidenceAction + } + | { + UpdateCommitteeAction: UpdateCommitteeAction + } + | { + NewConstitutionAction: NewConstitutionAction + } + | { + InfoAction: InfoAction + } +/** + * @minItems 0 + * @maxItems 0 + */ +export type InfoAction = [] +export type TransactionBodies = TransactionBody[] +export type RedeemerTag = 'Spend' | 'Mint' | 'Cert' | 'Reward' | 'Vote' | 'VotingProposal' +export type TransactionWitnessSets = TransactionWitnessSet[] + +export interface Block { + auxiliary_data_set: { + [k: string]: AuxiliaryData + } + header: Header + invalid_transactions: number[] + transaction_bodies: TransactionBodies + transaction_witness_sets: TransactionWitnessSets +} +export interface Header { + body_signature: string + header_body: HeaderBody +} +export interface HeaderBody { + block_body_hash: string + block_body_size: number + block_number: number + issuer_vkey: Vkey + leader_cert: HeaderLeaderCertEnum + operational_cert: OperationalCert + prev_hash?: string | null + protocol_version: ProtocolVersion + slot: string + vrf_vkey: string +} +export interface VRFCert { + output: number[] + proof: number[] +} +export interface OperationalCert { + hot_vkey: string + kes_period: number + sequence_number: number + sigma: string +} +export interface ProtocolVersion { + major: number + minor: number +} +export interface TransactionBody { + auxiliary_data_hash?: string | null + certs?: Certificate[] | null + collateral?: TransactionInput[] | null + collateral_return?: TransactionOutput | null + current_treasury_value?: string | null + donation?: string | null + fee: string + inputs: TransactionInput[] + mint?: Mint | null + network_id?: NetworkId | null + outputs: TransactionOutputs + reference_inputs?: TransactionInput[] | null + required_signers?: string[] | null + script_data_hash?: string | null + total_collateral?: string | null + ttl?: string | null + update?: Update | null + validity_start_interval?: string | null + voting_procedures?: VoterVotes[] | null + voting_proposals?: VotingProposal[] | null + withdrawals?: { + [k: string]: string + } | null +} +export interface StakeRegistration { + coin?: string | null + stake_credential: CredType +} +export interface StakeDeregistration { + coin?: string | null + stake_credential: CredType +} +export interface StakeDelegation { + pool_keyhash: string + stake_credential: CredType +} +export interface PoolRegistration { + pool_params: PoolParams +} +export interface PoolParams { + cost: string + margin: UnitInterval + operator: string + pledge: string + pool_metadata?: PoolMetadata | null + pool_owners: string[] + relays: Relays + reward_account: string + vrf_keyhash: string +} +export interface UnitInterval { + denominator: string + numerator: string +} +export interface PoolMetadata { + pool_metadata_hash: string + url: URL +} +export interface SingleHostAddr { + ipv4?: Ipv4 | null + ipv6?: Ipv6 | null + port?: number | null +} +export interface SingleHostName { + dns_name: DNSRecordAorAAAA + port?: number | null +} +export interface MultiHostName { + dns_name: DNSRecordSRV +} +export interface PoolRetirement { + epoch: number + pool_keyhash: string +} +export interface GenesisKeyDelegation { + genesis_delegate_hash: string + genesishash: string + vrf_keyhash: string +} +export interface MoveInstantaneousRewardsCert { + move_instantaneous_reward: MoveInstantaneousReward +} +export interface MoveInstantaneousReward { + pot: MIRPot + variant: MIREnum +} +export interface StakeToCoin { + amount: string + stake_cred: CredType +} +export interface CommitteeHotAuth { + committee_cold_credential: CredType + committee_hot_credential: CredType +} +export interface CommitteeColdResign { + anchor?: Anchor | null + committee_cold_credential: CredType +} +export interface DRepDeregistration { + coin: string + voting_credential: CredType +} +export interface DRepRegistration { + anchor?: Anchor | null + coin: string + voting_credential: CredType +} +export interface DRepUpdate { + anchor?: Anchor | null + voting_credential: CredType +} +export interface StakeAndVoteDelegation { + drep: DRep + pool_keyhash: string + stake_credential: CredType +} +export interface StakeRegistrationAndDelegation { + coin: string + pool_keyhash: string + stake_credential: CredType +} +export interface StakeVoteRegistrationAndDelegation { + coin: string + drep: DRep + pool_keyhash: string + stake_credential: CredType +} +export interface VoteDelegation { + drep: DRep + stake_credential: CredType +} +export interface VoteRegistrationAndDelegation { + coin: string + drep: DRep + stake_credential: CredType +} +export interface TransactionInput { + index: number + transaction_id: string +} +export interface TransactionOutput { + address: string + amount: Value + plutus_data?: DataOption | null + script_ref?: ScriptRef | null +} +export interface Value { + coin: string + multiasset?: MultiAsset | null +} +export interface MultiAsset { + [k: string]: Assets +} +export interface MintAssets { + [k: string]: string +} +export interface Update { + epoch: number + proposed_protocol_parameter_updates: { + [k: string]: ProtocolParamUpdate + } +} +export interface ProtocolParamUpdate { + ada_per_utxo_byte?: string | null + collateral_percentage?: number | null + committee_term_limit?: number | null + cost_models?: Costmdls | null + d?: UnitInterval | null + drep_deposit?: string | null + drep_inactivity_period?: number | null + drep_voting_thresholds?: DRepVotingThresholds | null + execution_costs?: ExUnitPrices | null + expansion_rate?: UnitInterval | null + extra_entropy?: Nonce | null + governance_action_deposit?: string | null + governance_action_validity_period?: number | null + key_deposit?: string | null + max_block_body_size?: number | null + max_block_ex_units?: ExUnits | null + max_block_header_size?: number | null + max_collateral_inputs?: number | null + max_epoch?: number | null + max_tx_ex_units?: ExUnits | null + max_tx_size?: number | null + max_value_size?: number | null + min_committee_size?: number | null + min_pool_cost?: string | null + minfee_a?: string | null + minfee_b?: string | null + n_opt?: number | null + pool_deposit?: string | null + pool_pledge_influence?: UnitInterval | null + pool_voting_thresholds?: PoolVotingThresholds | null + protocol_version?: ProtocolVersion | null + ref_script_coins_per_byte?: UnitInterval | null + treasury_growth_rate?: UnitInterval | null +} +export interface Costmdls { + [k: string]: CostModel +} +export interface DRepVotingThresholds { + committee_no_confidence: UnitInterval + committee_normal: UnitInterval + hard_fork_initiation: UnitInterval + motion_no_confidence: UnitInterval + pp_economic_group: UnitInterval + pp_governance_group: UnitInterval + pp_network_group: UnitInterval + pp_technical_group: UnitInterval + treasury_withdrawal: UnitInterval + update_constitution: UnitInterval +} +export interface ExUnitPrices { + mem_price: UnitInterval + step_price: UnitInterval +} +export interface Nonce { + /** + * @minItems 32 + * @maxItems 32 + */ + hash?: + | [ + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + number, + ] + | null +} +export interface ExUnits { + mem: string + steps: string +} +export interface PoolVotingThresholds { + committee_no_confidence: UnitInterval + committee_normal: UnitInterval + hard_fork_initiation: UnitInterval + motion_no_confidence: UnitInterval + security_relevant_threshold: UnitInterval +} +export interface VoterVotes { + voter: Voter + votes: Vote[] +} +export interface Vote { + action_id: GovernanceActionId + voting_procedure: VotingProcedure +} +export interface GovernanceActionId { + index: number + transaction_id: string +} +export interface VotingProcedure { + anchor?: Anchor | null + vote: VoteKind +} +export interface VotingProposal { + anchor: Anchor + deposit: string + governance_action: GovernanceAction + reward_account: string +} +export interface ParameterChangeAction { + gov_action_id?: GovernanceActionId | null + policy_hash?: string | null + protocol_param_updates: ProtocolParamUpdate +} +export interface HardForkInitiationAction { + gov_action_id?: GovernanceActionId | null + protocol_version: ProtocolVersion +} +export interface TreasuryWithdrawalsAction { + policy_hash?: string | null + withdrawals: TreasuryWithdrawals +} +export interface TreasuryWithdrawals { + [k: string]: string +} +export interface NoConfidenceAction { + gov_action_id?: GovernanceActionId | null +} +export interface UpdateCommitteeAction { + committee: Committee + gov_action_id?: GovernanceActionId | null + members_to_remove: CredType[] +} +export interface Committee { + members: CommitteeMember[] + quorum_threshold: UnitInterval +} +export interface CommitteeMember { + stake_credential: CredType + term_limit: number +} +export interface NewConstitutionAction { + constitution: Constitution + gov_action_id?: GovernanceActionId | null +} +export interface Constitution { + anchor: Anchor + script_hash?: string | null +} +export interface TransactionWitnessSet { + bootstraps?: BootstrapWitness[] | null + native_scripts?: NativeScripts | null + plutus_data?: PlutusList | null + plutus_scripts?: PlutusScripts | null + redeemers?: Redeemer[] | null + vkeys?: Vkeywitness[] | null +} +export interface BootstrapWitness { + attributes: number[] + chain_code: number[] + signature: string + vkey: Vkey +} +export interface PlutusList { + definite_encoding?: boolean | null + elems: string[] +} +export interface Redeemer { + data: string + ex_units: ExUnits + index: string + tag: RedeemerTag +} +export interface Vkeywitness { + signature: string + vkey: Vkey +} +export type BlockHash = string +export type BootstrapWitnesses = BootstrapWitness[] + +export type CertificateEnum = + | { + StakeRegistration: StakeRegistration + } + | { + StakeDeregistration: StakeDeregistration + } + | { + StakeDelegation: StakeDelegation + } + | { + PoolRegistration: PoolRegistration + } + | { + PoolRetirement: PoolRetirement + } + | { + GenesisKeyDelegation: GenesisKeyDelegation + } + | { + MoveInstantaneousRewardsCert: MoveInstantaneousRewardsCert + } + | { + CommitteeHotAuth: CommitteeHotAuth + } + | { + CommitteeColdResign: CommitteeColdResign + } + | { + DRepDeregistration: DRepDeregistration + } + | { + DRepRegistration: DRepRegistration + } + | { + DRepUpdate: DRepUpdate + } + | { + StakeAndVoteDelegation: StakeAndVoteDelegation + } + | { + StakeRegistrationAndDelegation: StakeRegistrationAndDelegation + } + | { + StakeVoteRegistrationAndDelegation: StakeVoteRegistrationAndDelegation + } + | { + VoteDelegation: VoteDelegation + } + | { + VoteRegistrationAndDelegation: VoteRegistrationAndDelegation + } +export type Certificates = Certificate[] + +export type Credential = CredType +export type Credentials = CredType[] +export type DRepEnum = + | ('AlwaysAbstain' | 'AlwaysNoConfidence') + | { + KeyHash: string + } + | { + ScriptHash: string + } +export type DataHash = string +export type Ed25519KeyHash = string +export type Ed25519KeyHashes = string[] +export type Ed25519Signature = string +export interface GeneralTransactionMetadata { + [k: string]: string +} +export type GenesisDelegateHash = string +export type GenesisHash = string +export type GenesisHashes = string[] +export type GovernanceActionEnum = + | { + ParameterChangeAction: ParameterChangeAction + } + | { + HardForkInitiationAction: HardForkInitiationAction + } + | { + TreasuryWithdrawalsAction: TreasuryWithdrawalsAction + } + | { + NoConfidenceAction: NoConfidenceAction + } + | { + UpdateCommitteeAction: UpdateCommitteeAction + } + | { + NewConstitutionAction: NewConstitutionAction + } + | { + InfoAction: InfoAction + } +export type GovernanceActionIds = GovernanceActionId[] + +export type Int = string +/** + * @minItems 4 + * @maxItems 4 + */ +export type KESVKey = string +export type Language = LanguageKind +export type LanguageKind = 'PlutusV1' | 'PlutusV2' | 'PlutusV3' +export type Languages = Language[] +export type MIRToStakeCredentials = StakeToCoin[] + +export type MintsAssets = MintAssets[] + +export type NetworkIdKind = 'Testnet' | 'Mainnet' +export type PlutusScript = string +export type PoolMetadataHash = string +export interface ProposedProtocolParameterUpdates { + [k: string]: ProtocolParamUpdate +} +export type PublicKey = string +export type RedeemerTagKind = 'Spend' | 'Mint' | 'Cert' | 'Reward' | 'Vote' | 'VotingProposal' +export type Redeemers = Redeemer[] + +export type RelayEnum = + | { + SingleHostAddr: SingleHostAddr + } + | { + SingleHostName: SingleHostName + } + | { + MultiHostName: MultiHostName + } +/** + * @minItems 4 + * @maxItems 4 + */ +export type RewardAddress = string +export type RewardAddresses = string[] +export type ScriptDataHash = string +export type ScriptHash = string +export type ScriptHashes = string[] +export type ScriptRefEnum = + | { + NativeScript: NativeScript + } + | { + PlutusScript: string + } +export interface Transaction { + auxiliary_data?: AuxiliaryData | null + body: TransactionBody + is_valid: boolean + witness_set: TransactionWitnessSet +} +export type TransactionHash = string +export type TransactionInputs = TransactionInput[] + +export type TransactionMetadatum = string +export interface TransactionUnspentOutput { + input: TransactionInput + output: TransactionOutput +} +export type TransactionUnspentOutputs = TransactionUnspentOutput[] + +export type VRFKeyHash = string +export type VRFVKey = string +export interface VersionedBlock { + block: Block + era_code: number +} +export type Vkeywitnesses = Vkeywitness[] + +export type VoterEnum = + | { + ConstitutionalCommitteeHotCred: CredType + } + | { + DRep: CredType + } + | { + StakingPool: string + } +export type Voters = Voter[] +export type VotingProcedures = VoterVotes[] + +export type VotingProposals = VotingProposal[] + +export interface Withdrawals { + [k: string]: string +} diff --git a/apps/wallet-mobile/src/features/ReviewTransaction/useCases/ReviewTransactionScreen/Overview/OverviewTab.tsx b/apps/wallet-mobile/src/features/ReviewTransaction/useCases/ReviewTransactionScreen/Overview/OverviewTab.tsx new file mode 100644 index 0000000000..9fe38a4708 --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTransaction/useCases/ReviewTransactionScreen/Overview/OverviewTab.tsx @@ -0,0 +1,469 @@ +import {Blockies} from '@yoroi/identicon' +import {useTheme} from '@yoroi/theme' +import * as React from 'react' +import {Linking, StyleSheet, Text, TouchableOpacity, View} from 'react-native' +import Svg, {Defs, Image, Pattern, Rect, SvgProps, Use} from 'react-native-svg' + +import {Icon} from '../../../../../components' +import {Space} from '../../../../../components/Space/Space' +import {Warning} from '../../../../../components/Warning' +import {useCopy} from '../../../../../hooks/useCopy' +import {useRewardAddress} from '../../../../../yoroi-wallets/hooks' +import {useSelectedWallet} from '../../../../WalletManager/common/hooks/useSelectedWallet' +import {useWalletManager} from '../../../../WalletManager/context/WalletManagerProvider' +import {Divider} from '../../../common/Divider' +import {formattedTx} from '../../../common/formattedTransaction' + +export const OverviewTab = ({tx, createdBy}: {tx: formattedTx; createdBy?: React.ReactNode}) => { + const {styles} = useStyles() + + return ( + + + + + + + + {createdBy !== undefined && ( + <> + + + + + )} + + + + + + + + + + + + + + + + + + + + ) +} + +const WalletInfoItem = () => { + const {styles} = useStyles() + const {wallet, meta} = useSelectedWallet() + const {walletManager} = useWalletManager() + const {plate, seed} = walletManager.checksum(wallet.publicKeyHex) + + return ( + + Wallet + + + + + + + {`${plate} | ${meta.name}`} + + + ) +} + +const FeeInfoItem = ({fee}: {fee: string}) => { + const {styles} = useStyles() + + return ( + + Fee + + {fee} + + ) +} + +// TODO (for dapps) +const CreatedByInfoItem = () => { + const {styles} = useStyles() + + return ( + + Created By + + + + + + + Linking.openURL('https://google.com')}> + dapp.org + + + + ) +} + +const SenderTokensSection = ({tx}: {tx: formattedTx}) => { + console.log(tx) + const {wallet} = useSelectedWallet() + const rewardAddress = useRewardAddress(wallet) + + return ( + + + +
+ + + + + + ) +} + +const Address = ({address}: {address: string}) => { + const {styles, colors} = useStyles() + const [, copy] = useCopy() + + return ( + + + {address} + + + copy(address)} activeOpacity={0.5}> + + + + ) +} + +const SenderTokensItems = () => { + const {styles} = useStyles() + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +const SenderTokensSectionLabel = () => { + const {styles, colors} = useStyles() + + return ( + + + + + + Send + + ) +} + +const ReceiverTokensSectionLabel = () => { + const {styles, colors} = useStyles() + + return ( + + + + + + Receive + + ) +} + +const ReceiverTokensSection = () => { + const {styles, colors} = useStyles() + + const isRegularAdress = true + const isMultiReceiver = true + + if (isMultiReceiver) { + return ( + <> + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) + } + + return ( + <> + + + + {isRegularAdress ? `To` : 'To script'}: + + + stake1u948jr02falxxqphnv3g3rkd3mdzqmtqq3x0tjl39m7dqngqg0fxp + + + + + + ) +} + +const TokenItem = ({ + isPrimaryToken = true, + isSent = true, + value, +}: { + isPrimaryToken?: boolean + isSent?: boolean + value: string +}) => { + const {styles} = useStyles() + + if (!isSent) + return ( + + + {value} + + + ) + + return ( + + {value} + + ) +} + +const CollapsibleSection = ({label, children}: {label: string; children: React.ReactNode}) => { + const {styles, colors} = useStyles() + const [isOpen, setIsOpen] = React.useState(false) + + return ( + <> + + {label} + + setIsOpen((isOpen) => !isOpen)}> + + + + + {isOpen && children} + + ) +} + +const useStyles = () => { + const {atoms, color} = useTheme() + const styles = StyleSheet.create({ + root: { + ...atoms.px_lg, + }, + infoItem: { + ...atoms.flex_row, + ...atoms.justify_between, + }, + infoLabel: { + ...atoms.body_2_md_regular, + color: color.gray_600, + }, + walletInfoText: { + ...atoms.body_2_md_medium, + color: color.text_primary_medium, + }, + plate: { + ...atoms.flex_row, + ...atoms.align_center, + }, + fee: { + color: color.gray_900, + ...atoms.body_2_md_regular, + }, + link: { + color: color.text_primary_medium, + ...atoms.body_2_md_medium, + }, + sectionHeader: { + ...atoms.flex_row, + ...atoms.justify_between, + }, + myWalletAddress: { + ...atoms.flex_row, + ...atoms.align_center, + ...atoms.flex_row, + ...atoms.justify_between, + }, + myWalletAddressText: { + ...atoms.flex_1, + ...atoms.body_2_md_regular, + color: color.gray_900, + }, + sectionHeaderText: { + ...atoms.body_1_lg_medium, + color: color.gray_900, + }, + tokenSectionLabel: { + ...atoms.body_2_md_regular, + color: color.gray_900, + }, + sentTokenItem: { + ...atoms.flex, + ...atoms.flex_row, + ...atoms.align_center, + ...atoms.py_xs, + ...atoms.px_md, + borderRadius: 8, + backgroundColor: color.primary_500, + }, + receivedTokenItem: { + ...atoms.flex, + ...atoms.flex_row, + ...atoms.align_center, + ...atoms.py_xs, + ...atoms.px_md, + borderRadius: 8, + backgroundColor: color.secondary_300, + }, + senderTokenItems: { + ...atoms.flex_wrap, + ...atoms.flex_row, + ...atoms.justify_end, + ...atoms.flex_1, + gap: 8, + }, + tokenSentItemText: { + ...atoms.body_2_md_regular, + color: color.white_static, + }, + tokenReceivedItemText: { + ...atoms.body_2_md_regular, + color: color.text_gray_max, + }, + notPrimarySentTokenItem: { + backgroundColor: color.primary_100, + }, + notPrimaryReceivedTokenItem: { + backgroundColor: color.secondary_100, + }, + notPrimarySentTokenItemText: { + color: color.text_primary_medium, + }, + notPrimaryReceivedTokenItemText: { + color: color.secondary_700, + }, + tokensSection: { + ...atoms.flex_row, + ...atoms.justify_between, + }, + tokensSectionLabel: { + ...atoms.flex_row, + ...atoms.align_center, + }, + walletChecksum: { + width: 24, + height: 24, + }, + }) + + const colors = { + copy: color.gray_900, + chevron: color.gray_900, + send: color.primary_500, + received: color.green_static, + } + + return {styles, colors} as const +} + +function SvgComponent(props: SvgProps) { + return ( + + + + + + + + + + + + ) +} diff --git a/apps/wallet-mobile/src/features/ReviewTransaction/useCases/ReviewTransactionScreen/ReviewTransactionScreen.tsx b/apps/wallet-mobile/src/features/ReviewTransaction/useCases/ReviewTransactionScreen/ReviewTransactionScreen.tsx new file mode 100644 index 0000000000..38833abb88 --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTransaction/useCases/ReviewTransactionScreen/ReviewTransactionScreen.tsx @@ -0,0 +1,59 @@ +import {useTheme} from '@yoroi/theme' +import * as React from 'react' +import {StyleSheet} from 'react-native' + +import {SafeArea} from '../../../../components/SafeArea' +import {Tab, Tabs} from '../../../../components/Tabs' +import {Divider} from '../../common/Divider' +import {useFormattedTransaction} from '../../common/formattedTransaction' +import {multiAssetsOneReceiver} from '../../common/mocks' +import {OverviewTab} from './Overview/OverviewTab' + +export const ReviewTransactionScreen = () => { + const {styles} = useStyles() + const [activeTab, setActiveTab] = React.useState('overview') + const formatedTx = useFormattedTransaction(multiAssetsOneReceiver) + + console.log('tx', JSON.stringify(formatedTx, null, 2)) + + const renderTabs = React.useMemo(() => { + return ( + + setActiveTab('overview')} + label="Overview" + /> + + setActiveTab('utxos')} label="UTxOs" /> + + ) + }, [activeTab, setActiveTab, styles.tab, styles.tabs]) + + return ( + + {renderTabs} + + + + {activeTab === 'overview' && } + + ) +} + +const useStyles = () => { + const {atoms, color} = useTheme() + const styles = StyleSheet.create({ + tabs: { + ...atoms.px_lg, + ...atoms.gap_lg, + backgroundColor: color.bg_color_max, + }, + tab: { + flex: 0, + }, + }) + + return {styles} as const +} diff --git a/apps/wallet-mobile/src/features/Send/useCases/ConfirmTx/ConfirmTxScreen.tsx b/apps/wallet-mobile/src/features/Send/useCases/ConfirmTx/ConfirmTxScreen.tsx index 2ed1e978d7..fc4375a5b6 100644 --- a/apps/wallet-mobile/src/features/Send/useCases/ConfirmTx/ConfirmTxScreen.tsx +++ b/apps/wallet-mobile/src/features/Send/useCases/ConfirmTx/ConfirmTxScreen.tsx @@ -73,6 +73,14 @@ export const ConfirmTxScreen = () => { if (yoroiUnsignedTx === undefined) throw new Error('Missing yoroiUnsignedTx') + React.useEffect(() => { + const test = async () => { + console.log('txBody', await yoroiUnsignedTx.unsignedTx.txBody.toJson()) + } + + test() + }, [yoroiUnsignedTx.unsignedTx.txBody]) + return ( diff --git a/apps/wallet-mobile/src/features/Transactions/TxHistoryNavigator.tsx b/apps/wallet-mobile/src/features/Transactions/TxHistoryNavigator.tsx index e186ab34a7..88f1d7149e 100644 --- a/apps/wallet-mobile/src/features/Transactions/TxHistoryNavigator.tsx +++ b/apps/wallet-mobile/src/features/Transactions/TxHistoryNavigator.tsx @@ -27,6 +27,7 @@ import {ReceiveProvider} from '../Receive/common/ReceiveProvider' import {DescribeSelectedAddressScreen} from '../Receive/useCases/DescribeSelectedAddressScreen' import {ListMultipleAddressesScreen} from '../Receive/useCases/ListMultipleAddressesScreen' import {RequestSpecificAmountScreen} from '../Receive/useCases/RequestSpecificAmountScreen' +import {ReviewTransactionNavigator} from '../ReviewTransaction/ReviewTransactionNavigator' import {CodeScannerButton} from '../Scan/common/CodeScannerButton' import {ScanCodeScreen} from '../Scan/useCases/ScanCodeScreen' import {ShowCameraPermissionDeniedScreen} from '../Scan/useCases/ShowCameraPermissionDeniedScreen/ShowCameraPermissionDeniedScreen' @@ -38,7 +39,7 @@ import {SelectTokenFromListScreen} from '../Send/useCases/ListAmountsToSend/AddT import {EditAmountScreen} from '../Send/useCases/ListAmountsToSend/EditAmount/EditAmountScreen' import {StartMultiTokenTxScreen} from '../Send/useCases/StartMultiTokenTx/StartMultiTokenTxScreen' import {NetworkTag} from '../Settings/ChangeNetwork/NetworkTag' -import {SwapTabNavigator} from '../Swap/SwapNavigator' +// import {SwapTabNavigator} from '../Swap/SwapNavigator' import { ConfirmTxScreen as ConfirmTxSwapScreen, EditSlippageScreen, @@ -220,7 +221,7 @@ export const TxHistoryNavigator = () => { + 'review-transaction-routes': NavigatorScreenParams 'nft-details-routes': NavigatorScreenParams settings: NavigatorScreenParams 'voting-registration': NavigatorScreenParams @@ -314,6 +315,15 @@ export type Portfolio2Routes = { history: NavigatorScreenParams } +export type ReviewTransactionRoutes = { + 'review-transaction': NavigatorScreenParams +} + +export type ReviewTransactionTabRoutes = { + overview: undefined + utxos: undefined +} + export type PortfolioTokenListTabRoutes = { 'wallet-token': undefined 'dapps-token': undefined diff --git a/apps/wallet-mobile/src/yoroi-wallets/hooks/index.ts b/apps/wallet-mobile/src/yoroi-wallets/hooks/index.ts index ffe38c81a5..c35bc6b89e 100644 --- a/apps/wallet-mobile/src/yoroi-wallets/hooks/index.ts +++ b/apps/wallet-mobile/src/yoroi-wallets/hooks/index.ts @@ -30,6 +30,7 @@ import {TRANSACTION_DIRECTION, TRANSACTION_STATUS, YoroiSignedTx, YoroiUnsignedT import {TipStatusResponse, TxSubmissionStatus} from '../types/other' import {delay} from '../utils/timeUtils' import {Amounts, Quantities, Utxos} from '../utils/utils' +import {CardanoMobile} from '../wallets' const crashReportsStorageKey = 'sendCrashReports' @@ -123,6 +124,20 @@ export const useStakingKey = (wallet: YoroiWallet) => { return result.data } +export const useRewardAddress = (wallet: YoroiWallet) => { + const result = useQuery( + [wallet.id, 'useRewardAddress'], + async () => { + const rewardAddress = await CardanoMobile.Address.fromBytes(Buffer.from(wallet.rewardAddressHex, 'hex')) + const bech32RewardAddress = await rewardAddress.toBech32(undefined) + return bech32RewardAddress + }, + {suspense: true}, + ) + if (!result.data) throw new Error('invalid state') + return result.data +} + export const useKeyHashes = ({address}: {address: string}) => { const [spendingData, stakingData] = useQueries([ { diff --git a/apps/wallet-mobile/translations/messages/src/WalletNavigator.json b/apps/wallet-mobile/translations/messages/src/WalletNavigator.json index 40622d7b4e..22035b6b29 100644 --- a/apps/wallet-mobile/translations/messages/src/WalletNavigator.json +++ b/apps/wallet-mobile/translations/messages/src/WalletNavigator.json @@ -6,12 +6,12 @@ "start": { "line": 305, "column": 22, - "index": 10540 + "index": 10548 }, "end": { "line": 308, "column": 3, - "index": 10643 + "index": 10651 } }, { @@ -21,12 +21,12 @@ "start": { "line": 309, "column": 14, - "index": 10659 + "index": 10667 }, "end": { "line": 312, "column": 3, - "index": 10758 + "index": 10766 } }, { @@ -36,12 +36,12 @@ "start": { "line": 313, "column": 17, - "index": 10777 + "index": 10785 }, "end": { "line": 316, "column": 3, - "index": 10882 + "index": 10890 } }, { @@ -51,12 +51,12 @@ "start": { "line": 317, "column": 19, - "index": 10903 + "index": 10911 }, "end": { "line": 320, "column": 3, - "index": 11000 + "index": 11008 } }, { @@ -66,12 +66,12 @@ "start": { "line": 321, "column": 18, - "index": 11020 + "index": 11028 }, "end": { "line": 324, "column": 3, - "index": 11115 + "index": 11123 } }, { @@ -81,12 +81,12 @@ "start": { "line": 325, "column": 16, - "index": 11133 + "index": 11141 }, "end": { "line": 328, "column": 3, - "index": 11231 + "index": 11239 } }, { @@ -96,12 +96,12 @@ "start": { "line": 329, "column": 17, - "index": 11250 + "index": 11258 }, "end": { "line": 332, "column": 3, - "index": 11315 + "index": 11323 } }, { @@ -111,12 +111,12 @@ "start": { "line": 333, "column": 14, - "index": 11331 + "index": 11339 }, "end": { "line": 336, "column": 3, - "index": 11425 + "index": 11433 } }, { @@ -126,12 +126,12 @@ "start": { "line": 337, "column": 14, - "index": 11441 + "index": 11449 }, "end": { "line": 340, "column": 3, - "index": 11493 + "index": 11501 } }, { @@ -141,12 +141,12 @@ "start": { "line": 341, "column": 18, - "index": 11513 + "index": 11521 }, "end": { "line": 344, "column": 3, - "index": 11602 + "index": 11610 } }, { @@ -156,12 +156,12 @@ "start": { "line": 345, "column": 31, - "index": 11635 + "index": 11643 }, "end": { "line": 348, "column": 3, - "index": 11744 + "index": 11752 } }, { @@ -171,12 +171,12 @@ "start": { "line": 349, "column": 19, - "index": 11765 + "index": 11773 }, "end": { "line": 352, "column": 3, - "index": 11834 + "index": 11842 } } ] \ No newline at end of file diff --git a/apps/wallet-mobile/translations/messages/src/features/Transactions/TxHistoryNavigator.json b/apps/wallet-mobile/translations/messages/src/features/Transactions/TxHistoryNavigator.json index 7d93064caa..eb3d4f5724 100644 --- a/apps/wallet-mobile/translations/messages/src/features/Transactions/TxHistoryNavigator.json +++ b/apps/wallet-mobile/translations/messages/src/features/Transactions/TxHistoryNavigator.json @@ -4,14 +4,14 @@ "defaultMessage": "!!!Receive", "file": "src/features/Transactions/TxHistoryNavigator.tsx", "start": { - "line": 417, + "line": 418, "column": 16, - "index": 15219 + "index": 15323 }, "end": { - "line": 420, + "line": 421, "column": 3, - "index": 15308 + "index": 15412 } }, { @@ -19,14 +19,14 @@ "defaultMessage": "!!!Address details", "file": "src/features/Transactions/TxHistoryNavigator.tsx", "start": { - "line": 421, + "line": 422, "column": 32, - "index": 15342 + "index": 15446 }, "end": { - "line": 424, + "line": 425, "column": 3, - "index": 15455 + "index": 15559 } }, { @@ -34,14 +34,14 @@ "defaultMessage": "!!!Swap", "file": "src/features/Transactions/TxHistoryNavigator.tsx", "start": { - "line": 425, + "line": 426, "column": 13, - "index": 15470 + "index": 15574 }, "end": { - "line": 428, + "line": 429, "column": 3, - "index": 15543 + "index": 15647 } }, { @@ -49,14 +49,14 @@ "defaultMessage": "!!!Swap from", "file": "src/features/Transactions/TxHistoryNavigator.tsx", "start": { - "line": 429, + "line": 430, "column": 17, - "index": 15562 + "index": 15666 }, "end": { - "line": 432, + "line": 433, "column": 3, - "index": 15639 + "index": 15743 } }, { @@ -64,14 +64,14 @@ "defaultMessage": "!!!Swap to", "file": "src/features/Transactions/TxHistoryNavigator.tsx", "start": { - "line": 433, + "line": 434, "column": 15, - "index": 15656 + "index": 15760 }, "end": { - "line": 436, + "line": 437, "column": 3, - "index": 15729 + "index": 15833 } }, { @@ -79,14 +79,14 @@ "defaultMessage": "!!!Slippage Tolerance", "file": "src/features/Transactions/TxHistoryNavigator.tsx", "start": { - "line": 437, + "line": 438, "column": 21, - "index": 15752 + "index": 15856 }, "end": { - "line": 440, + "line": 441, "column": 3, - "index": 15847 + "index": 15951 } }, { @@ -94,14 +94,14 @@ "defaultMessage": "!!!Select pool", "file": "src/features/Transactions/TxHistoryNavigator.tsx", "start": { - "line": 441, + "line": 442, "column": 14, - "index": 15863 + "index": 15967 }, "end": { - "line": 444, + "line": 445, "column": 3, - "index": 15944 + "index": 16048 } }, { @@ -109,14 +109,14 @@ "defaultMessage": "!!!Send", "file": "src/features/Transactions/TxHistoryNavigator.tsx", "start": { - "line": 445, + "line": 446, "column": 13, - "index": 15959 + "index": 16063 }, "end": { - "line": 448, + "line": 449, "column": 3, - "index": 16039 + "index": 16143 } }, { @@ -124,14 +124,14 @@ "defaultMessage": "!!!Scan QR code address", "file": "src/features/Transactions/TxHistoryNavigator.tsx", "start": { - "line": 449, + "line": 450, "column": 18, - "index": 16059 + "index": 16163 }, "end": { - "line": 452, + "line": 453, "column": 3, - "index": 16160 + "index": 16264 } }, { @@ -139,14 +139,14 @@ "defaultMessage": "!!!Select asset", "file": "src/features/Transactions/TxHistoryNavigator.tsx", "start": { - "line": 453, + "line": 454, "column": 20, - "index": 16182 + "index": 16286 }, "end": { - "line": 456, + "line": 457, "column": 3, - "index": 16271 + "index": 16375 } }, { @@ -154,14 +154,14 @@ "defaultMessage": "!!!Assets added", "file": "src/features/Transactions/TxHistoryNavigator.tsx", "start": { - "line": 457, + "line": 458, "column": 26, - "index": 16299 + "index": 16403 }, "end": { - "line": 460, + "line": 461, "column": 3, - "index": 16400 + "index": 16504 } }, { @@ -169,14 +169,14 @@ "defaultMessage": "!!!Edit amount", "file": "src/features/Transactions/TxHistoryNavigator.tsx", "start": { - "line": 461, + "line": 462, "column": 19, - "index": 16421 + "index": 16525 }, "end": { - "line": 464, + "line": 465, "column": 3, - "index": 16514 + "index": 16618 } }, { @@ -184,14 +184,14 @@ "defaultMessage": "!!!Confirm", "file": "src/features/Transactions/TxHistoryNavigator.tsx", "start": { - "line": 465, + "line": 466, "column": 16, - "index": 16532 + "index": 16636 }, "end": { - "line": 468, + "line": 469, "column": 3, - "index": 16618 + "index": 16722 } }, { @@ -199,14 +199,14 @@ "defaultMessage": "!!!Share this address to receive payments. To protect your privacy, new addresses are generated automatically once you use them.", "file": "src/features/Transactions/TxHistoryNavigator.tsx", "start": { - "line": 469, + "line": 470, "column": 19, - "index": 16639 + "index": 16743 }, "end": { - "line": 475, + "line": 476, "column": 3, - "index": 16877 + "index": 16981 } }, { @@ -214,14 +214,14 @@ "defaultMessage": "!!!Confirm transaction", "file": "src/features/Transactions/TxHistoryNavigator.tsx", "start": { - "line": 476, + "line": 477, "column": 27, - "index": 16906 + "index": 17010 }, "end": { - "line": 479, + "line": 480, "column": 3, - "index": 16999 + "index": 17103 } }, { @@ -229,14 +229,14 @@ "defaultMessage": "!!!Please scan a QR code", "file": "src/features/Transactions/TxHistoryNavigator.tsx", "start": { - "line": 480, + "line": 481, "column": 13, - "index": 17014 + "index": 17118 }, "end": { - "line": 483, + "line": 484, "column": 3, - "index": 17089 + "index": 17193 } }, { @@ -244,14 +244,14 @@ "defaultMessage": "!!!Success", "file": "src/features/Transactions/TxHistoryNavigator.tsx", "start": { - "line": 484, + "line": 485, "column": 25, - "index": 17116 + "index": 17220 }, "end": { - "line": 487, + "line": 488, "column": 3, - "index": 17190 + "index": 17294 } }, { @@ -259,14 +259,14 @@ "defaultMessage": "!!!Request specific amount", "file": "src/features/Transactions/TxHistoryNavigator.tsx", "start": { - "line": 488, + "line": 489, "column": 18, - "index": 17210 + "index": 17314 }, "end": { - "line": 491, + "line": 492, "column": 3, - "index": 17324 + "index": 17428 } }, { @@ -274,14 +274,14 @@ "defaultMessage": "!!!Buy/Sell ADA", "file": "src/features/Transactions/TxHistoryNavigator.tsx", "start": { - "line": 492, + "line": 493, "column": 28, - "index": 17354 + "index": 17458 }, "end": { - "line": 495, + "line": 496, "column": 3, - "index": 17450 + "index": 17554 } }, { @@ -289,14 +289,14 @@ "defaultMessage": "!!!Buy provider", "file": "src/features/Transactions/TxHistoryNavigator.tsx", "start": { - "line": 496, + "line": 497, "column": 29, - "index": 17481 + "index": 17585 }, "end": { - "line": 499, + "line": 500, "column": 3, - "index": 17589 + "index": 17693 } }, { @@ -304,14 +304,14 @@ "defaultMessage": "!!!Sell provider", "file": "src/features/Transactions/TxHistoryNavigator.tsx", "start": { - "line": 500, + "line": 501, "column": 30, - "index": 17621 + "index": 17725 }, "end": { - "line": 503, + "line": 504, "column": 3, - "index": 17731 + "index": 17835 } }, { @@ -319,14 +319,14 @@ "defaultMessage": "!!!Tx Details", "file": "src/features/Transactions/TxHistoryNavigator.tsx", "start": { - "line": 504, + "line": 505, "column": 18, - "index": 17751 + "index": 17855 }, "end": { - "line": 507, + "line": 508, "column": 3, - "index": 17845 + "index": 17949 } } ] \ No newline at end of file diff --git a/packages/theme/src/base-palettes/dark-palette.ts b/packages/theme/src/base-palettes/dark-palette.ts index 2d5582e877..8374a43683 100644 --- a/packages/theme/src/base-palettes/dark-palette.ts +++ b/packages/theme/src/base-palettes/dark-palette.ts @@ -39,6 +39,7 @@ export const darkPalette: BasePalette = { black_static: '#000000', white_static: '#FFFFFF', + green_static: '#08C29D', sys_magenta_700: '#FFC0D0', sys_magenta_600: '#FB9CB5', diff --git a/packages/theme/src/base-palettes/light-palette.ts b/packages/theme/src/base-palettes/light-palette.ts index dbddef3e92..5dedefc337 100644 --- a/packages/theme/src/base-palettes/light-palette.ts +++ b/packages/theme/src/base-palettes/light-palette.ts @@ -36,6 +36,7 @@ export const lightPalette: BasePalette = { black_static: '#000000', white_static: '#FFFFFF', + green_static: '#08C29D', sys_magenta_700: '#CF053A', sys_magenta_600: '#E80742', diff --git a/packages/theme/src/types.ts b/packages/theme/src/types.ts index a8031ec031..f8dd1776e6 100644 --- a/packages/theme/src/types.ts +++ b/packages/theme/src/types.ts @@ -59,6 +59,7 @@ export type BasePalette = { black_static: HexColor white_static: HexColor + green_static: HexColor sys_magenta_700: HexColor sys_magenta_600: HexColor From 22159a850e6a90a19db86e16471cf18abe26704d Mon Sep 17 00:00:00 2001 From: Javier Bueno Date: Wed, 18 Sep 2024 09:57:20 +0200 Subject: [PATCH 002/113] feat(tx-review): improvements --- .../useCases/ReviewTransaction/types.ts | 846 ------------------ .../ReviewTransactionNavigator.tsx | 39 - .../common/formattedTransaction.tsx | 58 +- .../ReviewTransaction/common/mocks.ts | 6 +- .../Overview/OverviewTab.tsx | 63 +- .../ReviewTransactionScreen.tsx | 2 +- .../Transactions/TxHistoryNavigator.tsx | 4 +- .../src/yoroi-wallets/hooks/index.ts | 6 +- .../Transactions/TxHistoryNavigator.json | 88 +- 9 files changed, 120 insertions(+), 992 deletions(-) delete mode 100644 apps/wallet-mobile/src/features/Discover/useCases/ReviewTransaction/types.ts delete mode 100644 apps/wallet-mobile/src/features/ReviewTransaction/ReviewTransactionNavigator.tsx diff --git a/apps/wallet-mobile/src/features/Discover/useCases/ReviewTransaction/types.ts b/apps/wallet-mobile/src/features/Discover/useCases/ReviewTransaction/types.ts deleted file mode 100644 index c65b394738..0000000000 --- a/apps/wallet-mobile/src/features/Discover/useCases/ReviewTransaction/types.ts +++ /dev/null @@ -1,846 +0,0 @@ -export type AddressJSON = string -export type URLJSON = string - -export interface AnchorJSON { - anchor_data_hash: string - anchor_url: URLJSON -} -export type AnchorDataHashJSON = string -export type AssetNameJSON = string -export type AssetNamesJSON = string[] -export interface AssetsJSON { - [k: string]: string -} -export type NativeScriptJSON = - | { - ScriptPubkey: ScriptPubkeyJSON - } - | { - ScriptAll: ScriptAllJSON - } - | { - ScriptAny: ScriptAnyJSON - } - | { - ScriptNOfK: ScriptNOfKJSON - } - | { - TimelockStart: TimelockStartJSON - } - | { - TimelockExpiry: TimelockExpiryJSON - } -export type NativeScriptsJSON = NativeScriptJSON[] -export type PlutusScriptsJSON = string[] - -export interface AuxiliaryDataJSON { - metadata?: { - [k: string]: string - } | null - native_scripts?: NativeScriptsJSON | null - plutus_scripts?: PlutusScriptsJSON | null - prefer_alonzo_format: boolean -} -export interface ScriptPubkeyJSON { - addr_keyhash: string -} -export interface ScriptAllJSON { - native_scripts: NativeScriptsJSON -} -export interface ScriptAnyJSON { - native_scripts: NativeScriptsJSON -} -export interface ScriptNOfKJSON { - n: number - native_scripts: NativeScriptsJSON -} -export interface TimelockStartJSON { - slot: string -} -export interface TimelockExpiryJSON { - slot: string -} -export type AuxiliaryDataHashJSON = string -export interface AuxiliaryDataSetJSON { - [k: string]: AuxiliaryDataJSON -} -export type BigIntJSON = string -export type BigNumJSON = string -export type VkeyJSON = string -export type HeaderLeaderCertEnumJSON = - | { - /** - * @minItems 2 - * @maxItems 2 - */ - NonceAndLeader: [VRFCertJSON, VRFCertJSON] - } - | { - VrfResult: VRFCertJSON - } -export type CertificateJSON = - | { - StakeRegistration: StakeRegistrationJSON - } - | { - StakeDeregistration: StakeDeregistrationJSON - } - | { - StakeDelegation: StakeDelegationJSON - } - | { - PoolRegistration: PoolRegistrationJSON - } - | { - PoolRetirement: PoolRetirementJSON - } - | { - GenesisKeyDelegation: GenesisKeyDelegationJSON - } - | { - MoveInstantaneousRewardsCert: MoveInstantaneousRewardsCertJSON - } - | { - CommitteeHotAuth: CommitteeHotAuthJSON - } - | { - CommitteeColdResign: CommitteeColdResignJSON - } - | { - DRepDeregistration: DRepDeregistrationJSON - } - | { - DRepRegistration: DRepRegistrationJSON - } - | { - DRepUpdate: DRepUpdateJSON - } - | { - StakeAndVoteDelegation: StakeAndVoteDelegationJSON - } - | { - StakeRegistrationAndDelegation: StakeRegistrationAndDelegationJSON - } - | { - StakeVoteRegistrationAndDelegation: StakeVoteRegistrationAndDelegationJSON - } - | { - VoteDelegation: VoteDelegationJSON - } - | { - VoteRegistrationAndDelegation: VoteRegistrationAndDelegationJSON - } -export type CredTypeJSON = - | { - Key: string - } - | { - Script: string - } -export type RelayJSON = - | { - SingleHostAddr: SingleHostAddrJSON - } - | { - SingleHostName: SingleHostNameJSON - } - | { - MultiHostName: MultiHostNameJSON - } -/** - * @minItems 4 - * @maxItems 4 - */ -export type Ipv4JSON = [number, number, number, number] -/** - * @minItems 16 - * @maxItems 16 - */ -export type Ipv6JSON = [ - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, -] -export type DNSRecordAorAAAAJSON = string -export type DNSRecordSRVJSON = string -export type RelaysJSON = RelayJSON[] -export type MIRPotJSON = 'Reserves' | 'Treasury' -export type MIREnumJSON = - | { - ToOtherPot: string - } - | { - ToStakeCredentials: StakeToCoinJSON[] - } -export type DRepJSON = - | ('AlwaysAbstain' | 'AlwaysNoConfidence') - | { - KeyHash: string - } - | { - ScriptHash: string - } -export type DataOptionJSON = - | { - DataHash: string - } - | { - Data: string - } -export type ScriptRefJSON = - | { - NativeScript: NativeScriptJSON - } - | { - PlutusScript: string - } -export type MintJSON = [string, MintAssetsJSON][] -export type NetworkIdJSON = 'Testnet' | 'Mainnet' -export type TransactionOutputsJSON = TransactionOutputJSON[] -export type CostModelJSON = string[] -export type VoterJSON = - | { - ConstitutionalCommitteeHotCred: CredTypeJSON - } - | { - DRep: CredTypeJSON - } - | { - StakingPool: string - } -export type VoteKindJSON = 'No' | 'Yes' | 'Abstain' -export type GovernanceActionJSON = - | { - ParameterChangeAction: ParameterChangeActionJSON - } - | { - HardForkInitiationAction: HardForkInitiationActionJSON - } - | { - TreasuryWithdrawalsAction: TreasuryWithdrawalsActionJSON - } - | { - NoConfidenceAction: NoConfidenceActionJSON - } - | { - UpdateCommitteeAction: UpdateCommitteeActionJSON - } - | { - NewConstitutionAction: NewConstitutionActionJSON - } - | { - InfoAction: InfoActionJSON - } -/** - * @minItems 0 - * @maxItems 0 - */ -export type InfoActionJSON = [] -export type TransactionBodiesJSON = TransactionBodyJSON[] -export type RedeemerTagJSON = 'Spend' | 'Mint' | 'Cert' | 'Reward' | 'Vote' | 'VotingProposal' -export type TransactionWitnessSetsJSON = TransactionWitnessSetJSON[] - -export interface BlockJSON { - auxiliary_data_set: { - [k: string]: AuxiliaryDataJSON - } - header: HeaderJSON - invalid_transactions: number[] - transaction_bodies: TransactionBodiesJSON - transaction_witness_sets: TransactionWitnessSetsJSON -} -export interface HeaderJSON { - body_signature: string - header_body: HeaderBodyJSON -} -export interface HeaderBodyJSON { - block_body_hash: string - block_body_size: number - block_number: number - issuer_vkey: VkeyJSON - leader_cert: HeaderLeaderCertEnumJSON - operational_cert: OperationalCertJSON - prev_hash?: string | null - protocol_version: ProtocolVersionJSON - slot: string - vrf_vkey: string -} -export interface VRFCertJSON { - output: number[] - proof: number[] -} -export interface OperationalCertJSON { - hot_vkey: string - kes_period: number - sequence_number: number - sigma: string -} -export interface ProtocolVersionJSON { - major: number - minor: number -} -export interface TransactionBodyJSON { - auxiliary_data_hash?: string | null - certs?: CertificateJSON[] | null - collateral?: TransactionInputJSON[] | null - collateral_return?: TransactionOutputJSON | null - current_treasury_value?: string | null - donation?: string | null - fee: string - inputs: TransactionInputJSON[] - mint?: MintJSON | null - network_id?: NetworkIdJSON | null - outputs: TransactionOutputsJSON - reference_inputs?: TransactionInputJSON[] | null - required_signers?: string[] | null - script_data_hash?: string | null - total_collateral?: string | null - ttl?: string | null - update?: UpdateJSON | null - validity_start_interval?: string | null - voting_procedures?: VoterVotesJSON[] | null - voting_proposals?: VotingProposalJSON[] | null - withdrawals?: { - [k: string]: string - } | null -} -export interface StakeRegistrationJSON { - coin?: string | null - stake_credential: CredTypeJSON -} -export interface StakeDeregistrationJSON { - coin?: string | null - stake_credential: CredTypeJSON -} -export interface StakeDelegationJSON { - pool_keyhash: string - stake_credential: CredTypeJSON -} -export interface PoolRegistrationJSON { - pool_params: PoolParamsJSON -} -export interface PoolParamsJSON { - cost: string - margin: UnitIntervalJSON - operator: string - pledge: string - pool_metadata?: PoolMetadataJSON | null - pool_owners: string[] - relays: RelaysJSON - reward_account: string - vrf_keyhash: string -} -export interface UnitIntervalJSON { - denominator: string - numerator: string -} -export interface PoolMetadataJSON { - pool_metadata_hash: string - url: URLJSON -} -export interface SingleHostAddrJSON { - ipv4?: Ipv4JSON | null - ipv6?: Ipv6JSON | null - port?: number | null -} -export interface SingleHostNameJSON { - dns_name: DNSRecordAorAAAAJSON - port?: number | null -} -export interface MultiHostNameJSON { - dns_name: DNSRecordSRVJSON -} -export interface PoolRetirementJSON { - epoch: number - pool_keyhash: string -} -export interface GenesisKeyDelegationJSON { - genesis_delegate_hash: string - genesishash: string - vrf_keyhash: string -} -export interface MoveInstantaneousRewardsCertJSON { - move_instantaneous_reward: MoveInstantaneousRewardJSON -} -export interface MoveInstantaneousRewardJSON { - pot: MIRPotJSON - variant: MIREnumJSON -} -export interface StakeToCoinJSON { - amount: string - stake_cred: CredTypeJSON -} -export interface CommitteeHotAuthJSON { - committee_cold_credential: CredTypeJSON - committee_hot_credential: CredTypeJSON -} -export interface CommitteeColdResignJSON { - anchor?: AnchorJSON | null - committee_cold_credential: CredTypeJSON -} -export interface DRepDeregistrationJSON { - coin: string - voting_credential: CredTypeJSON -} -export interface DRepRegistrationJSON { - anchor?: AnchorJSON | null - coin: string - voting_credential: CredTypeJSON -} -export interface DRepUpdateJSON { - anchor?: AnchorJSON | null - voting_credential: CredTypeJSON -} -export interface StakeAndVoteDelegationJSON { - drep: DRepJSON - pool_keyhash: string - stake_credential: CredTypeJSON -} -export interface StakeRegistrationAndDelegationJSON { - coin: string - pool_keyhash: string - stake_credential: CredTypeJSON -} -export interface StakeVoteRegistrationAndDelegationJSON { - coin: string - drep: DRepJSON - pool_keyhash: string - stake_credential: CredTypeJSON -} -export interface VoteDelegationJSON { - drep: DRepJSON - stake_credential: CredTypeJSON -} -export interface VoteRegistrationAndDelegationJSON { - coin: string - drep: DRepJSON - stake_credential: CredTypeJSON -} -export interface TransactionInputJSON { - index: number - transaction_id: string -} -export interface TransactionOutputJSON { - address: string - amount: ValueJSON - plutus_data?: DataOptionJSON | null - script_ref?: ScriptRefJSON | null -} -export interface ValueJSON { - coin: string - multiasset?: MultiAssetJSON | null -} -export interface MultiAssetJSON { - [k: string]: AssetsJSON -} -export interface MintAssetsJSON { - [k: string]: string -} -export interface UpdateJSON { - epoch: number - proposed_protocol_parameter_updates: { - [k: string]: ProtocolParamUpdateJSON - } -} -export interface ProtocolParamUpdateJSON { - ada_per_utxo_byte?: string | null - collateral_percentage?: number | null - committee_term_limit?: number | null - cost_models?: CostmdlsJSON | null - d?: UnitIntervalJSON | null - drep_deposit?: string | null - drep_inactivity_period?: number | null - drep_voting_thresholds?: DRepVotingThresholdsJSON | null - execution_costs?: ExUnitPricesJSON | null - expansion_rate?: UnitIntervalJSON | null - extra_entropy?: NonceJSON | null - governance_action_deposit?: string | null - governance_action_validity_period?: number | null - key_deposit?: string | null - max_block_body_size?: number | null - max_block_ex_units?: ExUnitsJSON | null - max_block_header_size?: number | null - max_collateral_inputs?: number | null - max_epoch?: number | null - max_tx_ex_units?: ExUnitsJSON | null - max_tx_size?: number | null - max_value_size?: number | null - min_committee_size?: number | null - min_pool_cost?: string | null - minfee_a?: string | null - minfee_b?: string | null - n_opt?: number | null - pool_deposit?: string | null - pool_pledge_influence?: UnitIntervalJSON | null - pool_voting_thresholds?: PoolVotingThresholdsJSON | null - protocol_version?: ProtocolVersionJSON | null - ref_script_coins_per_byte?: UnitIntervalJSON | null - treasury_growth_rate?: UnitIntervalJSON | null -} -export interface CostmdlsJSON { - [k: string]: CostModelJSON -} -export interface DRepVotingThresholdsJSON { - committee_no_confidence: UnitIntervalJSON - committee_normal: UnitIntervalJSON - hard_fork_initiation: UnitIntervalJSON - motion_no_confidence: UnitIntervalJSON - pp_economic_group: UnitIntervalJSON - pp_governance_group: UnitIntervalJSON - pp_network_group: UnitIntervalJSON - pp_technical_group: UnitIntervalJSON - treasury_withdrawal: UnitIntervalJSON - update_constitution: UnitIntervalJSON -} -export interface ExUnitPricesJSON { - mem_price: UnitIntervalJSON - step_price: UnitIntervalJSON -} -export interface NonceJSON { - /** - * @minItems 32 - * @maxItems 32 - */ - hash?: - | [ - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - number, - ] - | null -} -export interface ExUnitsJSON { - mem: string - steps: string -} -export interface PoolVotingThresholdsJSON { - committee_no_confidence: UnitIntervalJSON - committee_normal: UnitIntervalJSON - hard_fork_initiation: UnitIntervalJSON - motion_no_confidence: UnitIntervalJSON - security_relevant_threshold: UnitIntervalJSON -} -export interface VoterVotesJSON { - voter: VoterJSON - votes: VoteJSON[] -} -export interface VoteJSON { - action_id: GovernanceActionIdJSON - voting_procedure: VotingProcedureJSON -} -export interface GovernanceActionIdJSON { - index: number - transaction_id: string -} -export interface VotingProcedureJSON { - anchor?: AnchorJSON | null - vote: VoteKindJSON -} -export interface VotingProposalJSON { - anchor: AnchorJSON - deposit: string - governance_action: GovernanceActionJSON - reward_account: string -} -export interface ParameterChangeActionJSON { - gov_action_id?: GovernanceActionIdJSON | null - policy_hash?: string | null - protocol_param_updates: ProtocolParamUpdateJSON -} -export interface HardForkInitiationActionJSON { - gov_action_id?: GovernanceActionIdJSON | null - protocol_version: ProtocolVersionJSON -} -export interface TreasuryWithdrawalsActionJSON { - policy_hash?: string | null - withdrawals: TreasuryWithdrawalsJSON -} -export interface TreasuryWithdrawalsJSON { - [k: string]: string -} -export interface NoConfidenceActionJSON { - gov_action_id?: GovernanceActionIdJSON | null -} -export interface UpdateCommitteeActionJSON { - committee: CommitteeJSON - gov_action_id?: GovernanceActionIdJSON | null - members_to_remove: CredTypeJSON[] -} -export interface CommitteeJSON { - members: CommitteeMemberJSON[] - quorum_threshold: UnitIntervalJSON -} -export interface CommitteeMemberJSON { - stake_credential: CredTypeJSON - term_limit: number -} -export interface NewConstitutionActionJSON { - constitution: ConstitutionJSON - gov_action_id?: GovernanceActionIdJSON | null -} -export interface ConstitutionJSON { - anchor: AnchorJSON - script_hash?: string | null -} -export interface TransactionWitnessSetJSON { - bootstraps?: BootstrapWitnessJSON[] | null - native_scripts?: NativeScriptsJSON | null - plutus_data?: PlutusListJSON | null - plutus_scripts?: PlutusScriptsJSON | null - redeemers?: RedeemerJSON[] | null - vkeys?: VkeywitnessJSON[] | null -} -export interface BootstrapWitnessJSON { - attributes: number[] - chain_code: number[] - signature: string - vkey: VkeyJSON -} -export interface PlutusListJSON { - definite_encoding?: boolean | null - elems: string[] -} -export interface RedeemerJSON { - data: string - ex_units: ExUnitsJSON - index: string - tag: RedeemerTagJSON -} -export interface VkeywitnessJSON { - signature: string - vkey: VkeyJSON -} -export type BlockHashJSON = string -export type BootstrapWitnessesJSON = BootstrapWitnessJSON[] - -export type CertificateEnumJSON = - | { - StakeRegistration: StakeRegistrationJSON - } - | { - StakeDeregistration: StakeDeregistrationJSON - } - | { - StakeDelegation: StakeDelegationJSON - } - | { - PoolRegistration: PoolRegistrationJSON - } - | { - PoolRetirement: PoolRetirementJSON - } - | { - GenesisKeyDelegation: GenesisKeyDelegationJSON - } - | { - MoveInstantaneousRewardsCert: MoveInstantaneousRewardsCertJSON - } - | { - CommitteeHotAuth: CommitteeHotAuthJSON - } - | { - CommitteeColdResign: CommitteeColdResignJSON - } - | { - DRepDeregistration: DRepDeregistrationJSON - } - | { - DRepRegistration: DRepRegistrationJSON - } - | { - DRepUpdate: DRepUpdateJSON - } - | { - StakeAndVoteDelegation: StakeAndVoteDelegationJSON - } - | { - StakeRegistrationAndDelegation: StakeRegistrationAndDelegationJSON - } - | { - StakeVoteRegistrationAndDelegation: StakeVoteRegistrationAndDelegationJSON - } - | { - VoteDelegation: VoteDelegationJSON - } - | { - VoteRegistrationAndDelegation: VoteRegistrationAndDelegationJSON - } -export type CertificatesJSON = CertificateJSON[] - -export type CredentialJSON = CredTypeJSON -export type CredentialsJSON = CredTypeJSON[] -export type DRepEnumJSON = - | ('AlwaysAbstain' | 'AlwaysNoConfidence') - | { - KeyHash: string - } - | { - ScriptHash: string - } -export type DataHashJSON = string -export type Ed25519KeyHashJSON = string -export type Ed25519KeyHashesJSON = string[] -export type Ed25519SignatureJSON = string -export interface GeneralTransactionMetadataJSON { - [k: string]: string -} -export type GenesisDelegateHashJSON = string -export type GenesisHashJSON = string -export type GenesisHashesJSON = string[] -export type GovernanceActionEnumJSON = - | { - ParameterChangeAction: ParameterChangeActionJSON - } - | { - HardForkInitiationAction: HardForkInitiationActionJSON - } - | { - TreasuryWithdrawalsAction: TreasuryWithdrawalsActionJSON - } - | { - NoConfidenceAction: NoConfidenceActionJSON - } - | { - UpdateCommitteeAction: UpdateCommitteeActionJSON - } - | { - NewConstitutionAction: NewConstitutionActionJSON - } - | { - InfoAction: InfoActionJSON - } -export type GovernanceActionIdsJSON = GovernanceActionIdJSON[] - -export type IntJSON = string -/** - * @minItems 4 - * @maxItems 4 - */ -export type KESVKeyJSON = string -export type LanguageJSON = LanguageKindJSON -export type LanguageKindJSON = 'PlutusV1' | 'PlutusV2' | 'PlutusV3' -export type LanguagesJSON = LanguageJSON[] -export type MIRToStakeCredentialsJSON = StakeToCoinJSON[] - -export type MintsAssetsJSON = MintAssetsJSON[] - -export type NetworkIdKindJSON = 'Testnet' | 'Mainnet' -export type PlutusScriptJSON = string -export type PoolMetadataHashJSON = string -export interface ProposedProtocolParameterUpdatesJSON { - [k: string]: ProtocolParamUpdateJSON -} -export type PublicKeyJSON = string -export type RedeemerTagKindJSON = 'Spend' | 'Mint' | 'Cert' | 'Reward' | 'Vote' | 'VotingProposal' -export type RedeemersJSON = RedeemerJSON[] - -export type RelayEnumJSON = - | { - SingleHostAddr: SingleHostAddrJSON - } - | { - SingleHostName: SingleHostNameJSON - } - | { - MultiHostName: MultiHostNameJSON - } -/** - * @minItems 4 - * @maxItems 4 - */ -export type RewardAddressJSON = string -export type RewardAddressesJSON = string[] -export type ScriptDataHashJSON = string -export type ScriptHashJSON = string -export type ScriptHashesJSON = string[] -export type ScriptRefEnumJSON = - | { - NativeScript: NativeScriptJSON - } - | { - PlutusScript: string - } -export interface TransactionJSON { - auxiliary_data?: AuxiliaryDataJSON | null - body: TransactionBodyJSON - is_valid: boolean - witness_set: TransactionWitnessSetJSON -} -export type TransactionHashJSON = string -export type TransactionInputsJSON = TransactionInputJSON[] - -export type TransactionMetadatumJSON = string -export interface TransactionUnspentOutputJSON { - input: TransactionInputJSON - output: TransactionOutputJSON -} -export type TransactionUnspentOutputsJSON = TransactionUnspentOutputJSON[] - -export type VRFKeyHashJSON = string -export type VRFVKeyJSON = string -export interface VersionedBlockJSON { - block: BlockJSON - era_code: number -} -export type VkeywitnessesJSON = VkeywitnessJSON[] - -export type VoterEnumJSON = - | { - ConstitutionalCommitteeHotCred: CredTypeJSON - } - | { - DRep: CredTypeJSON - } - | { - StakingPool: string - } -export type VotersJSON = VoterJSON[] -export type VotingProceduresJSON = VoterVotesJSON[] - -export type VotingProposalsJSON = VotingProposalJSON[] - -export interface WithdrawalsJSON { - [k: string]: string -} diff --git a/apps/wallet-mobile/src/features/ReviewTransaction/ReviewTransactionNavigator.tsx b/apps/wallet-mobile/src/features/ReviewTransaction/ReviewTransactionNavigator.tsx deleted file mode 100644 index c2c50d55b4..0000000000 --- a/apps/wallet-mobile/src/features/ReviewTransaction/ReviewTransactionNavigator.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import {createStackNavigator} from '@react-navigation/stack' -import {useTheme} from '@yoroi/theme' -import * as React from 'react' -import {StyleSheet} from 'react-native' - -import {KeyboardAvoidingView} from '../../components' -import {defaultStackNavigationOptions, ReviewTransactionRoutes} from '../../kernel/navigation' -import {ReviewTransactionScreen} from './useCases/ReviewTransactionScreen/ReviewTransactionScreen' - -const Stack = createStackNavigator() - -export const ReviewTransactionNavigator = () => { - const {atoms, color} = useTheme() - const styles = useStyles() - - return ( - - - - - - ) -} - -const useStyles = () => { - const {color, atoms} = useTheme() - const styles = StyleSheet.create({ - root: { - ...atoms.flex_1, - backgroundColor: color.bg_color_max, - }, - }) - return styles -} diff --git a/apps/wallet-mobile/src/features/ReviewTransaction/common/formattedTransaction.tsx b/apps/wallet-mobile/src/features/ReviewTransaction/common/formattedTransaction.tsx index d93d4a573d..93de538741 100644 --- a/apps/wallet-mobile/src/features/ReviewTransaction/common/formattedTransaction.tsx +++ b/apps/wallet-mobile/src/features/ReviewTransaction/common/formattedTransaction.tsx @@ -1,10 +1,10 @@ import {isNonNullable} from '@yoroi/common' import {infoExtractName} from '@yoroi/portfolio' +import {Portfolio} from '@yoroi/types' import * as _ from 'lodash' -import {useTokenInfos} from '../../../yoroi-wallets/hooks' -import {asQuantity} from '../../../yoroi-wallets/utils' -import {formatAdaWithText, formatTokenWithText} from '../../../yoroi-wallets/utils/format' +import {asQuantity} from '../../../yoroi-wallets/utils/utils' +import {usePortfolioTokenInfos} from '../../Portfolio/common/hooks/usePortfolioTokenInfos' import {useSelectedWallet} from '../../WalletManager/common/hooks/useSelectedWallet' import {TransactionBody} from './types' @@ -24,7 +24,7 @@ export const useFormattedTransaction = (data: TransactionBody) => { const inputTokenIds = inputs.flatMap((i) => { const receiveUTxO = getUtxoByTxIdAndIndex(i.transaction_id, i.index) - return receiveUTxO?.assets.map((a) => `${a.policyId}.${a.assetId}`) ?? [] + return receiveUTxO?.assets.map((a) => `${a.policyId}.${a.assetId}` as Portfolio.Token.Id) ?? [] }) const outputTokenIds = outputs.flatMap((o) => { @@ -32,25 +32,25 @@ export const useFormattedTransaction = (data: TransactionBody) => { const policyIds = Object.keys(o.amount.multiasset) const tokenIds = policyIds.flatMap((policyId) => { const assetIds = Object.keys(o.amount.multiasset?.[policyId] ?? {}) - return assetIds.map((assetId) => `${policyId}.${assetId}`) + return assetIds.map((assetId) => `${policyId}.${assetId}` as Portfolio.Token.Id) }) return tokenIds }) - const tokenIds = _.uniq([...inputTokenIds, ...outputTokenIds]) - const tokenInfos = useTokenInfos({wallet, tokenIds}) + const tokenIds = _.uniq([...inputTokenIds, ...outputTokenIds]) + const {tokenInfos} = usePortfolioTokenInfos({wallet, tokenIds}, {suspense: true}) const formattedInputs = inputs.map((input) => { const receiveUTxO = getUtxoByTxIdAndIndex(input.transaction_id, input.index) const address = receiveUTxO?.receiver const coin = receiveUTxO?.amount != null ? asQuantity(receiveUTxO.amount) : null - const coinText = coin != null ? formatAdaWithText(coin, wallet.primaryToken) : null const primaryAssets = - coinText != null + coin != null ? [ { - label: coinText, + name: wallet.portfolioPrimaryTokenInfo.name, + label: `${coin} ${wallet.portfolioPrimaryTokenInfo.name}`, quantity: coin, isPrimary: true, }, @@ -60,11 +60,12 @@ export const useFormattedTransaction = (data: TransactionBody) => { const multiAssets = receiveUTxO?.assets .map((a) => { - const tokenInfo = tokenInfos.find((t) => t.id === a.assetId) + const tokenInfo = tokenInfos?.get(a.assetId as Portfolio.Token.Id) if (!tokenInfo) return null const quantity = asQuantity(a.amount) return { - label: formatTokenWithText(quantity, tokenInfo), + name: infoExtractName(tokenInfo), + label: `${quantity} ${infoExtractName(tokenInfo)}`, quantity, isPrimary: false, } @@ -83,27 +84,25 @@ export const useFormattedTransaction = (data: TransactionBody) => { const formattedOutputs = outputs.map((output) => { const address = output.address const coin = asQuantity(output.amount.coin) - const coinText = formatAdaWithText(coin, wallet.primaryToken) - const primaryAssets = - coinText != null - ? [ - { - label: coinText, - quantity: coin, - isPrimary: true, - }, - ] - : [] + const primaryAssets = [ + { + name: wallet.portfolioPrimaryTokenInfo.name, + label: `${coin} ${wallet.portfolioPrimaryTokenInfo.name}`, + quantity: coin, + isPrimary: true, + }, + ] const multiAssets = output.amount.multiasset ? Object.entries(output.amount.multiasset).map(([policyId, assets]) => { return Object.entries(assets).map(([assetId, amount]) => { - const tokenInfo = tokenInfos.find((t) => t.id === `${policyId}.${assetId}`) + const tokenInfo = tokenInfos?.get(`${policyId}.${assetId}`) if (tokenInfo == null) return null const quantity = asQuantity(amount) return { name: infoExtractName(tokenInfo), + label: `${quantity} ${infoExtractName(tokenInfo)}`, quantity, isPrimary: false, } @@ -115,9 +114,16 @@ export const useFormattedTransaction = (data: TransactionBody) => { return {assets, address, ownAddress: address != null && isOwnedAddress(address)} }) - const formattedFee = formatAdaWithText(asQuantity(data?.fee ?? '0'), wallet.primaryToken) + const fee = asQuantity(data?.fee ?? '0') + + const formattedFee = { + name: wallet.portfolioPrimaryTokenInfo.name, + label: `${fee} ${wallet.portfolioPrimaryTokenInfo.name}`, + quantity: fee, + isPrimary: true, + } return {inputs: formattedInputs, outputs: formattedOutputs, fee: formattedFee} } -export type formattedTx = ReturnType +export type FormattedTx = ReturnType diff --git a/apps/wallet-mobile/src/features/ReviewTransaction/common/mocks.ts b/apps/wallet-mobile/src/features/ReviewTransaction/common/mocks.ts index 311b526bcf..899bb3aa5f 100644 --- a/apps/wallet-mobile/src/features/ReviewTransaction/common/mocks.ts +++ b/apps/wallet-mobile/src/features/ReviewTransaction/common/mocks.ts @@ -73,7 +73,7 @@ export const multiAssetsOneReceiver: TransactionBody = { coin: '10000000', multiasset: { cdaaee586376139ee8c3cc4061623968810d177ca5c300afb890b48a: { - '43415354': '10', + '43415354': '5', }, f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a: { '000de1406a6176696275656e6f': '1', @@ -102,7 +102,7 @@ export const multiAssetsOneReceiver: TransactionBody = { '484f534b59': '115930085', }, cdaaee586376139ee8c3cc4061623968810d177ca5c300afb890b48a: { - '43415354': '4498', + '43415354': '4503', }, e0c4c2d7c4a0ed2cf786753fd845dee82c45512cee03e92adfd3fb8d: { '6a6176696275656e6f2e616461': '1', @@ -127,7 +127,7 @@ export const multiAssetsOneReceiver: TransactionBody = { }, ], fee: '189349', - ttl: '220396208', + ttl: '93045', certs: null, withdrawals: null, update: null, diff --git a/apps/wallet-mobile/src/features/ReviewTransaction/useCases/ReviewTransactionScreen/Overview/OverviewTab.tsx b/apps/wallet-mobile/src/features/ReviewTransaction/useCases/ReviewTransactionScreen/Overview/OverviewTab.tsx index 11c34c223d..d7cb2d150d 100644 --- a/apps/wallet-mobile/src/features/ReviewTransaction/useCases/ReviewTransactionScreen/Overview/OverviewTab.tsx +++ b/apps/wallet-mobile/src/features/ReviewTransaction/useCases/ReviewTransactionScreen/Overview/OverviewTab.tsx @@ -4,17 +4,18 @@ import * as React from 'react' import {Linking, StyleSheet, Text, TouchableOpacity, View} from 'react-native' import Svg, {Defs, Image, Pattern, Rect, SvgProps, Use} from 'react-native-svg' -import {Icon} from '../../../../../components' +import {Icon} from '../../../../../components/Icon' import {Space} from '../../../../../components/Space/Space' -import {Warning} from '../../../../../components/Warning' +import {Warning} from '../../../../../components/Warning/Warning' import {useCopy} from '../../../../../hooks/useCopy' import {useRewardAddress} from '../../../../../yoroi-wallets/hooks' +import {Quantities} from '../../../../../yoroi-wallets/utils/utils' import {useSelectedWallet} from '../../../../WalletManager/common/hooks/useSelectedWallet' import {useWalletManager} from '../../../../WalletManager/context/WalletManagerProvider' import {Divider} from '../../../common/Divider' -import {formattedTx} from '../../../common/formattedTransaction' +import {FormattedTx} from '../../../common/formattedTransaction' -export const OverviewTab = ({tx, createdBy}: {tx: formattedTx; createdBy?: React.ReactNode}) => { +export const OverviewTab = ({tx, createdBy}: {tx: FormattedTx; createdBy?: React.ReactNode}) => { const {styles} = useStyles() return ( @@ -33,7 +34,7 @@ export const OverviewTab = ({tx, createdBy}: {tx: formattedTx; createdBy?: React )} - + @@ -59,13 +60,14 @@ const WalletInfoItem = () => { const {wallet, meta} = useSelectedWallet() const {walletManager} = useWalletManager() const {plate, seed} = walletManager.checksum(wallet.publicKeyHex) + const seedImage = new Blockies({seed}).asBase64() return ( Wallet - + @@ -108,7 +110,7 @@ const CreatedByInfoItem = () => { ) } -const SenderTokensSection = ({tx}: {tx: formattedTx}) => { +const SenderTokensSection = ({tx}: {tx: FormattedTx}) => { const {wallet} = useSelectedWallet() const rewardAddress = useRewardAddress(wallet) @@ -142,8 +144,31 @@ const Address = ({address}: {address: string}) => { ) } -const SenderTokensItems = ({tx}: {tx: formattedTx}) => { +const SenderTokensItems = ({tx}: {tx: FormattedTx}) => { const {styles} = useStyles() + const {wallet} = useSelectedWallet() + + const totalPrimaryTokenSent = React.useMemo( + () => + tx.outputs + .filter((output) => !output.ownAddress) + .flatMap((output) => output.assets.filter((asset) => asset.isPrimary)) + .reduce((previous, current) => Quantities.sum([previous, current.quantity]), Quantities.zero), + [tx.outputs], + ) + const totalPrimaryTokenSpent = React.useMemo( + () => Quantities.sum([totalPrimaryTokenSent, tx.fee.quantity]), + [totalPrimaryTokenSent, tx.fee.quantity], + ) + const totalPrimaryTokenSpentLabel = `${totalPrimaryTokenSpent} ${wallet.portfolioPrimaryTokenInfo.name}` + + const notPrimaryTokenSent = React.useMemo( + () => + tx.outputs + .filter((output) => !output.ownAddress) + .flatMap((output) => output.assets.filter((asset) => !asset.isPrimary)), + [tx.outputs], + ) return ( @@ -152,25 +177,11 @@ const SenderTokensItems = ({tx}: {tx: formattedTx}) => { - - - - - - - - - - - - - - - - - + - + {notPrimaryTokenSent.map((token) => ( + + ))} ) diff --git a/apps/wallet-mobile/src/features/ReviewTransaction/useCases/ReviewTransactionScreen/ReviewTransactionScreen.tsx b/apps/wallet-mobile/src/features/ReviewTransaction/useCases/ReviewTransactionScreen/ReviewTransactionScreen.tsx index 38833abb88..36eeec40d0 100644 --- a/apps/wallet-mobile/src/features/ReviewTransaction/useCases/ReviewTransactionScreen/ReviewTransactionScreen.tsx +++ b/apps/wallet-mobile/src/features/ReviewTransaction/useCases/ReviewTransactionScreen/ReviewTransactionScreen.tsx @@ -3,7 +3,7 @@ import * as React from 'react' import {StyleSheet} from 'react-native' import {SafeArea} from '../../../../components/SafeArea' -import {Tab, Tabs} from '../../../../components/Tabs' +import {Tab, Tabs} from '../../../../components/Tabs/Tabs' import {Divider} from '../../common/Divider' import {useFormattedTransaction} from '../../common/formattedTransaction' import {multiAssetsOneReceiver} from '../../common/mocks' diff --git a/apps/wallet-mobile/src/features/Transactions/TxHistoryNavigator.tsx b/apps/wallet-mobile/src/features/Transactions/TxHistoryNavigator.tsx index 7875a3457b..ed81857507 100644 --- a/apps/wallet-mobile/src/features/Transactions/TxHistoryNavigator.tsx +++ b/apps/wallet-mobile/src/features/Transactions/TxHistoryNavigator.tsx @@ -28,7 +28,7 @@ import {ReceiveProvider} from '../Receive/common/ReceiveProvider' import {DescribeSelectedAddressScreen} from '../Receive/useCases/DescribeSelectedAddressScreen' import {ListMultipleAddressesScreen} from '../Receive/useCases/ListMultipleAddressesScreen' import {RequestSpecificAmountScreen} from '../Receive/useCases/RequestSpecificAmountScreen' -import {ReviewTransactionNavigator} from '../ReviewTransaction/ReviewTransactionNavigator' +import {ReviewTransactionScreen} from '../ReviewTransaction/useCases/ReviewTransactionScreen/ReviewTransactionScreen' import {CodeScannerButton} from '../Scan/common/CodeScannerButton' import {ScanCodeScreen} from '../Scan/useCases/ScanCodeScreen' import {ShowCameraPermissionDeniedScreen} from '../Scan/useCases/ShowCameraPermissionDeniedScreen/ShowCameraPermissionDeniedScreen' @@ -222,7 +222,7 @@ export const TxHistoryNavigator = () => { >>>>>> origin/develop +import {CardanoMobile} from '../wallets' const crashReportsStorageKey = 'sendCrashReports' diff --git a/apps/wallet-mobile/translations/messages/src/features/Transactions/TxHistoryNavigator.json b/apps/wallet-mobile/translations/messages/src/features/Transactions/TxHistoryNavigator.json index c7bbf3a72a..676a07292c 100644 --- a/apps/wallet-mobile/translations/messages/src/features/Transactions/TxHistoryNavigator.json +++ b/apps/wallet-mobile/translations/messages/src/features/Transactions/TxHistoryNavigator.json @@ -6,12 +6,12 @@ "start": { "line": 419, "column": 16, - "index": 15421 + "index": 15445 }, "end": { "line": 422, "column": 3, - "index": 15510 + "index": 15534 } }, { @@ -21,12 +21,12 @@ "start": { "line": 423, "column": 32, - "index": 15544 + "index": 15568 }, "end": { "line": 426, "column": 3, - "index": 15657 + "index": 15681 } }, { @@ -36,12 +36,12 @@ "start": { "line": 427, "column": 13, - "index": 15672 + "index": 15696 }, "end": { "line": 430, "column": 3, - "index": 15745 + "index": 15769 } }, { @@ -51,12 +51,12 @@ "start": { "line": 431, "column": 17, - "index": 15764 + "index": 15788 }, "end": { "line": 434, "column": 3, - "index": 15841 + "index": 15865 } }, { @@ -66,12 +66,12 @@ "start": { "line": 435, "column": 15, - "index": 15858 + "index": 15882 }, "end": { "line": 438, "column": 3, - "index": 15931 + "index": 15955 } }, { @@ -81,12 +81,12 @@ "start": { "line": 439, "column": 21, - "index": 15954 + "index": 15978 }, "end": { "line": 442, "column": 3, - "index": 16049 + "index": 16073 } }, { @@ -96,12 +96,12 @@ "start": { "line": 443, "column": 14, - "index": 16065 + "index": 16089 }, "end": { "line": 446, "column": 3, - "index": 16146 + "index": 16170 } }, { @@ -111,12 +111,12 @@ "start": { "line": 447, "column": 13, - "index": 16161 + "index": 16185 }, "end": { "line": 450, "column": 3, - "index": 16241 + "index": 16265 } }, { @@ -126,12 +126,12 @@ "start": { "line": 451, "column": 18, - "index": 16261 + "index": 16285 }, "end": { "line": 454, "column": 3, - "index": 16362 + "index": 16386 } }, { @@ -141,12 +141,12 @@ "start": { "line": 455, "column": 20, - "index": 16384 + "index": 16408 }, "end": { "line": 458, "column": 3, - "index": 16473 + "index": 16497 } }, { @@ -156,12 +156,12 @@ "start": { "line": 459, "column": 26, - "index": 16501 + "index": 16525 }, "end": { "line": 462, "column": 3, - "index": 16602 + "index": 16626 } }, { @@ -171,12 +171,12 @@ "start": { "line": 463, "column": 19, - "index": 16623 + "index": 16647 }, "end": { "line": 466, "column": 3, - "index": 16716 + "index": 16740 } }, { @@ -186,12 +186,12 @@ "start": { "line": 467, "column": 16, - "index": 16734 + "index": 16758 }, "end": { "line": 470, "column": 3, - "index": 16820 + "index": 16844 } }, { @@ -201,12 +201,12 @@ "start": { "line": 471, "column": 19, - "index": 16841 + "index": 16865 }, "end": { "line": 477, "column": 3, - "index": 17079 + "index": 17103 } }, { @@ -216,12 +216,12 @@ "start": { "line": 478, "column": 27, - "index": 17108 + "index": 17132 }, "end": { "line": 481, "column": 3, - "index": 17201 + "index": 17225 } }, { @@ -231,12 +231,12 @@ "start": { "line": 482, "column": 13, - "index": 17216 + "index": 17240 }, "end": { "line": 485, "column": 3, - "index": 17291 + "index": 17315 } }, { @@ -246,12 +246,12 @@ "start": { "line": 486, "column": 25, - "index": 17318 + "index": 17342 }, "end": { "line": 489, "column": 3, - "index": 17392 + "index": 17416 } }, { @@ -261,12 +261,12 @@ "start": { "line": 490, "column": 18, - "index": 17412 + "index": 17436 }, "end": { "line": 493, "column": 3, - "index": 17526 + "index": 17550 } }, { @@ -276,12 +276,12 @@ "start": { "line": 494, "column": 28, - "index": 17556 + "index": 17580 }, "end": { "line": 497, "column": 3, - "index": 17652 + "index": 17676 } }, { @@ -291,12 +291,12 @@ "start": { "line": 498, "column": 29, - "index": 17683 + "index": 17707 }, "end": { "line": 501, "column": 3, - "index": 17791 + "index": 17815 } }, { @@ -306,12 +306,12 @@ "start": { "line": 502, "column": 30, - "index": 17823 + "index": 17847 }, "end": { "line": 505, "column": 3, - "index": 17933 + "index": 17957 } }, { @@ -321,12 +321,12 @@ "start": { "line": 506, "column": 18, - "index": 17953 + "index": 17977 }, "end": { "line": 509, "column": 3, - "index": 18047 + "index": 18071 } } ] \ No newline at end of file From 7c43a3082d211023db2fcee8e260fbd389544afb Mon Sep 17 00:00:00 2001 From: Javier Bueno Date: Fri, 20 Sep 2024 11:03:11 +0200 Subject: [PATCH 003/113] feat(tx-review): add confirm tx to send funnel --- apps/wallet-mobile/src/WalletNavigator.tsx | 3 + .../ReviewTransaction/ReviewTransaction.tsx | 2 +- .../common/formattedTransaction.tsx | 129 ----- .../Overview/OverviewTab.tsx | 479 ------------------ .../ReviewTransactionScreen.tsx | 59 --- .../features/ReviewTx/ReviewTxNavigator.tsx | 34 ++ .../src/features/ReviewTx/common/Address.tsx | 46 ++ .../ReviewTx/common/CollapsibleSection.tsx | 44 ++ .../common/Divider.tsx | 16 +- .../features/ReviewTx/common/TokenItem.tsx | 76 +++ .../ReviewTx/common/hooks/useAddressType.tsx | 12 + .../ReviewTx/common/hooks/useFormattedTx.tsx | 228 +++++++++ .../ReviewTx/common/hooks/useOnConfirm.tsx | 71 +++ .../ReviewTx/common/hooks/useStrings.tsx | 11 + .../ReviewTx/common/hooks/useTxBody.tsx | 44 ++ .../common/mocks.ts | 2 +- .../common/types.ts | 39 ++ .../ReviewTxScreen/Overview/OverviewTab.tsx | 344 +++++++++++++ .../ReviewTxScreen/ReviewTxScreen.tsx | 95 ++++ .../ListAmountsToSendScreen.tsx | 28 +- .../Transactions/TxHistoryNavigator.tsx | 4 +- apps/wallet-mobile/src/kernel/navigation.tsx | 29 +- .../src/yoroi-wallets/cardano/utils.ts | 41 ++ .../src/yoroi-wallets/hooks/index.ts | 15 - .../messages/src/WalletNavigator.json | 96 ++-- .../ListAmountsToSendScreen.json | 8 +- .../Transactions/TxHistoryNavigator.json | 88 ++-- 27 files changed, 1245 insertions(+), 798 deletions(-) delete mode 100644 apps/wallet-mobile/src/features/ReviewTransaction/common/formattedTransaction.tsx delete mode 100644 apps/wallet-mobile/src/features/ReviewTransaction/useCases/ReviewTransactionScreen/Overview/OverviewTab.tsx delete mode 100644 apps/wallet-mobile/src/features/ReviewTransaction/useCases/ReviewTransactionScreen/ReviewTransactionScreen.tsx create mode 100644 apps/wallet-mobile/src/features/ReviewTx/ReviewTxNavigator.tsx create mode 100644 apps/wallet-mobile/src/features/ReviewTx/common/Address.tsx create mode 100644 apps/wallet-mobile/src/features/ReviewTx/common/CollapsibleSection.tsx rename apps/wallet-mobile/src/features/{ReviewTransaction => ReviewTx}/common/Divider.tsx (51%) create mode 100644 apps/wallet-mobile/src/features/ReviewTx/common/TokenItem.tsx create mode 100644 apps/wallet-mobile/src/features/ReviewTx/common/hooks/useAddressType.tsx create mode 100644 apps/wallet-mobile/src/features/ReviewTx/common/hooks/useFormattedTx.tsx create mode 100644 apps/wallet-mobile/src/features/ReviewTx/common/hooks/useOnConfirm.tsx create mode 100644 apps/wallet-mobile/src/features/ReviewTx/common/hooks/useStrings.tsx create mode 100644 apps/wallet-mobile/src/features/ReviewTx/common/hooks/useTxBody.tsx rename apps/wallet-mobile/src/features/{ReviewTransaction => ReviewTx}/common/mocks.ts (98%) rename apps/wallet-mobile/src/features/{ReviewTransaction => ReviewTx}/common/types.ts (95%) create mode 100644 apps/wallet-mobile/src/features/ReviewTx/useCases/ReviewTxScreen/Overview/OverviewTab.tsx create mode 100644 apps/wallet-mobile/src/features/ReviewTx/useCases/ReviewTxScreen/ReviewTxScreen.tsx diff --git a/apps/wallet-mobile/src/WalletNavigator.tsx b/apps/wallet-mobile/src/WalletNavigator.tsx index 8a1345c895..bb5620f4b1 100644 --- a/apps/wallet-mobile/src/WalletNavigator.tsx +++ b/apps/wallet-mobile/src/WalletNavigator.tsx @@ -25,6 +25,7 @@ import {useLinksShowActionResult} from './features/Links/common/useLinksShowActi import {MenuNavigator} from './features/Menu/Menu' import {PortfolioNavigator} from './features/Portfolio/PortfolioNavigator' import {CatalystNavigator} from './features/RegisterCatalyst/CatalystNavigator' +import {ReviewTxNavigator} from './features/ReviewTx/ReviewTxNavigator' import {SearchProvider} from './features/Search/SearchContext' import {SettingsScreenNavigator} from './features/Settings' import {NetworkTag} from './features/Settings/ChangeNetwork/NetworkTag' @@ -260,6 +261,8 @@ export const WalletNavigator = () => { + + { }, [promptRootKey]) } -const useSignTxWithHW = () => { +export const useSignTxWithHW = () => { const {confirmHWConnection, closeModal} = useConfirmHWConnectionModal() const {wallet, meta} = useSelectedWallet() diff --git a/apps/wallet-mobile/src/features/ReviewTransaction/common/formattedTransaction.tsx b/apps/wallet-mobile/src/features/ReviewTransaction/common/formattedTransaction.tsx deleted file mode 100644 index 93de538741..0000000000 --- a/apps/wallet-mobile/src/features/ReviewTransaction/common/formattedTransaction.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import {isNonNullable} from '@yoroi/common' -import {infoExtractName} from '@yoroi/portfolio' -import {Portfolio} from '@yoroi/types' -import * as _ from 'lodash' - -import {asQuantity} from '../../../yoroi-wallets/utils/utils' -import {usePortfolioTokenInfos} from '../../Portfolio/common/hooks/usePortfolioTokenInfos' -import {useSelectedWallet} from '../../WalletManager/common/hooks/useSelectedWallet' -import {TransactionBody} from './types' - -export const useFormattedTransaction = (data: TransactionBody) => { - const {wallet} = useSelectedWallet() - - const inputs = data?.inputs ?? [] - const outputs = data?.outputs ?? [] - - const getUtxoByTxIdAndIndex = (txId: string, index: number) => { - return wallet.utxos.find((u) => u.tx_hash === txId && u.tx_index === index) - } - - const isOwnedAddress = (bech32Address: string) => { - return wallet.internalAddresses.includes(bech32Address) || wallet.externalAddresses.includes(bech32Address) - } - - const inputTokenIds = inputs.flatMap((i) => { - const receiveUTxO = getUtxoByTxIdAndIndex(i.transaction_id, i.index) - return receiveUTxO?.assets.map((a) => `${a.policyId}.${a.assetId}` as Portfolio.Token.Id) ?? [] - }) - - const outputTokenIds = outputs.flatMap((o) => { - if (!o.amount.multiasset) return [] - const policyIds = Object.keys(o.amount.multiasset) - const tokenIds = policyIds.flatMap((policyId) => { - const assetIds = Object.keys(o.amount.multiasset?.[policyId] ?? {}) - return assetIds.map((assetId) => `${policyId}.${assetId}` as Portfolio.Token.Id) - }) - return tokenIds - }) - - const tokenIds = _.uniq([...inputTokenIds, ...outputTokenIds]) - const {tokenInfos} = usePortfolioTokenInfos({wallet, tokenIds}, {suspense: true}) - - const formattedInputs = inputs.map((input) => { - const receiveUTxO = getUtxoByTxIdAndIndex(input.transaction_id, input.index) - const address = receiveUTxO?.receiver - const coin = receiveUTxO?.amount != null ? asQuantity(receiveUTxO.amount) : null - - const primaryAssets = - coin != null - ? [ - { - name: wallet.portfolioPrimaryTokenInfo.name, - label: `${coin} ${wallet.portfolioPrimaryTokenInfo.name}`, - quantity: coin, - isPrimary: true, - }, - ] - : [] - - const multiAssets = - receiveUTxO?.assets - .map((a) => { - const tokenInfo = tokenInfos?.get(a.assetId as Portfolio.Token.Id) - if (!tokenInfo) return null - const quantity = asQuantity(a.amount) - return { - name: infoExtractName(tokenInfo), - label: `${quantity} ${infoExtractName(tokenInfo)}`, - quantity, - isPrimary: false, - } - }) - .filter(Boolean) ?? [] - - return { - assets: [...primaryAssets, ...multiAssets].filter(isNonNullable), - address, - ownAddress: address != null && isOwnedAddress(address), - txIndex: input.index, - txHash: input.transaction_id, - } - }) - - const formattedOutputs = outputs.map((output) => { - const address = output.address - const coin = asQuantity(output.amount.coin) - - const primaryAssets = [ - { - name: wallet.portfolioPrimaryTokenInfo.name, - label: `${coin} ${wallet.portfolioPrimaryTokenInfo.name}`, - quantity: coin, - isPrimary: true, - }, - ] - - const multiAssets = output.amount.multiasset - ? Object.entries(output.amount.multiasset).map(([policyId, assets]) => { - return Object.entries(assets).map(([assetId, amount]) => { - const tokenInfo = tokenInfos?.get(`${policyId}.${assetId}`) - if (tokenInfo == null) return null - const quantity = asQuantity(amount) - return { - name: infoExtractName(tokenInfo), - label: `${quantity} ${infoExtractName(tokenInfo)}`, - quantity, - isPrimary: false, - } - }) - }) - : [] - - const assets = [...primaryAssets, ...multiAssets.flat()].filter(isNonNullable) - return {assets, address, ownAddress: address != null && isOwnedAddress(address)} - }) - - const fee = asQuantity(data?.fee ?? '0') - - const formattedFee = { - name: wallet.portfolioPrimaryTokenInfo.name, - label: `${fee} ${wallet.portfolioPrimaryTokenInfo.name}`, - quantity: fee, - isPrimary: true, - } - - return {inputs: formattedInputs, outputs: formattedOutputs, fee: formattedFee} -} - -export type FormattedTx = ReturnType diff --git a/apps/wallet-mobile/src/features/ReviewTransaction/useCases/ReviewTransactionScreen/Overview/OverviewTab.tsx b/apps/wallet-mobile/src/features/ReviewTransaction/useCases/ReviewTransactionScreen/Overview/OverviewTab.tsx deleted file mode 100644 index d7cb2d150d..0000000000 --- a/apps/wallet-mobile/src/features/ReviewTransaction/useCases/ReviewTransactionScreen/Overview/OverviewTab.tsx +++ /dev/null @@ -1,479 +0,0 @@ -import {Blockies} from '@yoroi/identicon' -import {useTheme} from '@yoroi/theme' -import * as React from 'react' -import {Linking, StyleSheet, Text, TouchableOpacity, View} from 'react-native' -import Svg, {Defs, Image, Pattern, Rect, SvgProps, Use} from 'react-native-svg' - -import {Icon} from '../../../../../components/Icon' -import {Space} from '../../../../../components/Space/Space' -import {Warning} from '../../../../../components/Warning/Warning' -import {useCopy} from '../../../../../hooks/useCopy' -import {useRewardAddress} from '../../../../../yoroi-wallets/hooks' -import {Quantities} from '../../../../../yoroi-wallets/utils/utils' -import {useSelectedWallet} from '../../../../WalletManager/common/hooks/useSelectedWallet' -import {useWalletManager} from '../../../../WalletManager/context/WalletManagerProvider' -import {Divider} from '../../../common/Divider' -import {FormattedTx} from '../../../common/formattedTransaction' - -export const OverviewTab = ({tx, createdBy}: {tx: FormattedTx; createdBy?: React.ReactNode}) => { - const {styles} = useStyles() - - return ( - - - - - - - - {createdBy !== undefined && ( - <> - - - - - )} - - - - - - - - - - - - - - - - - - - - ) -} - -const WalletInfoItem = () => { - const {styles} = useStyles() - const {wallet, meta} = useSelectedWallet() - const {walletManager} = useWalletManager() - const {plate, seed} = walletManager.checksum(wallet.publicKeyHex) - const seedImage = new Blockies({seed}).asBase64() - - return ( - - Wallet - - - - - - - {`${plate} | ${meta.name}`} - - - ) -} - -const FeeInfoItem = ({fee}: {fee: string}) => { - const {styles} = useStyles() - - return ( - - Fee - - {fee} - - ) -} - -// TODO (for dapps) -const CreatedByInfoItem = () => { - const {styles} = useStyles() - - return ( - - Created By - - - - - - - Linking.openURL('https://google.com')}> - dapp.org - - - - ) -} - -const SenderTokensSection = ({tx}: {tx: FormattedTx}) => { - const {wallet} = useSelectedWallet() - const rewardAddress = useRewardAddress(wallet) - - return ( - - - -
- - - - - - ) -} - -const Address = ({address}: {address: string}) => { - const {styles, colors} = useStyles() - const [, copy] = useCopy() - - return ( - - - {address} - - - copy(address)} activeOpacity={0.5}> - - - - ) -} - -const SenderTokensItems = ({tx}: {tx: FormattedTx}) => { - const {styles} = useStyles() - const {wallet} = useSelectedWallet() - - const totalPrimaryTokenSent = React.useMemo( - () => - tx.outputs - .filter((output) => !output.ownAddress) - .flatMap((output) => output.assets.filter((asset) => asset.isPrimary)) - .reduce((previous, current) => Quantities.sum([previous, current.quantity]), Quantities.zero), - [tx.outputs], - ) - const totalPrimaryTokenSpent = React.useMemo( - () => Quantities.sum([totalPrimaryTokenSent, tx.fee.quantity]), - [totalPrimaryTokenSent, tx.fee.quantity], - ) - const totalPrimaryTokenSpentLabel = `${totalPrimaryTokenSpent} ${wallet.portfolioPrimaryTokenInfo.name}` - - const notPrimaryTokenSent = React.useMemo( - () => - tx.outputs - .filter((output) => !output.ownAddress) - .flatMap((output) => output.assets.filter((asset) => !asset.isPrimary)), - [tx.outputs], - ) - - return ( - - - - - - - - - {notPrimaryTokenSent.map((token) => ( - - ))} - - - ) -} - -const SenderTokensSectionLabel = () => { - const {styles, colors} = useStyles() - - return ( - - - - - - Send - - ) -} - -const ReceiverTokensSectionLabel = () => { - const {styles, colors} = useStyles() - - return ( - - - - - - Receive - - ) -} - -const ReceiverTokensSection = () => { - const {styles, colors} = useStyles() - - const isRegularAdress = true - const isMultiReceiver = true - - if (isMultiReceiver) { - return ( - <> - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) - } - - return ( - <> - - - - {isRegularAdress ? `To` : 'To script'}: - - - stake1u948jr02falxxqphnv3g3rkd3mdzqmtqq3x0tjl39m7dqngqg0fxp - - - - - - ) -} - -const TokenItem = ({ - isPrimaryToken = true, - isSent = true, - value, -}: { - isPrimaryToken?: boolean - isSent?: boolean - value: string -}) => { - const {styles} = useStyles() - - if (!isSent) - return ( - - - {value} - - - ) - - return ( - - {value} - - ) -} - -const CollapsibleSection = ({label, children}: {label: string; children: React.ReactNode}) => { - const {styles, colors} = useStyles() - const [isOpen, setIsOpen] = React.useState(false) - - return ( - <> - - {label} - - setIsOpen((isOpen) => !isOpen)}> - - - - - {isOpen && children} - - ) -} - -const useStyles = () => { - const {atoms, color} = useTheme() - const styles = StyleSheet.create({ - root: { - ...atoms.px_lg, - }, - infoItem: { - ...atoms.flex_row, - ...atoms.justify_between, - }, - infoLabel: { - ...atoms.body_2_md_regular, - color: color.gray_600, - }, - walletInfoText: { - ...atoms.body_2_md_medium, - color: color.text_primary_medium, - }, - plate: { - ...atoms.flex_row, - ...atoms.align_center, - }, - fee: { - color: color.gray_900, - ...atoms.body_2_md_regular, - }, - link: { - color: color.text_primary_medium, - ...atoms.body_2_md_medium, - }, - sectionHeader: { - ...atoms.flex_row, - ...atoms.justify_between, - }, - myWalletAddress: { - ...atoms.flex_row, - ...atoms.align_center, - ...atoms.flex_row, - ...atoms.justify_between, - }, - myWalletAddressText: { - ...atoms.flex_1, - ...atoms.body_2_md_regular, - color: color.gray_900, - }, - sectionHeaderText: { - ...atoms.body_1_lg_medium, - color: color.gray_900, - }, - tokenSectionLabel: { - ...atoms.body_2_md_regular, - color: color.gray_900, - }, - sentTokenItem: { - ...atoms.flex, - ...atoms.flex_row, - ...atoms.align_center, - ...atoms.py_xs, - ...atoms.px_md, - borderRadius: 8, - backgroundColor: color.primary_500, - }, - receivedTokenItem: { - ...atoms.flex, - ...atoms.flex_row, - ...atoms.align_center, - ...atoms.py_xs, - ...atoms.px_md, - borderRadius: 8, - backgroundColor: color.secondary_300, - }, - senderTokenItems: { - ...atoms.flex_wrap, - ...atoms.flex_row, - ...atoms.justify_end, - ...atoms.flex_1, - gap: 8, - }, - tokenSentItemText: { - ...atoms.body_2_md_regular, - color: color.white_static, - }, - tokenReceivedItemText: { - ...atoms.body_2_md_regular, - color: color.text_gray_max, - }, - notPrimarySentTokenItem: { - backgroundColor: color.primary_100, - }, - notPrimaryReceivedTokenItem: { - backgroundColor: color.secondary_100, - }, - notPrimarySentTokenItemText: { - color: color.text_primary_medium, - }, - notPrimaryReceivedTokenItemText: { - color: color.secondary_700, - }, - tokensSection: { - ...atoms.flex_row, - ...atoms.justify_between, - }, - tokensSectionLabel: { - ...atoms.flex_row, - ...atoms.align_center, - }, - walletChecksum: { - width: 24, - height: 24, - }, - }) - - const colors = { - copy: color.gray_900, - chevron: color.gray_900, - send: color.primary_500, - received: color.green_static, - } - - return {styles, colors} as const -} - -function SvgComponent(props: SvgProps) { - return ( - - - - - - - - - - - - ) -} diff --git a/apps/wallet-mobile/src/features/ReviewTransaction/useCases/ReviewTransactionScreen/ReviewTransactionScreen.tsx b/apps/wallet-mobile/src/features/ReviewTransaction/useCases/ReviewTransactionScreen/ReviewTransactionScreen.tsx deleted file mode 100644 index 36eeec40d0..0000000000 --- a/apps/wallet-mobile/src/features/ReviewTransaction/useCases/ReviewTransactionScreen/ReviewTransactionScreen.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import {useTheme} from '@yoroi/theme' -import * as React from 'react' -import {StyleSheet} from 'react-native' - -import {SafeArea} from '../../../../components/SafeArea' -import {Tab, Tabs} from '../../../../components/Tabs/Tabs' -import {Divider} from '../../common/Divider' -import {useFormattedTransaction} from '../../common/formattedTransaction' -import {multiAssetsOneReceiver} from '../../common/mocks' -import {OverviewTab} from './Overview/OverviewTab' - -export const ReviewTransactionScreen = () => { - const {styles} = useStyles() - const [activeTab, setActiveTab] = React.useState('overview') - const formatedTx = useFormattedTransaction(multiAssetsOneReceiver) - - console.log('tx', JSON.stringify(formatedTx, null, 2)) - - const renderTabs = React.useMemo(() => { - return ( - - setActiveTab('overview')} - label="Overview" - /> - - setActiveTab('utxos')} label="UTxOs" /> - - ) - }, [activeTab, setActiveTab, styles.tab, styles.tabs]) - - return ( - - {renderTabs} - - - - {activeTab === 'overview' && } - - ) -} - -const useStyles = () => { - const {atoms, color} = useTheme() - const styles = StyleSheet.create({ - tabs: { - ...atoms.px_lg, - ...atoms.gap_lg, - backgroundColor: color.bg_color_max, - }, - tab: { - flex: 0, - }, - }) - - return {styles} as const -} diff --git a/apps/wallet-mobile/src/features/ReviewTx/ReviewTxNavigator.tsx b/apps/wallet-mobile/src/features/ReviewTx/ReviewTxNavigator.tsx new file mode 100644 index 0000000000..9a0e390ca5 --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/ReviewTxNavigator.tsx @@ -0,0 +1,34 @@ +import {createStackNavigator} from '@react-navigation/stack' +import {Atoms, ThemedPalette, useTheme} from '@yoroi/theme' +import React from 'react' + +import {Boundary} from '../../components/Boundary/Boundary' +import {defaultStackNavigationOptions, ReviewTxRoutes} from '../../kernel/navigation' +import {ReviewTxScreen} from './useCases/ReviewTxScreen/ReviewTxScreen' + +export const Stack = createStackNavigator() + +export const ReviewTxNavigator = () => { + const {atoms, color} = useTheme() + + return ( + + + {() => ( + + + + )} + + + ) +} + +const screenOptions = (atoms: Atoms, color: ThemedPalette) => ({ + ...defaultStackNavigationOptions(atoms, color), + gestureEnabled: true, +}) diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/Address.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/Address.tsx new file mode 100644 index 0000000000..aad8711413 --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/common/Address.tsx @@ -0,0 +1,46 @@ +import {useTheme} from '@yoroi/theme' +import * as React from 'react' +import {StyleSheet, Text, TextStyle, TouchableOpacity, View} from 'react-native' + +import {Icon} from '../../../components/Icon' +import {useCopy} from '../../../hooks/useCopy' + +export const Address = ({address, textStyle}: {address: string; textStyle?: TextStyle}) => { + const {styles, colors} = useStyles() + const [, copy] = useCopy() + + return ( + + + {address} + + + copy(address)} activeOpacity={0.5}> + + + + ) +} + +const useStyles = () => { + const {atoms, color} = useTheme() + const styles = StyleSheet.create({ + address: { + ...atoms.flex_row, + ...atoms.align_center, + ...atoms.flex_row, + ...atoms.justify_between, + }, + addressText: { + ...atoms.flex_1, + ...atoms.body_2_md_regular, + color: color.gray_900, + }, + }) + + const colors = { + copy: color.gray_900, + } + + return {styles, colors} as const +} diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/CollapsibleSection.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/CollapsibleSection.tsx new file mode 100644 index 0000000000..0bb8691140 --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/common/CollapsibleSection.tsx @@ -0,0 +1,44 @@ +import {useTheme} from '@yoroi/theme' +import * as React from 'react' +import {StyleSheet, Text, TouchableOpacity, View} from 'react-native' + +import {Icon} from '../../../components/Icon' + +export const CollapsibleSection = ({label, children}: {label: string; children: React.ReactNode}) => { + const {styles, colors} = useStyles() + const [isOpen, setIsOpen] = React.useState(false) + + return ( + <> + + {label} + + setIsOpen((isOpen) => !isOpen)}> + + + + + {isOpen && children} + + ) +} + +const useStyles = () => { + const {atoms, color} = useTheme() + const styles = StyleSheet.create({ + sectionHeader: { + ...atoms.flex_row, + ...atoms.justify_between, + }, + sectionHeaderText: { + ...atoms.body_1_lg_medium, + color: color.gray_900, + }, + }) + + const colors = { + chevron: color.gray_900, + } + + return {styles, colors} as const +} diff --git a/apps/wallet-mobile/src/features/ReviewTransaction/common/Divider.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/Divider.tsx similarity index 51% rename from apps/wallet-mobile/src/features/ReviewTransaction/common/Divider.tsx rename to apps/wallet-mobile/src/features/ReviewTx/common/Divider.tsx index c53f95c9cd..1733930fda 100644 --- a/apps/wallet-mobile/src/features/ReviewTransaction/common/Divider.tsx +++ b/apps/wallet-mobile/src/features/ReviewTx/common/Divider.tsx @@ -1,10 +1,20 @@ -import {useTheme} from '@yoroi/theme' +import {SpacingSize, useTheme} from '@yoroi/theme' import * as React from 'react' import {StyleSheet, View} from 'react-native' -export const Divider = () => { +import {Space} from '../../../components/Space/Space' + +export const Divider = ({verticalSpace = 'none'}: {verticalSpace?: SpacingSize}) => { const {styles} = useStyles() - return + return ( + <> + + + + + + + ) } const useStyles = () => { diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/TokenItem.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/TokenItem.tsx new file mode 100644 index 0000000000..751a3bbd56 --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/common/TokenItem.tsx @@ -0,0 +1,76 @@ +import {useTheme} from '@yoroi/theme' +import * as React from 'react' +import {StyleSheet, Text, View} from 'react-native' + +export const TokenItem = ({ + isPrimaryToken = true, + isSent = true, + label, +}: { + isPrimaryToken?: boolean + isSent?: boolean + label: string +}) => { + const {styles} = useStyles() + + if (!isSent) + return ( + + + {label} + + + ) + + return ( + + {label} + + ) +} + +const useStyles = () => { + const {atoms, color} = useTheme() + const styles = StyleSheet.create({ + sentTokenItem: { + ...atoms.flex, + ...atoms.flex_row, + ...atoms.align_center, + ...atoms.py_xs, + ...atoms.px_md, + borderRadius: 8, + backgroundColor: color.primary_500, + }, + receivedTokenItem: { + ...atoms.flex, + ...atoms.flex_row, + ...atoms.align_center, + ...atoms.py_xs, + ...atoms.px_md, + borderRadius: 8, + backgroundColor: color.secondary_300, + }, + tokenSentItemText: { + ...atoms.body_2_md_regular, + color: color.white_static, + }, + tokenReceivedItemText: { + ...atoms.body_2_md_regular, + color: color.text_gray_max, + }, + notPrimarySentTokenItem: { + backgroundColor: color.primary_100, + }, + notPrimaryReceivedTokenItem: { + backgroundColor: color.secondary_100, + }, + notPrimarySentTokenItemText: { + color: color.text_primary_medium, + }, + notPrimaryReceivedTokenItemText: { + color: color.secondary_700, + }, + }) + + return {styles} as const +} diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useAddressType.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useAddressType.tsx new file mode 100644 index 0000000000..debc3efe06 --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useAddressType.tsx @@ -0,0 +1,12 @@ +import {useQuery} from 'react-query' + +import {getAddressType} from '../../../../yoroi-wallets/cardano/utils' + +export const useAddressType = (address: string) => { + const query = useQuery(['useAddressType', address], () => getAddressType(address), { + suspense: true, + }) + + if (query.data === undefined) throw new Error('invalid formatted outputs') + return query.data +} diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useFormattedTx.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useFormattedTx.tsx new file mode 100644 index 0000000000..9770e71a55 --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useFormattedTx.tsx @@ -0,0 +1,228 @@ +import {invalid, isNonNullable} from '@yoroi/common' +import {infoExtractName} from '@yoroi/portfolio' +import {Portfolio} from '@yoroi/types' +import * as _ from 'lodash' +import {useQuery} from 'react-query' + +import {YoroiWallet} from '../../../../yoroi-wallets/cardano/types' +import {wrappedCsl} from '../../../../yoroi-wallets/cardano/wrappedCsl' +import {formatTokenWithText} from '../../../../yoroi-wallets/utils/format' +import {asQuantity} from '../../../../yoroi-wallets/utils/utils' +import {usePortfolioTokenInfos} from '../../../Portfolio/common/hooks/usePortfolioTokenInfos' +import {useSelectedWallet} from '../../../WalletManager/common/hooks/useSelectedWallet' +import { + FormattedFee, + FormattedInputs, + FormattedOutputs, + TransactionBody, + TransactionInputs, + TransactionOutputs, +} from '../types' + +export const useFormattedTx = (data: TransactionBody) => { + const {wallet} = useSelectedWallet() + + const inputs = data?.inputs ?? [] + const outputs = data?.outputs ?? [] + + const inputTokenIds = inputs.flatMap((i) => { + const receiveUTxO = getUtxoByTxIdAndIndex(wallet, i.transaction_id, i.index) + return receiveUTxO?.assets.map((a) => `${a.policyId}.${a.assetId}` as Portfolio.Token.Id) ?? [] + }) + + const outputTokenIds = outputs.flatMap((o) => { + if (!o.amount.multiasset) return [] + const policyIds = Object.keys(o.amount.multiasset) + const tokenIds = policyIds.flatMap((policyId) => { + const assetIds = Object.keys(o.amount.multiasset?.[policyId] ?? {}) + return assetIds.map((assetId) => `${policyId}.${assetId}` as Portfolio.Token.Id) + }) + return tokenIds + }) + + const tokenIds = _.uniq([...inputTokenIds, ...outputTokenIds]) + const portfolioTokenInfos = usePortfolioTokenInfos({wallet, tokenIds}, {suspense: true}) + + const formattedInputs = useFormattedInputs(wallet, inputs, portfolioTokenInfos) + const formattedOutputs = useFormattedOutputs(wallet, outputs, portfolioTokenInfos) + const formattedFee = formatFee(wallet, data) + + return {inputs: formattedInputs, outputs: formattedOutputs, fee: formattedFee} +} + +export const useFormattedInputs = ( + wallet: YoroiWallet, + inputs: TransactionInputs, + tokenInfosResult: ReturnType, +) => { + const query = useQuery( + ['useFormattedInputs', inputs], + async () => { + const inputss = await formatInputs(wallet, inputs, tokenInfosResult) + console.log('inputs', inputs) + console.log('inputss', inputss) + return inputss + }, + { + suspense: true, + }, + ) + + if (!query.data) throw new Error('invalid formatted inputs') + return query.data +} + +export const useFormattedOutputs = ( + wallet: YoroiWallet, + outputs: TransactionOutputs, + portfolioTokenInfos: ReturnType, +) => { + const query = useQuery( + ['useFormattedOutputs', outputs], + () => formatOutputs(wallet, outputs, portfolioTokenInfos), + { + suspense: true, + }, + ) + + if (!query.data) throw new Error('invalid formatted outputs') + return query.data +} + +const formatInputs = async ( + wallet: YoroiWallet, + inputs: TransactionInputs, + portfolioTokenInfos: ReturnType, +): Promise => { + return Promise.all( + inputs.map(async (input) => { + const receiveUTxO = getUtxoByTxIdAndIndex(wallet, input.transaction_id, input.index) + const address = receiveUTxO?.receiver + const rewardAddress = + address !== undefined ? await deriveRewardAddressFromAddress(address, wallet.networkManager.chainId) : null + const coin = receiveUTxO?.amount != null ? asQuantity(receiveUTxO.amount) : null + + const primaryAssets = + coin != null + ? [ + { + name: wallet.portfolioPrimaryTokenInfo.name, + label: formatTokenWithText(coin, wallet.portfolioPrimaryTokenInfo), + quantity: coin, + isPrimary: true, + }, + ] + : [] + + const multiAssets = + receiveUTxO?.assets + .map((a) => { + const tokenInfo = portfolioTokenInfos.tokenInfos?.get(a.assetId as Portfolio.Token.Id) + if (!tokenInfo) return null + const quantity = asQuantity(a.amount) + return { + name: infoExtractName(tokenInfo), + label: formatTokenWithText(quantity, tokenInfo), + quantity, + isPrimary: false, + } + }) + .filter(Boolean) ?? [] + + return { + assets: [...primaryAssets, ...multiAssets].filter(isNonNullable), + address, + rewardAddress, + ownAddress: address != null && isOwnedAddress(wallet, address), + txIndex: input.index, + txHash: input.transaction_id, + } + }), + ) +} + +const formatOutputs = async ( + wallet: YoroiWallet, + outputs: TransactionOutputs, + portfolioTokenInfos: ReturnType, +): Promise => { + return Promise.all( + outputs.map(async (output) => { + const address = output.address + const rewardAddress = await deriveRewardAddressFromAddress(address, wallet.networkManager.chainId) + const coin = asQuantity(output.amount.coin) + + const primaryAssets = [ + { + name: wallet.portfolioPrimaryTokenInfo.name, + label: formatTokenWithText(coin, wallet.portfolioPrimaryTokenInfo), + quantity: coin, + isPrimary: true, + }, + ] + + const multiAssets = output.amount.multiasset + ? Object.entries(output.amount.multiasset).flatMap(([policyId, assets]) => { + return Object.entries(assets).map(([assetId, amount]) => { + const tokenInfo = portfolioTokenInfos.tokenInfos?.get(`${policyId}.${assetId}`) + if (tokenInfo == null) return null + const quantity = asQuantity(amount) + return { + name: infoExtractName(tokenInfo), + label: formatTokenWithText(quantity, tokenInfo), + quantity, + isPrimary: false, + } + }) + }) + : [] + + const assets = [...primaryAssets, ...multiAssets].filter(isNonNullable) + + return { + assets, + address, + rewardAddress, + ownAddress: isOwnedAddress(wallet, address), + } + }), + ) +} + +export const formatFee = (wallet: YoroiWallet, data: TransactionBody): FormattedFee => { + const fee = asQuantity(data?.fee ?? '0') + + return { + name: wallet.portfolioPrimaryTokenInfo.name, + label: formatTokenWithText(fee, wallet.portfolioPrimaryTokenInfo), + quantity: fee, + isPrimary: true, + } +} + +export const deriveRewardAddressFromAddress = async (address: string, chainId: number): Promise => { + const {csl, release} = wrappedCsl() + + try { + const result = await csl.Address.fromBech32(address) + .then((address) => csl.BaseAddress.fromAddress(address)) + .then((baseAddress) => baseAddress?.stakeCred() ?? invalid('invalid base address')) + .then((stakeCredential) => csl.RewardAddress.new(chainId, stakeCredential)) + .then((rewardAddress) => rewardAddress.toAddress()) + .then((rewardAddrAsAddress) => rewardAddrAsAddress.toBech32(undefined)) + .catch((error) => error) + + if (typeof result !== 'string') throw new Error('Its not possible to derive reward address') + return result + } finally { + release() + } +} + +const getUtxoByTxIdAndIndex = (wallet: YoroiWallet, txId: string, index: number) => { + return wallet.utxos.find((u) => u.tx_hash === txId && u.tx_index === index) +} + +const isOwnedAddress = (wallet: YoroiWallet, bech32Address: string) => { + return wallet.internalAddresses.includes(bech32Address) || wallet.externalAddresses.includes(bech32Address) +} diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useOnConfirm.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useOnConfirm.tsx new file mode 100644 index 0000000000..dfb076eab5 --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useOnConfirm.tsx @@ -0,0 +1,71 @@ +import * as React from 'react' + +import {ConfirmTxWithHwModal} from '../../../../components/ConfirmTxWithHwModal/ConfirmTxWithHwModal' +import {ConfirmTxWithOsModal} from '../../../../components/ConfirmTxWithOsModal/ConfirmTxWithOsModal' +import {ConfirmTxWithSpendingPasswordModal} from '../../../../components/ConfirmTxWithSpendingPasswordModal/ConfirmTxWithSpendingPasswordModal' +import {useModal} from '../../../../components/Modal/ModalContext' +import {YoroiSignedTx, YoroiUnsignedTx} from '../../../../yoroi-wallets/types/yoroi' +import {useSelectedWallet} from '../../../WalletManager/common/hooks/useSelectedWallet' +import {useStrings} from './useStrings' + +// TODO: make it compatible with CBOR signing +export const useOnConfirm = ({ + unsignedTx, + onSuccess, + onError, + onNotSupportedCIP1694, +}: { + onSuccess: (txId: YoroiSignedTx) => void + onError: () => void + cbor?: string + unsignedTx?: YoroiUnsignedTx + onNotSupportedCIP1694?: () => void +}) => { + if (unsignedTx === undefined) throw new Error('useOnConfirm: unsignedTx missing') + + const {meta} = useSelectedWallet() + const {openModal, closeModal} = useModal() + const strings = useStrings() + + const onConfirm = () => { + if (meta.isHW) { + openModal( + strings.signTransaction, + onSuccess(signedTx)} + onNotSupportedCIP1694={onNotSupportedCIP1694} + />, + 400, + ) + return + } + + if (!meta.isHW && !meta.isEasyConfirmationEnabled) { + openModal( + strings.signTransaction, + onSuccess(signedTx)} + onError={onError} + />, + ) + return + } + + if (!meta.isHW && meta.isEasyConfirmationEnabled) { + openModal( + strings.signTransaction, + onSuccess(signedTx)} + onError={onError} + />, + ) + return + } + } + + return {onConfirm} +} diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useStrings.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useStrings.tsx new file mode 100644 index 0000000000..0513fdd25f --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useStrings.tsx @@ -0,0 +1,11 @@ +import {useIntl} from 'react-intl' + +import {txLabels} from '../../../../kernel/i18n/global-messages' + +export const useStrings = () => { + const intl = useIntl() + + return { + signTransaction: intl.formatMessage(txLabels.signingTx), + } +} diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useTxBody.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useTxBody.tsx new file mode 100644 index 0000000000..9de33bb075 --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useTxBody.tsx @@ -0,0 +1,44 @@ +import {useQuery} from 'react-query' + +import {wrappedCsl} from '../../../../yoroi-wallets/cardano/wrappedCsl' +import {YoroiUnsignedTx} from '../../../../yoroi-wallets/types/yoroi' + +export const useTxBody = ({cbor, unsignedTx}: {cbor?: string; unsignedTx?: YoroiUnsignedTx}) => { + const query = useQuery( + ['useTxBody', cbor, unsignedTx], + async () => { + if (cbor !== undefined) { + return getCborTxBody(cbor) + } else if (unsignedTx !== undefined) { + return getUnsignedTxTxBody(unsignedTx) + } else { + throw new Error('useTxBody: missing cbor and unsignedTx') + } + }, + { + useErrorBoundary: true, + suspense: true, + }, + ) + + if (query.data === undefined) throw new Error('useTxBody: cannot extract txBody') + return query.data +} +const getCborTxBody = async (cbor: string) => { + const {csl, release} = wrappedCsl() + try { + const tx = await csl.Transaction.fromHex(cbor) + const jsonString = await tx.toJson() + return JSON.parse(jsonString).body + } finally { + release() + } +} + +const getUnsignedTxTxBody = async (unsignedTx: YoroiUnsignedTx) => { + const { + unsignedTx: {txBody}, + } = unsignedTx + const txBodyjson = await txBody.toJson() + return JSON.parse(txBodyjson) +} diff --git a/apps/wallet-mobile/src/features/ReviewTransaction/common/mocks.ts b/apps/wallet-mobile/src/features/ReviewTx/common/mocks.ts similarity index 98% rename from apps/wallet-mobile/src/features/ReviewTransaction/common/mocks.ts rename to apps/wallet-mobile/src/features/ReviewTx/common/mocks.ts index 899bb3aa5f..12fbe9274a 100644 --- a/apps/wallet-mobile/src/features/ReviewTransaction/common/mocks.ts +++ b/apps/wallet-mobile/src/features/ReviewTx/common/mocks.ts @@ -54,7 +54,7 @@ export const adaTransactionSingleReceiver: TransactionBody = { current_treasury_value: null, } -export const multiAssetsOneReceiver: TransactionBody = { +export const multiAssetsTransactionOneReceiver: TransactionBody = { inputs: [ { transaction_id: '46fe71d85a733d970fe7bb8e6586624823803936d18c7e14601713d05b5b287a', diff --git a/apps/wallet-mobile/src/features/ReviewTransaction/common/types.ts b/apps/wallet-mobile/src/features/ReviewTx/common/types.ts similarity index 95% rename from apps/wallet-mobile/src/features/ReviewTransaction/common/types.ts rename to apps/wallet-mobile/src/features/ReviewTx/common/types.ts index 0c6847d747..52785e8c48 100644 --- a/apps/wallet-mobile/src/features/ReviewTransaction/common/types.ts +++ b/apps/wallet-mobile/src/features/ReviewTx/common/types.ts @@ -1,3 +1,7 @@ +import {Balance} from '@yoroi/types' + +import {useFormattedTx} from './hooks/useFormattedTx' + export type TransactionDetails = { id: string walletPlate: React.ReactNode @@ -853,3 +857,38 @@ export type VotingProposals = VotingProposal[] export interface Withdrawals { [k: string]: string } + +export type FormattedInput = { + assets: Array<{ + name: string + label: string + quantity: Balance.Quantity + isPrimary: boolean + }> + address: string | undefined + rewardAddress: string | null + ownAddress: boolean + txIndex: number + txHash: string +} + +export type FormattedInputs = Array +export type FormattedTx = ReturnType +export type FormattedOutput = { + assets: Array<{ + name: string + label: string + quantity: Balance.Quantity + isPrimary: boolean + }> + address: string + rewardAddress: string | null + ownAddress: boolean +} +export type FormattedOutputs = Array +export type FormattedFee = { + name: string + label: string + quantity: Balance.Quantity + isPrimary: boolean +} diff --git a/apps/wallet-mobile/src/features/ReviewTx/useCases/ReviewTxScreen/Overview/OverviewTab.tsx b/apps/wallet-mobile/src/features/ReviewTx/useCases/ReviewTxScreen/Overview/OverviewTab.tsx new file mode 100644 index 0000000000..42b46ba0d5 --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/useCases/ReviewTxScreen/Overview/OverviewTab.tsx @@ -0,0 +1,344 @@ +import {Blockies} from '@yoroi/identicon' +import {useTheme} from '@yoroi/theme' +import * as React from 'react' +import {Linking, StyleSheet, Text, TouchableOpacity, View} from 'react-native' + +import {Icon} from '../../../../../components/Icon' +import {Space} from '../../../../../components/Space/Space' +import {Warning} from '../../../../../components/Warning/Warning' +import {formatTokenWithText} from '../../../../../yoroi-wallets/utils/format' +import {Quantities} from '../../../../../yoroi-wallets/utils/utils' +import {useSelectedWallet} from '../../../../WalletManager/common/hooks/useSelectedWallet' +import {useWalletManager} from '../../../../WalletManager/context/WalletManagerProvider' +import {Address} from '../../../common/Address' +import {CollapsibleSection} from '../../../common/CollapsibleSection' +import {Divider} from '../../../common/Divider' +import {useAddressType} from '../../../common/hooks/useAddressType' +import {TokenItem} from '../../../common/TokenItem' +import {FormattedOutputs, FormattedTx} from '../../../common/types' + +export const OverviewTab = ({tx}: {tx: FormattedTx}) => { + const {styles} = useStyles() + const notOwnedOutputs = React.useMemo(() => tx.outputs.filter((output) => !output.ownAddress), [tx.outputs]) + const ownedOutputs = React.useMemo(() => tx.outputs.filter((output) => output.ownAddress), [tx.outputs]) + + return ( + + + + + + + + + + ) +} + +const WalletInfoSection = ({tx}: {tx: FormattedTx}) => { + const {styles} = useStyles() + const {wallet, meta} = useSelectedWallet() + const {walletManager} = useWalletManager() + const {plate, seed} = walletManager.checksum(wallet.publicKeyHex) + const seedImage = new Blockies({seed}).asBase64() + + return ( + <> + + Wallet + + + + + + + {`${plate} | ${meta.name}`} + + + + + + + + ) +} + +const FeeInfoItem = ({fee}: {fee: string}) => { + const {styles} = useStyles() + + return ( + + Fee + + {fee} + + ) +} + +const SenderSection = ({ + tx, + notOwnedOutputs, + ownedOutputs, +}: { + tx: FormattedTx + notOwnedOutputs: FormattedOutputs + ownedOutputs: FormattedOutputs +}) => { + const address = ownedOutputs[0]?.rewardAddress ?? ownedOutputs[0]?.address + + return ( + + + +
+ + + + + + {notOwnedOutputs.length === 1 && } + + ) +} + +const SenderTokens = ({tx, notOwnedOutputs}: {tx: FormattedTx; notOwnedOutputs: FormattedOutputs}) => { + const {styles} = useStyles() + const {wallet} = useSelectedWallet() + + const totalPrimaryTokenSent = React.useMemo( + () => + notOwnedOutputs + .flatMap((output) => output.assets.filter((asset) => asset.isPrimary)) + .reduce((previous, current) => Quantities.sum([previous, current.quantity]), Quantities.zero), + [notOwnedOutputs], + ) + const totalPrimaryTokenSpent = React.useMemo( + () => Quantities.sum([totalPrimaryTokenSent, tx.fee.quantity]), + [totalPrimaryTokenSent, tx.fee.quantity], + ) + const totalPrimaryTokenSpentLabel = formatTokenWithText(totalPrimaryTokenSpent, wallet.portfolioPrimaryTokenInfo) + + const notPrimaryTokenSent = React.useMemo( + () => notOwnedOutputs.flatMap((output) => output.assets.filter((asset) => !asset.isPrimary)), + [notOwnedOutputs], + ) + + return ( + + + + + + + + + {notPrimaryTokenSent.map((token) => ( + + ))} + + + ) +} + +const SenderSectionLabel = () => { + const {styles, colors} = useStyles() + + return ( + + + + + + Send + + ) +} + +const ReceiverSection = ({notOwnedOutputs}: {notOwnedOutputs: FormattedOutputs}) => { + const address = notOwnedOutputs[0]?.rewardAddress ?? notOwnedOutputs[0]?.address + const {styles} = useStyles() + const addressType = useAddressType(address) + const isScriptAddress = addressType === 'script' + + return ( + <> + + + + {isScriptAddress ? 'To script' : `To`}: + +
+ + + ) +} + +const useStyles = () => { + const {atoms, color} = useTheme() + const styles = StyleSheet.create({ + root: { + ...atoms.px_lg, + }, + infoItem: { + ...atoms.flex_row, + ...atoms.justify_between, + }, + infoLabel: { + ...atoms.body_2_md_regular, + color: color.gray_600, + }, + walletInfoText: { + ...atoms.body_2_md_medium, + color: color.text_primary_medium, + }, + plate: { + ...atoms.flex_row, + ...atoms.align_center, + }, + fee: { + color: color.gray_900, + ...atoms.body_2_md_regular, + }, + link: { + color: color.text_primary_medium, + ...atoms.body_2_md_medium, + }, + receiverAddress: { + ...atoms.flex_row, + ...atoms.align_center, + ...atoms.flex_row, + ...atoms.justify_between, + }, + tokenSectionLabel: { + ...atoms.body_2_md_regular, + color: color.gray_900, + }, + senderTokenItems: { + ...atoms.flex_wrap, + ...atoms.flex_row, + ...atoms.justify_end, + ...atoms.flex_1, + gap: 8, + }, + tokensSection: { + ...atoms.flex_row, + ...atoms.justify_between, + }, + tokensSectionLabel: { + ...atoms.flex_row, + ...atoms.align_center, + }, + walletChecksum: { + width: 24, + height: 24, + }, + receiverSectionAddress: { + maxWidth: 260, + }, + }) + + const colors = { + send: color.primary_500, + received: color.green_static, + } + + return {styles, colors} as const +} + +// WORK IN PROGRESS BELOW + +// TODO: WIP +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const CreatedByInfoItem = () => { + const {styles} = useStyles() + + return ( + + Created By + + + {/* */} + + + + Linking.openURL('https://google.com')}> + dapp.org + + + + ) +} + +// TODO: WIP +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const ReceiverTokensSectionMultiReceiver = () => { + const {styles} = useStyles() + + return ( + <> + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +// TODO: WIP +const ReceiverSectionLabel = () => { + const {styles, colors} = useStyles() + + return ( + + + + + + Receive + + ) +} diff --git a/apps/wallet-mobile/src/features/ReviewTx/useCases/ReviewTxScreen/ReviewTxScreen.tsx b/apps/wallet-mobile/src/features/ReviewTx/useCases/ReviewTxScreen/ReviewTxScreen.tsx new file mode 100644 index 0000000000..2d831995ee --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/useCases/ReviewTxScreen/ReviewTxScreen.tsx @@ -0,0 +1,95 @@ +import {useTheme} from '@yoroi/theme' +import * as React from 'react' +import {StyleSheet, View} from 'react-native' + +import {Button} from '../../../../components/Button/Button' +import {KeyboardAvoidingView} from '../../../../components/KeyboardAvoidingView/KeyboardAvoidingView' +import {SafeArea} from '../../../../components/SafeArea' +import {ScrollView} from '../../../../components/ScrollView/ScrollView' +import {Tab, Tabs} from '../../../../components/Tabs/Tabs' +import {ReviewTxRoutes, useUnsafeParams} from '../../../../kernel/navigation' +import {Divider} from '../../common/Divider' +import {useFormattedTx} from '../../common/hooks/useFormattedTx' +import {useOnConfirm} from '../../common/hooks/useOnConfirm' +import {useTxBody} from '../../common/hooks/useTxBody' +import {OverviewTab} from './Overview/OverviewTab' + +type Tabs = 'overview' | 'utxos' + +export const ReviewTxScreen = () => { + const {styles} = useStyles() + const [activeTab, setActiveTab] = React.useState('overview') + + const params = useUnsafeParams() + + // TODO: add cbor arguments + const txBody = useTxBody({unsignedTx: params.unsignedTx}) + const formatedTx = useFormattedTx(txBody) + + const {onConfirm} = useOnConfirm({ + unsignedTx: params.unsignedTx, + onSuccess: params.onSuccess, + onError: params.onError, + }) + + const renderTabs = React.useMemo(() => { + return ( + + setActiveTab('overview')} + label="Overview" + /> + + setActiveTab('utxos')} label="UTxOs" /> + + ) + }, [activeTab, setActiveTab, styles.tab, styles.tabs]) + + return ( + + + + {renderTabs} + + + + {activeTab === 'overview' && } + + + + + + + + + + + + + + ) +} + +const TransactionReceivedSetting = ({ + value, + onChange, +}: { + value: NotificationTypes.Config['TransactionReceived'] + onChange: (value: NotificationTypes.Config['TransactionReceived']) => void +}) => { + const styles = useStyles() + return ( + + Transaction Received + + + Notify + + onChange({notify})} /> + + + ) +} + +const RewardsUpdateSetting = ({ + value, + onChange, +}: { + value: NotificationTypes.Config['RewardsUpdated'] + onChange: (value: NotificationTypes.Config['RewardsUpdated']) => void +}) => { + const styles = useStyles() + return ( + + Rewards Updated + + + Notify + + onChange({notify})} /> + + + ) +} + +const PrimaryTokenPriceChangedSetting = ({ + value, + onChange, +}: { + value: NotificationTypes.Config['PrimaryTokenPriceChanged'] + onChange: (value: NotificationTypes.Config['PrimaryTokenPriceChanged']) => void +}) => { + const styles = useStyles() + return ( + + Primary Token Price Changed + + + Notify + + onChange({...value, notify})} /> + + + + Threshold + + {value.thresholdInPercent} + + + + Interval + + {value.interval} + + + ) +} + +const Switch = ({ + value, + onValueChange, + disabled, +}: { + value: boolean + onValueChange: (value: boolean) => void + disabled?: boolean +}) => { + const {color} = useTheme() + return ( + + ) +} + +const useStyles = () => { + const {atoms} = useTheme() + const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + ...atoms.gap_sm, + }, + }) + return styles +} diff --git a/apps/wallet-mobile/src/features/Notifications/useCases/common/hooks.ts b/apps/wallet-mobile/src/features/Notifications/useCases/common/hooks.ts new file mode 100644 index 0000000000..b0736a3b0d --- /dev/null +++ b/apps/wallet-mobile/src/features/Notifications/useCases/common/hooks.ts @@ -0,0 +1,42 @@ +import {Notifications} from '@jamsinclair/react-native-notifications' +import {NotificationBackgroundFetchResult} from '@jamsinclair/react-native-notifications' +import React from 'react' +import {PermissionsAndroid} from 'react-native' + +import {notificationManager} from './notification-manager' +import {parseNotificationId} from './notifications' +import {useTransactionReceivedNotifications} from './transaction-received-notification' + +let initialized = false + +const init = () => { + if (initialized) return + initialized = true + PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS) + Notifications.registerRemoteNotifications() + Notifications.events().registerNotificationReceivedForeground((_notification, completion) => { + completion({alert: true, sound: true, badge: true}) + }) + + Notifications.events().registerNotificationReceivedBackground((_notification, completion) => { + completion(NotificationBackgroundFetchResult.NEW_DATA) + }) + + Notifications.events().registerNotificationOpened((notification, completion) => { + const payloadId = notification.identifier || notification.payload.id + const id = parseNotificationId(payloadId) + notificationManager.events.markAsRead(id) + completion() + }) + + notificationManager.hydrate() + + return () => { + notificationManager.destroy() + } +} + +export const useInitNotifications = ({enabled}: {enabled: boolean}) => { + React.useEffect(() => (enabled ? init() : undefined), [enabled]) + useTransactionReceivedNotifications({enabled}) +} diff --git a/apps/wallet-mobile/src/features/Notifications/useCases/common/notification-manager.ts b/apps/wallet-mobile/src/features/Notifications/useCases/common/notification-manager.ts new file mode 100644 index 0000000000..0dc2dc6d22 --- /dev/null +++ b/apps/wallet-mobile/src/features/Notifications/useCases/common/notification-manager.ts @@ -0,0 +1,18 @@ +import {mountAsyncStorage} from '@yoroi/common' +import {notificationManagerMaker} from '@yoroi/notifications' +import {Notifications} from '@yoroi/types' + +import {displayNotificationEvent} from './notifications' +import {transactionReceivedSubject} from './transaction-received-notification' + +const appStorage = mountAsyncStorage({path: '/'}) +const notificationStorage = appStorage.join('notifications/') + +export const notificationManager = notificationManagerMaker({ + eventsStorage: notificationStorage.join('events/'), + configStorage: notificationStorage.join('settings/'), + display: displayNotificationEvent, + subscriptions: { + [Notifications.Trigger.TransactionReceived]: transactionReceivedSubject, + }, +}) diff --git a/apps/wallet-mobile/src/features/Notifications/useCases/common/notifications.ts b/apps/wallet-mobile/src/features/Notifications/useCases/common/notifications.ts new file mode 100644 index 0000000000..ca4b688668 --- /dev/null +++ b/apps/wallet-mobile/src/features/Notifications/useCases/common/notifications.ts @@ -0,0 +1,34 @@ +import {Notification, Notifications} from '@jamsinclair/react-native-notifications' +import {Notifications as NotificationTypes} from '@yoroi/types' + +export const generateNotificationId = (): number => { + return generateRandomInteger(0, Number.MAX_SAFE_INTEGER) +} + +export const parseNotificationId = (id: string | number): number => { + return parseInt(String(id), 10) +} + +const generateRandomInteger = (min: number, max: number): number => { + return Math.floor(Math.random() * (max - min + 1)) + min +} + +export const displayNotificationEvent = (notificationEvent: NotificationTypes.Event) => { + if (notificationEvent.trigger === NotificationTypes.Trigger.TransactionReceived) { + sendNotification({ + title: 'Transaction received', + body: 'You have received a new transaction', + id: notificationEvent.id, + }) + } +} + +const sendNotification = (options: {title: string; body: string; id: number}) => { + const notification = new Notification({ + title: options.title, + body: options.body, + sound: 'default', + id: options.id, + }) + Notifications.postLocalNotification(notification.payload, options.id) +} diff --git a/apps/wallet-mobile/src/features/Notifications/useCases/common/transaction-received-notification.ts b/apps/wallet-mobile/src/features/Notifications/useCases/common/transaction-received-notification.ts new file mode 100644 index 0000000000..b84b38c098 --- /dev/null +++ b/apps/wallet-mobile/src/features/Notifications/useCases/common/transaction-received-notification.ts @@ -0,0 +1,155 @@ +import {useAsyncStorage} from '@yoroi/common' +import {mountAsyncStorage} from '@yoroi/common/src' +import {App, Notifications as NotificationTypes} from '@yoroi/types' +import * as BackgroundFetch from 'expo-background-fetch' +import * as TaskManager from 'expo-task-manager' +import * as React from 'react' +import {Subject} from 'rxjs' + +import {YoroiWallet} from '../../../../yoroi-wallets/cardano/types' +import {TRANSACTION_DIRECTION} from '../../../../yoroi-wallets/types/other' +import {useWalletManager} from '../../../WalletManager/context/WalletManagerProvider' +import {WalletManager, walletManager} from '../../../WalletManager/wallet-manager' +import {notificationManager} from './notification-manager' +import {generateNotificationId} from './notifications' + +const BACKGROUND_FETCH_TASK = 'yoroi-transaction-received-notifications-background-fetch' + +// Check is needed for hot reloading, as task can not be defined twice +if (!TaskManager.isTaskDefined(BACKGROUND_FETCH_TASK)) { + const appStorage = mountAsyncStorage({path: '/'}) + TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { + await syncAllWallets(walletManager) + const notifications = await checkForNewTransactions(walletManager, appStorage) + notifications.forEach((notification) => notificationManager.events.push(notification)) + const hasNewData = notifications.length > 0 + return hasNewData ? BackgroundFetch.BackgroundFetchResult.NewData : BackgroundFetch.BackgroundFetchResult.NoData + }) +} + +const registerBackgroundFetchAsync = () => { + return BackgroundFetch.registerTaskAsync(BACKGROUND_FETCH_TASK, { + minimumInterval: 60 * 10, + stopOnTerminate: false, + startOnBoot: true, + }) +} + +const unregisterBackgroundFetchAsync = () => { + return BackgroundFetch.unregisterTaskAsync(BACKGROUND_FETCH_TASK) +} + +const syncAllWallets = async (walletManager: WalletManager) => { + const ids = [...walletManager.walletMetas.keys()] + for (const id of ids) { + const wallet = walletManager.getWalletById(id) + if (!wallet) continue + await wallet.sync({}) + } +} + +const checkForNewTransactions = async (walletManager: WalletManager, appStorage: App.Storage) => { + const walletIds = [...walletManager.walletMetas.keys()] + const notifications: NotificationTypes.TransactionReceivedEvent[] = [] + + for (const walletId of walletIds) { + const wallet = walletManager.getWalletById(walletId) + if (!wallet) continue + const storage = buildStorage(appStorage, walletId) + const processed = await storage.getProcessedTransactions() + const allTxIds = getTxIds(wallet) + + if (processed.length === 0) { + await storage.addProcessedTransactions(allTxIds) + continue + } + + const newTxIds = allTxIds.filter((txId) => !processed.includes(txId)) + + if (newTxIds.length === 0) { + continue + } + + await storage.addProcessedTransactions(newTxIds) + + newTxIds.forEach((id) => { + const metadata: NotificationTypes.TransactionReceivedEvent['metadata'] = { + txId: id, + isSentByUser: wallet.transactions[id]?.direction === TRANSACTION_DIRECTION.SENT, + nextTxsCounter: newTxIds.length + processed.length, + previousTxsCounter: processed.length, + } + notifications.push(createTransactionReceivedNotification(metadata)) + }) + } + + return notifications +} + +const getTxIds = (wallet: YoroiWallet) => { + const ids = wallet.allUtxos.map((utxo) => utxo.tx_hash) + return [...new Set(ids)] +} + +export const createTransactionReceivedNotification = ( + metadata: NotificationTypes.TransactionReceivedEvent['metadata'], +) => { + return { + id: generateNotificationId(), + date: new Date().toISOString(), + isRead: false, + trigger: NotificationTypes.Trigger.TransactionReceived, + metadata, + } as const +} + +export const transactionReceivedSubject = new Subject() + +export const useTransactionReceivedNotifications = ({enabled}: {enabled: boolean}) => { + const {walletManager} = useWalletManager() + const asyncStorage = useAsyncStorage() + + React.useEffect(() => { + if (!enabled) return + registerBackgroundFetchAsync() + return () => { + unregisterBackgroundFetchAsync() + } + }, [enabled]) + + React.useEffect(() => { + if (!enabled) return + const subscription = walletManager.syncWalletInfos$.subscribe(async (status) => { + const walletInfos = Array.from(status.values()) + const walletsDoneSyncing = walletInfos.filter((info) => info.status === 'done') + const areAllDone = walletsDoneSyncing.length === walletInfos.length + if (!areAllDone) return + + const notifications = await checkForNewTransactions(walletManager, asyncStorage) + notifications.forEach((notification) => transactionReceivedSubject.next(notification)) + }) + + return () => { + subscription.unsubscribe() + } + }, [walletManager, asyncStorage, enabled]) +} + +const buildStorage = (appStorage: App.Storage, walletId: string) => { + const storage = appStorage.join(`wallet/${walletId}/transaction-received-notification-history/`) + + const getProcessedTransactions = async () => { + return (await storage.getItem('processed')) || [] + } + + const addProcessedTransactions = async (txIds: string[]) => { + const processed = await getProcessedTransactions() + const newProcessed = [...processed, ...txIds] + await storage.setItem('processed', newProcessed) + } + + return { + getProcessedTransactions, + addProcessedTransactions, + } +} diff --git a/apps/wallet-mobile/src/kernel/features.ts b/apps/wallet-mobile/src/kernel/features.ts index 76e3908c0b..3780658d9d 100644 --- a/apps/wallet-mobile/src/kernel/features.ts +++ b/apps/wallet-mobile/src/kernel/features.ts @@ -5,6 +5,7 @@ export const features = { prefillWalletInfo: false, showProdPoolsInDev: isDev, moderatingNftsEnabled: false, + notifications: isDev, poolTransition: true, portfolioPerformance: false, portfolioNews: false, diff --git a/apps/wallet-mobile/src/kernel/navigation.tsx b/apps/wallet-mobile/src/kernel/navigation.tsx index 50691d8b91..1e6b7989a5 100644 --- a/apps/wallet-mobile/src/kernel/navigation.tsx +++ b/apps/wallet-mobile/src/kernel/navigation.tsx @@ -347,6 +347,7 @@ export type AppRoutes = { 'choose-biometric-login': undefined 'dark-theme-announcement': undefined 'setup-wallet': undefined + notifications: undefined } export type AppRouteNavigation = StackNavigationProp diff --git a/apps/wallet-mobile/src/yoroi-wallets/cardano/cardano-wallet.ts b/apps/wallet-mobile/src/yoroi-wallets/cardano/cardano-wallet.ts index 52df12db52..e4bf088e85 100644 --- a/apps/wallet-mobile/src/yoroi-wallets/cardano/cardano-wallet.ts +++ b/apps/wallet-mobile/src/yoroi-wallets/cardano/cardano-wallet.ts @@ -141,6 +141,7 @@ export const makeCardanoWallet = (networkManager: Network.Manager, implementatio storage: accountStorage.join('utxos/'), apiUrl: legacyApiBaseUrl, }) + const transactionManager = await TransactionManager.create(accountStorage.join('txs/')) // TODO: revisit memos should be per network and shouldn't be cleared on wallet clear (unless user selects it) const memosManager = await makeMemosManager(accountStorage.join('memos/')) diff --git a/apps/wallet-mobile/translations/messages/src/AppNavigator.json b/apps/wallet-mobile/translations/messages/src/AppNavigator.json index 0d1c6a4daf..43de5ca9da 100644 --- a/apps/wallet-mobile/translations/messages/src/AppNavigator.json +++ b/apps/wallet-mobile/translations/messages/src/AppNavigator.json @@ -4,14 +4,14 @@ "defaultMessage": "!!!Enter PIN", "file": "src/AppNavigator.tsx", "start": { - "line": 233, + "line": 239, "column": 17, - "index": 8700 + "index": 9071 }, "end": { - "line": 236, + "line": 242, "column": 3, - "index": 8790 + "index": 9161 } }, { @@ -19,14 +19,14 @@ "defaultMessage": "!!!Set PIN", "file": "src/AppNavigator.tsx", "start": { - "line": 237, + "line": 243, "column": 18, - "index": 8810 + "index": 9181 }, "end": { - "line": 240, + "line": 246, "column": 3, - "index": 8908 + "index": 9279 } }, { @@ -34,14 +34,14 @@ "defaultMessage": "!!!Auth with OS changes", "file": "src/AppNavigator.tsx", "start": { - "line": 241, + "line": 247, "column": 25, - "index": 8935 + "index": 9306 }, "end": { - "line": 244, + "line": 250, "column": 3, - "index": 9049 + "index": 9420 } }, { @@ -49,14 +49,14 @@ "defaultMessage": "!!!Auth with OS changed detected", "file": "src/AppNavigator.tsx", "start": { - "line": 245, + "line": 251, "column": 27, - "index": 9078 + "index": 9449 }, "end": { - "line": 248, + "line": 254, "column": 3, - "index": 9199 + "index": 9570 } } ] \ No newline at end of file diff --git a/metro.config.js b/metro.config.js index 580acaeee0..28cb4abada 100644 --- a/metro.config.js +++ b/metro.config.js @@ -6,12 +6,14 @@ module.exports = { path.resolve(__dirname, "apps/wallet-mobile"), path.resolve(__dirname, "node_modules"), path.resolve(__dirname, "packages/api"), + path.resolve(__dirname, "packages/claim"), path.resolve(__dirname, "packages/common"), path.resolve(__dirname, "packages/dapp-connector"), path.resolve(__dirname, "packages/exchange"), path.resolve(__dirname, "packages/explorers"), path.resolve(__dirname, "packages/identicon"), path.resolve(__dirname, "packages/links"), + path.resolve(__dirname, "packages/notifications"), path.resolve(__dirname, "packages/portfolio"), path.resolve(__dirname, "packages/resolver"), path.resolve(__dirname, "packages/setup-wallet"), @@ -19,7 +21,6 @@ module.exports = { path.resolve(__dirname, "packages/swap"), path.resolve(__dirname, "packages/theme"), path.resolve(__dirname, "packages/transfer"), - path.resolve(__dirname, "packages/claim"), path.resolve(__dirname, "packages/types"), ], resolver: { diff --git a/packages/notifications/README.md b/packages/notifications/README.md index ff7dfa74be..b8383f54dc 100644 --- a/packages/notifications/README.md +++ b/packages/notifications/README.md @@ -1,6 +1,40 @@ # Notifications package for Yoroi The `@yoroi/notifications` package handles local notifications within the Yoroi wallet app, ensuring users are alerted to important events like balance changes, and transactions. +This package does not contain any environment-specific code, so it can be used in both web and mobile environments. + +## Usage +1. Create a transaction received subject and push notifications to it whenever a transaction is received. +```ts +const transactionReceivedSubject = new Subject() +``` + +2. Create a notification manager with the necessary configuration. +```ts +export const notificationManager = notificationManagerMaker({ + eventsStorage: appStorage.join('events/'), + configStorage: appStorage.join('settings/'), + display: displayNotificationEvent, + subscriptions: { + [Notifications.Trigger.TransactionReceived]: transactionReceivedSubject, + }, +}) + +``` +3. Initialize the notification manager. +```ts +notificationManager.hydrate() +``` + +4. Destroy the notification manager when it is no longer needed. +```ts +notificationManager.destroy() +``` + +For background notifications in react-native, the UI needs to define a background task using `expo-task-manager` and `expo-background-fetch`. + + +## Details **Notification Types** - Local Notifications: Generated by the app on the device, useful for in-app alerts like transaction updates. @@ -17,4 +51,7 @@ The `@yoroi/notifications` package handles local notifications within the Yoroi **Version 1.0** -Initial version includes local notifications only. \ No newline at end of file +Initial version includes local notifications only. + +**Debug** +- To debug notifications in the background, you can force a background sync by following instructions from https://docs.expo.dev/versions/latest/sdk/background-fetch/ \ No newline at end of file diff --git a/packages/notifications/package.json b/packages/notifications/package.json index c2de979939..ee0d1c9ea4 100644 --- a/packages/notifications/package.json +++ b/packages/notifications/package.json @@ -1,6 +1,6 @@ { "name": "@yoroi/notifications", - "version": "1.5.2", + "version": "1.0.0", "description": "The Notifications package of Yoroi SDK", "keywords": [ "yoroi", @@ -114,10 +114,10 @@ ], "coverageThreshold": { "global": { - "branches": 60, - "functions": 60, - "lines": 60, - "statements": 60 + "branches": 100, + "functions": 100, + "lines": 100, + "statements": 100 } }, "modulePathIgnorePatterns": [ @@ -216,5 +216,8 @@ "preset": "angular" } } + }, + "dependencies": { + "rxjs": "^7.8.1" } } diff --git a/packages/notifications/src/index.ts b/packages/notifications/src/index.ts index d9c011d044..f5a82a0b0b 100644 --- a/packages/notifications/src/index.ts +++ b/packages/notifications/src/index.ts @@ -1 +1,10 @@ -export const NotificationManager = {} +export {notificationManagerMaker} from './notification-manager' +export {useResetNotificationsConfig} from './translators/reactjs/useResetNotificationsConfig' +export { + NotificationProvider, + useNotificationManager, +} from './translators/reactjs/NotificationProvider' + +export {useNotificationsConfig} from './translators/reactjs/useNotificationsConfig' +export {useReceivedNotificationEvents} from './translators/reactjs/useReceivedNotificationEvents' +export {useUpdateNotificationsConfig} from './translators/reactjs/useUpdateNotificationsConfig' diff --git a/packages/notifications/src/notification-manager.test.ts b/packages/notifications/src/notification-manager.test.ts index 472ad560df..752b72ef71 100644 --- a/packages/notifications/src/notification-manager.test.ts +++ b/packages/notifications/src/notification-manager.test.ts @@ -1,7 +1,319 @@ -import {NotificationManager} from './index' +import {notificationManagerMaker} from './notification-manager' +import {mountAsyncStorage} from '@yoroi/common/src' +import AsyncStorage from '@react-native-async-storage/async-storage' +import {BehaviorSubject, Subject} from 'rxjs' +import {Notifications} from '@yoroi/types' + +const createManager = () => { + const eventsStorage = mountAsyncStorage({path: 'events/'}) + const configStorage = mountAsyncStorage({path: 'config/'}) + return notificationManagerMaker({ + eventsStorage, + configStorage, + display: jest.fn(), + }) +} describe('NotificationManager', () => { + beforeEach(() => AsyncStorage.clear()) + it('should be defined', () => { - expect(NotificationManager).toBeDefined() + const manager = createManager() + expect(manager).toBeDefined() + }) + + it('should return default config if not set', async () => { + const manager = createManager() + + const config = await manager.config.read() + expect(config).toEqual({ + [Notifications.Trigger.PrimaryTokenPriceChanged]: { + interval: '24h', + notify: true, + thresholdInPercent: 10, + }, + [Notifications.Trigger.TransactionReceived]: { + notify: true, + }, + [Notifications.Trigger.RewardsUpdated]: { + notify: true, + }, + }) + }) + + it('should allow to save config', async () => { + const manager = createManager() + + const config = await manager.config.read() + const newConfig = { + ...config, + [Notifications.Trigger.TransactionReceived]: { + notify: false, + }, + } + + await manager.config.save(newConfig) + const savedConfig = await manager.config.read() + expect(savedConfig).toEqual(newConfig) + }) + + it('should allow to reset config', async () => { + const manager = createManager() + + const config = await manager.config.read() + const newConfig = { + ...config, + [Notifications.Trigger.TransactionReceived]: { + notify: false, + }, + } + + await manager.config.save(newConfig) + await manager.config.reset() + const savedConfig = await manager.config.read() + expect(savedConfig).toEqual(config) + }) + + it('should default unread counter with 0 for all event types', async () => { + const manager = createManager() + + expect(manager.unreadCounterByGroup$.value).toEqual( + new Map([ + ['transaction-history', 0], + ['portfolio', 0], + ]), + ) }) + + it('should allow to save events', async () => { + const manager = createManager() + + const event = createTransactionReceivedEvent() + await manager.events.push(event) + const savedEvents = await manager.events.read() + expect(savedEvents).toEqual([event]) + expect( + manager.unreadCounterByGroup$.value.get('transaction-history'), + ).toEqual(1) + }) + + it('should allow to save events that are read', async () => { + const manager = createManager() + + const event = createTransactionReceivedEvent({isRead: true}) + await manager.events.push(event) + const savedEvents = await manager.events.read() + expect(savedEvents).toEqual([event]) + expect( + manager.unreadCounterByGroup$.value.get('transaction-history'), + ).toEqual(0) + }) + + it('should allow to mark 1 event as read', async () => { + const manager = createManager() + + const event1 = createTransactionReceivedEvent() + const event2 = createTransactionReceivedEvent() + const event3 = createTransactionReceivedEvent() + await manager.events.push(event1) + await manager.events.push(event2) + await manager.events.push(event3) + + expect( + manager.unreadCounterByGroup$.value.get('transaction-history'), + ).toEqual(3) + await manager.events.markAsRead(event2.id) + const savedEvents = await manager.events.read() + expect(findEvent([...savedEvents], event2.id)?.isRead).toBeTruthy() + expect( + manager.unreadCounterByGroup$.value.get('transaction-history'), + ).toEqual(2) + }) + + it('should allow to mark all events as read', async () => { + const manager = createManager() + + const event1 = createTransactionReceivedEvent() + const event2 = createTransactionReceivedEvent() + const event3 = createTransactionReceivedEvent() + await manager.events.push(event1) + await manager.events.push(event2) + await manager.events.push(event3) + + await manager.events.markAllAsRead() + const savedEvents = await manager.events.read() + expect(savedEvents.every((event) => event.isRead)).toBeTruthy() + expect( + manager.unreadCounterByGroup$.value.get('transaction-history'), + ).toEqual(0) + }) + + it('should allow to clear events', async () => { + const manager = createManager() + + const event1 = createTransactionReceivedEvent() + const event2 = createTransactionReceivedEvent() + const event3 = createTransactionReceivedEvent() + await manager.events.push(event1) + await manager.events.push(event2) + await manager.events.push(event3) + + await manager.events.clear() + const savedEvents = await manager.events.read() + expect(savedEvents).toEqual([]) + expect( + manager.unreadCounterByGroup$.value.get('transaction-history'), + ).toEqual(0) + }) + + it('should allow to clear all events and reset config', async () => { + const manager = createManager() + + const event1 = createTransactionReceivedEvent() + const event2 = createTransactionReceivedEvent() + const event3 = createTransactionReceivedEvent() + await manager.events.push(event1) + await manager.events.push(event2) + await manager.events.push(event3) + + const config = await manager.config.read() + const newConfig = { + ...config, + [Notifications.Trigger.TransactionReceived]: { + notify: false, + }, + } + await manager.config.save(newConfig) + + await manager.clear() + const savedEvents = await manager.events.read() + const savedConfig = await manager.config.read() + expect(savedEvents).toEqual([]) + expect(savedConfig).toEqual(config) + expect( + manager.unreadCounterByGroup$.value.get('transaction-history'), + ).toEqual(0) + }) + + it('should allow to destroy manager', async () => { + const manager = createManager() + await manager.destroy() + expect(manager.unreadCounterByGroup$.isStopped).toBeTruthy() + }) + + it('should notify user if config is set to true', async () => { + const manager = createManager() + const event = createTransactionReceivedEvent() + const config = await manager.config.read() + const newConfig = { + ...config, + [Notifications.Trigger.TransactionReceived]: { + notify: true, + }, + } + await manager.config.save(newConfig) + await manager.events.push(event) + const savedEvents = await manager.events.read() + expect(savedEvents).toEqual([event]) + expect( + manager.unreadCounterByGroup$.value.get('transaction-history'), + ).toEqual(1) + }) + + it('should not notify user if config is set to false', async () => { + const manager = createManager() + const event = createTransactionReceivedEvent() + const config = await manager.config.read() + const newConfig = { + ...config, + [Notifications.Trigger.TransactionReceived]: { + notify: false, + }, + } + await manager.config.save(newConfig) + await manager.events.push(event) + const savedEvents = await manager.events.read() + expect(savedEvents).toEqual([]) + expect( + manager.unreadCounterByGroup$.value.get('transaction-history'), + ).toEqual(0) + }) + + it('should subscribe to events when called hydrate', async () => { + const eventsStorage = mountAsyncStorage({path: 'events/'}) + const configStorage = mountAsyncStorage({path: 'config/'}) + + const event = createTransactionReceivedEvent() + + const notificationSubscription = new BehaviorSubject(event) + + const manager = notificationManagerMaker({ + eventsStorage, + configStorage, + subscriptions: { + [Notifications.Trigger.TransactionReceived]: notificationSubscription, + [Notifications.Trigger.RewardsUpdated]: new Subject(), + [Notifications.Trigger.PrimaryTokenPriceChanged]: new Subject(), + }, + display: jest.fn(), + }) + + manager.hydrate() + + await new Promise((resolve) => setTimeout(resolve, 1000)) + const savedEventsAfter = await manager.events.read() + expect(savedEventsAfter).toEqual([event]) + await manager.destroy() + }) + + it('should not crash when hydrating with no subscriptions', async () => { + const manager = createManager() + + manager.hydrate() + await manager.destroy() + }) + + it('should only store 100 events', async () => { + const manager = createManager() + + for (let i = 0; i < 110; i++) { + await manager.events.push(createTransactionReceivedEvent()) + } + + const savedEvents = await manager.events.read() + expect(savedEvents).toHaveLength(100) + }) + + it('should should remove oldest events when reaching 100', async () => { + const manager = createManager() + + for (let i = 0; i < 110; i++) { + await manager.events.push(createTransactionReceivedEvent({id: i})) + } + + const savedEvents = await manager.events.read() + const expectedIds = Array.from({length: 100}, (_, i) => i + 10) + const savedIds = savedEvents.map((event) => event.id) + expect(savedIds.sort()).toEqual(expectedIds.sort()) + }) +}) + +const createTransactionReceivedEvent = ( + overrides?: Partial, +): Notifications.TransactionReceivedEvent => ({ + id: Math.random() * 10000, + trigger: Notifications.Trigger.TransactionReceived, + date: new Date().toISOString(), + isRead: false, + metadata: { + previousTxsCounter: 0, + nextTxsCounter: 1, + txId: '1', + isSentByUser: true, + }, + ...overrides, }) + +const findEvent = (events: Notifications.Event[], id: number) => { + return events.find((event) => event.id === id) +} diff --git a/packages/notifications/src/notification-manager.ts b/packages/notifications/src/notification-manager.ts new file mode 100644 index 0000000000..6e97ecca68 --- /dev/null +++ b/packages/notifications/src/notification-manager.ts @@ -0,0 +1,192 @@ +import {BehaviorSubject, Subscription} from 'rxjs' +import {App, Notifications} from '@yoroi/types' + +type EventsStorageData = ReadonlyArray +type ConfigStorageData = Notifications.Config + +const getAllTriggers = (): Array => + Object.values(Notifications.Trigger) + +export const notificationManagerMaker = ({ + eventsStorage, + configStorage, + subscriptions, + display, + eventsLimit = 100, +}: Notifications.ManagerMakerProps): Notifications.Manager => { + const localSubscriptions: Subscription[] = [] + + const hydrate = () => { + const triggers = getAllTriggers() + triggers.forEach((trigger) => { + const subscription = subscriptions?.[trigger]?.subscribe( + (event: Notifications.Event) => events.push(event), + ) + if (subscription) { + localSubscriptions.push(subscription) + } + }) + } + + const config = configManagerMaker({storage: configStorage}) + const {events, unreadCounterByGroup$} = eventsManagerMaker({ + storage: eventsStorage, + config, + display, + eventsLimit, + }) + + const clear = async () => { + await config.reset() + await events.clear() + } + + const destroy = async () => { + unreadCounterByGroup$.complete() + localSubscriptions.forEach((subscription) => subscription.unsubscribe()) + } + + return { + hydrate, + clear, + destroy, + events, + config, + unreadCounterByGroup$, + } +} + +const getNotificationGroup = ( + trigger: Notifications.Trigger, +): Notifications.Group => { + return notificationTriggerGroups[trigger] +} + +const notificationTriggerGroups: Record< + Notifications.Trigger, + Notifications.Group +> = { + [Notifications.Trigger.TransactionReceived]: 'transaction-history', + [Notifications.Trigger.RewardsUpdated]: 'portfolio', + [Notifications.Trigger.PrimaryTokenPriceChanged]: 'portfolio', +} + +const eventsManagerMaker = ({ + storage, + config, + display, + eventsLimit, +}: { + display: (event: Notifications.Event) => void + storage: App.Storage + config: Notifications.Manager['config'] + eventsLimit?: number +}): { + events: Notifications.Manager['events'] + unreadCounterByGroup$: BehaviorSubject< + Readonly> + > +} => { + const unreadCounterByGroup$ = new BehaviorSubject< + Map + >(buildUnreadCounterDefaultValue()) + + const updateUnreadCounter = async () => { + const allEvents = await events.read() + const unreadCounterByGroup = buildUnreadCounterDefaultValue() + const unreadEvents = allEvents.filter((event) => !event.isRead) + unreadEvents.forEach((event) => { + const group = getNotificationGroup(event.trigger) + const previousCount = unreadCounterByGroup.get(group) || 0 + unreadCounterByGroup.set(group, previousCount + 1) + }) + unreadCounterByGroup$.next(unreadCounterByGroup) + } + + const events = { + markAllAsRead: async () => { + const allEvents = await events.read() + const modifiedEvents = allEvents.map((event) => ({ + ...event, + isRead: true, + })) + await storage.setItem('events', modifiedEvents) + unreadCounterByGroup$.next(buildUnreadCounterDefaultValue()) + }, + markAsRead: async (id: number) => { + const allEvents = await events.read() + const modifiedEvents = allEvents.map((event) => + event.id === id ? {...event, isRead: true} : event, + ) + await storage.setItem('events', modifiedEvents) + await updateUnreadCounter() + }, + read: async (): Promise => { + return (await storage.getItem('events')) ?? [] + }, + push: async (event: Readonly) => { + if (!shouldNotify(event, await config.read())) { + return + } + const allEvents = await events.read() + const newEvents = [event, ...allEvents].slice(0, eventsLimit) + await storage.setItem('events', newEvents) + if (!event.isRead) { + await updateUnreadCounter() + display(event) + } + }, + clear: async (): Promise => { + await storage.removeItem('events') + unreadCounterByGroup$.next(buildUnreadCounterDefaultValue()) + }, + } + return {events, unreadCounterByGroup$} +} + +const configManagerMaker = ({ + storage, +}: { + storage: App.Storage +}): Notifications.Manager['config'] => { + return { + read: async (): Promise => { + return ( + (await storage.getItem('config')) ?? defaultConfig + ) + }, + save: async (config: Notifications.Config): Promise => { + await storage.setItem('config', config) + }, + reset: async (): Promise => { + return storage.removeItem('config') + }, + } +} + +const shouldNotify = ( + event: Notifications.Event, + config: Notifications.Config, +): boolean => { + return config[event.trigger].notify +} + +const buildUnreadCounterDefaultValue = (): Map => { + return new Map( + Object.values(notificationTriggerGroups).map((group) => [group, 0]), + ) +} + +const defaultConfig: Notifications.Config = { + [Notifications.Trigger.PrimaryTokenPriceChanged]: { + notify: true, + thresholdInPercent: 10, + interval: '24h', + }, + [Notifications.Trigger.TransactionReceived]: { + notify: true, + }, + [Notifications.Trigger.RewardsUpdated]: { + notify: true, + }, +} diff --git a/packages/notifications/src/translators/reactjs/NotificationProvider.test.tsx b/packages/notifications/src/translators/reactjs/NotificationProvider.test.tsx new file mode 100644 index 0000000000..dcef64853d --- /dev/null +++ b/packages/notifications/src/translators/reactjs/NotificationProvider.test.tsx @@ -0,0 +1,37 @@ +import * as React from 'react' +import { + NotificationProvider, + useNotificationManager, +} from './NotificationProvider' +import AsyncStorage from '@react-native-async-storage/async-storage' +import {render, renderHook} from '@testing-library/react-native' +import {createManagerMock} from './mocks' + +describe('NotificationProvider', () => { + beforeEach(() => AsyncStorage.clear()) + + it('should render', () => { + const manager = createManagerMock() + expect( + render( + + <> + , + ), + ).toBeDefined() + }) + + it('should render hook without crashing', () => { + const manager = createManagerMock() + + const wrapper = ({children}: {children: React.ReactNode}) => ( + {children} + ) + + expect(renderHook(() => useNotificationManager(), {wrapper})).toBeDefined() + }) + + it('should crash hook if it is not wrapped in NotificationProvider', () => { + expect(() => renderHook(() => useNotificationManager())).toThrow() + }) +}) diff --git a/packages/notifications/src/translators/reactjs/NotificationProvider.tsx b/packages/notifications/src/translators/reactjs/NotificationProvider.tsx new file mode 100644 index 0000000000..be5d2670a2 --- /dev/null +++ b/packages/notifications/src/translators/reactjs/NotificationProvider.tsx @@ -0,0 +1,29 @@ +import * as React from 'react' +import {Notifications} from '@yoroi/types' + +type NotificationContextType = { + manager: Notifications.Manager +} + +const Context = React.createContext(null) + +type Props = { + manager: Notifications.Manager + children: React.ReactNode +} + +export const NotificationProvider = ({manager, children}: Props) => { + const value = React.useMemo(() => ({manager}), [manager]) + return {children} +} + +export const useNotificationManager = () => { + const context = React.useContext(Context) + + if (context === null) { + throw new Error( + 'useNotificationManager must be used within a NotificationProvider', + ) + } + return context.manager +} diff --git a/packages/notifications/src/translators/reactjs/mocks.ts b/packages/notifications/src/translators/reactjs/mocks.ts new file mode 100644 index 0000000000..e65d825c88 --- /dev/null +++ b/packages/notifications/src/translators/reactjs/mocks.ts @@ -0,0 +1,12 @@ +import {mountAsyncStorage} from '@yoroi/common' +import {notificationManagerMaker} from '../../notification-manager' + +export const createManagerMock = () => { + const eventsStorage = mountAsyncStorage({path: 'events/'}) + const configStorage = mountAsyncStorage({path: 'config/'}) + return notificationManagerMaker({ + eventsStorage, + configStorage, + display: jest.fn(), + }) +} diff --git a/packages/notifications/src/translators/reactjs/useNotificationsConfig.test.tsx b/packages/notifications/src/translators/reactjs/useNotificationsConfig.test.tsx new file mode 100644 index 0000000000..987103cb64 --- /dev/null +++ b/packages/notifications/src/translators/reactjs/useNotificationsConfig.test.tsx @@ -0,0 +1,29 @@ +import * as React from 'react' +import {renderHook, waitFor} from '@testing-library/react-native' +import {useNotificationsConfig} from './useNotificationsConfig' +import AsyncStorage from '@react-native-async-storage/async-storage' +import {queryClientFixture} from '@yoroi/common' +import {NotificationProvider} from './NotificationProvider' +import {QueryClientProvider} from 'react-query' +import {createManagerMock} from './mocks' + +describe('useNotificationsConfig', () => { + beforeEach(() => AsyncStorage.clear()) + + it('should return notifications config', async () => { + const client = queryClientFixture() + const manager = createManagerMock() + + const wrapper = ({children}: {children: React.ReactNode}) => ( + + + {children} + + + ) + const {result} = renderHook(() => useNotificationsConfig(), {wrapper}) + await waitFor(async () => + expect(result.current.data).toEqual(await manager.config.read()), + ) + }) +}) diff --git a/packages/notifications/src/translators/reactjs/useNotificationsConfig.ts b/packages/notifications/src/translators/reactjs/useNotificationsConfig.ts new file mode 100644 index 0000000000..5164e6b5fc --- /dev/null +++ b/packages/notifications/src/translators/reactjs/useNotificationsConfig.ts @@ -0,0 +1,7 @@ +import {useQuery} from 'react-query' +import {useNotificationManager} from './NotificationProvider' + +export const useNotificationsConfig = () => { + const manager = useNotificationManager() + return useQuery(['notificationsConfig'], () => manager.config.read()) +} diff --git a/packages/notifications/src/translators/reactjs/useReceivedNotificationEvents.test.tsx b/packages/notifications/src/translators/reactjs/useReceivedNotificationEvents.test.tsx new file mode 100644 index 0000000000..eedd80d0fe --- /dev/null +++ b/packages/notifications/src/translators/reactjs/useReceivedNotificationEvents.test.tsx @@ -0,0 +1,69 @@ +import * as React from 'react' +import {act, renderHook, waitFor} from '@testing-library/react-native' +import AsyncStorage from '@react-native-async-storage/async-storage' +import {queryClientFixture} from '@yoroi/common' +import {NotificationProvider} from './NotificationProvider' +import {QueryClientProvider} from 'react-query' +import {useReceivedNotificationEvents} from './useReceivedNotificationEvents' +import {createManagerMock} from './mocks' +import {Notifications} from '@yoroi/types' + +describe('useReceivedNotificationEvents', () => { + beforeEach(() => AsyncStorage.clear()) + + it('should return notification events', async () => { + const client = queryClientFixture() + const manager = createManagerMock() + + const wrapper = ({children}: {children: React.ReactNode}) => ( + + + {children} + + + ) + const {result} = renderHook(() => useReceivedNotificationEvents(), { + wrapper, + }) + await waitFor(async () => + expect(result.current.data).toEqual(await manager.events.read()), + ) + }) + + it('should rerender when there are new notifications', async () => { + const client = queryClientFixture() + const manager = createManagerMock() + + const wrapper = ({children}: {children: React.ReactNode}) => ( + + + {children} + + + ) + const {result} = renderHook(() => useReceivedNotificationEvents(), { + wrapper, + }) + + await waitFor(async () => expect(result.current.data).toHaveLength(0)) + + act(() => { + manager.events.push({ + id: 1, + metadata: { + txId: '123', + isSentByUser: false, + nextTxsCounter: 1, + previousTxsCounter: 0, + }, + date: new Date().toISOString(), + trigger: Notifications.Trigger.TransactionReceived, + isRead: false, + }) + }) + + await waitFor(async () => expect(result.current.data).toHaveLength(1), { + timeout: 1000, + }) + }) +}) diff --git a/packages/notifications/src/translators/reactjs/useReceivedNotificationEvents.ts b/packages/notifications/src/translators/reactjs/useReceivedNotificationEvents.ts new file mode 100644 index 0000000000..3a0fa983a2 --- /dev/null +++ b/packages/notifications/src/translators/reactjs/useReceivedNotificationEvents.ts @@ -0,0 +1,26 @@ +import {useQuery, useQueryClient, UseQueryOptions} from 'react-query' +import {Notifications as NotificationTypes} from '@yoroi/types' +import {useNotificationManager} from './NotificationProvider' +import {useEffect} from 'react' + +export const useReceivedNotificationEvents = ( + options: UseQueryOptions, Error> = {}, +) => { + const queryClient = useQueryClient() + const manager = useNotificationManager() + useEffect(() => { + const subscription = manager.unreadCounterByGroup$.subscribe(() => + queryClient.invalidateQueries(['receivedNotificationEvents']), + ) + return () => { + subscription.unsubscribe() + } + }, [manager, queryClient]) + + const queryFn = () => manager.events.read() + return useQuery({ + queryKey: ['receivedNotificationEvents'], + queryFn, + ...options, + }) +} diff --git a/packages/notifications/src/translators/reactjs/useResetNotificationsConfig.test.tsx b/packages/notifications/src/translators/reactjs/useResetNotificationsConfig.test.tsx new file mode 100644 index 0000000000..faff09861e --- /dev/null +++ b/packages/notifications/src/translators/reactjs/useResetNotificationsConfig.test.tsx @@ -0,0 +1,42 @@ +import * as React from 'react' +import {act, renderHook, waitFor} from '@testing-library/react-native' +import AsyncStorage from '@react-native-async-storage/async-storage' +import {queryClientFixture} from '@yoroi/common' +import {NotificationProvider} from './NotificationProvider' +import {QueryClientProvider} from 'react-query' +import {useResetNotificationsConfig} from './useResetNotificationsConfig' +import {Notifications} from '@yoroi/types' +import {createManagerMock} from './mocks' + +describe('useResetNotificationsConfig', () => { + beforeEach(() => AsyncStorage.clear()) + + it('should allow to reset config', async () => { + const client = queryClientFixture() + const manager = createManagerMock() + + const wrapper = ({children}: {children: React.ReactNode}) => ( + + + {children} + + + ) + const {result} = renderHook(() => useResetNotificationsConfig(), { + wrapper, + }) + await manager.config.save({ + ...(await manager.config.read()), + [Notifications.Trigger.TransactionReceived]: { + notify: false, + }, + }) + act(() => { + result.current.mutate() + }) + + await waitFor(async () => + expect(result.current.data).toEqual(await manager.config.read()), + ) + }) +}) diff --git a/packages/notifications/src/translators/reactjs/useResetNotificationsConfig.ts b/packages/notifications/src/translators/reactjs/useResetNotificationsConfig.ts new file mode 100644 index 0000000000..18e57da40b --- /dev/null +++ b/packages/notifications/src/translators/reactjs/useResetNotificationsConfig.ts @@ -0,0 +1,20 @@ +import {UseMutationOptions} from 'react-query' +import {Notifications as NotificationTypes} from '@yoroi/types' +import {useMutationWithInvalidations} from '@yoroi/common' +import {useNotificationManager} from './NotificationProvider' + +export const useResetNotificationsConfig = ( + options: UseMutationOptions = {}, +) => { + const manager = useNotificationManager() + const mutationFn = async () => { + await manager.config.reset() + return manager.config.read() + } + + return useMutationWithInvalidations({ + mutationFn, + invalidateQueries: [['notificationsConfig']], + ...options, + }) +} diff --git a/packages/notifications/src/translators/reactjs/useUpdateNotificationsConfig.test.tsx b/packages/notifications/src/translators/reactjs/useUpdateNotificationsConfig.test.tsx new file mode 100644 index 0000000000..1fe6ce5a1c --- /dev/null +++ b/packages/notifications/src/translators/reactjs/useUpdateNotificationsConfig.test.tsx @@ -0,0 +1,44 @@ +import * as React from 'react' +import {act, renderHook, waitFor} from '@testing-library/react-native' +import AsyncStorage from '@react-native-async-storage/async-storage' +import {queryClientFixture} from '@yoroi/common' +import {NotificationProvider} from './NotificationProvider' +import {QueryClientProvider} from 'react-query' +import {Notifications} from '@yoroi/types' +import {useUpdateNotificationsConfig} from './useUpdateNotificationsConfig' +import {createManagerMock} from './mocks' + +describe('useUpdateNotificationsConfig', () => { + beforeEach(() => AsyncStorage.clear()) + + it('should allow to update config', async () => { + const client = queryClientFixture() + const manager = createManagerMock() + + const wrapper = ({children}: {children: React.ReactNode}) => ( + + + {children} + + + ) + const {result} = renderHook(() => useUpdateNotificationsConfig(), { + wrapper, + }) + + const initialConfig = await manager.config.read() + + await act(async () => { + result.current.mutate({ + ...(await manager.config.read()), + [Notifications.Trigger.TransactionReceived]: { + notify: false, + }, + }) + }) + + await waitFor(async () => + expect(await manager.config.read()).not.toEqual(initialConfig), + ) + }) +}) diff --git a/packages/notifications/src/translators/reactjs/useUpdateNotificationsConfig.ts b/packages/notifications/src/translators/reactjs/useUpdateNotificationsConfig.ts new file mode 100644 index 0000000000..83e5daabfd --- /dev/null +++ b/packages/notifications/src/translators/reactjs/useUpdateNotificationsConfig.ts @@ -0,0 +1,16 @@ +import {Notifications as NotificationTypes} from '@yoroi/types' +import {useMutationWithInvalidations} from '@yoroi/common' +import {useNotificationManager} from './NotificationProvider' + +export const useUpdateNotificationsConfig = () => { + const manager = useNotificationManager() + + const mutationFn = async (newConfig: NotificationTypes.Config) => { + await manager.config.save(newConfig) + } + + return useMutationWithInvalidations({ + mutationFn, + invalidateQueries: [['notificationsConfig']], + }) +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 9107804cbc..8f738b91ab 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -23,8 +23,8 @@ import {AppMultiStorage, AppMultiStorageOptions} from './app/multi-storage' import {NumberLocale} from './intl/numbers' import {SwapAggregator} from './swap/aggregator' import { - ResolverAddressResponse, ResolverAddressesResponse, + ResolverAddressResponse, ResolverApi, ResolverStrategy, } from './resolver/api' @@ -55,28 +55,28 @@ import {ApiHttpStatusCode} from './api/status-code' import {ApiResponse, ApiResponseError, ApiResponseSuccess} from './api/response' import { ApiErrorBadRequest, - ApiErrorNotFound, ApiErrorConflict, ApiErrorForbidden, ApiErrorGone, + ApiErrorInvalidState, + ApiErrorNetwork, + ApiErrorNotFound, + ApiErrorResponseMalformed, + ApiErrorServerSide, ApiErrorTooEarly, ApiErrorTooManyRequests, ApiErrorUnauthorized, - ApiErrorNetwork, ApiErrorUnknown, - ApiErrorServerSide, - ApiErrorInvalidState, - ApiErrorResponseMalformed, } from './api/errors' import {ResolverNameServer} from './resolver/name-server' import { - ResolverErrorWrongBlockchain, ResolverErrorInvalidDomain, ResolverErrorInvalidResponse, ResolverErrorNotFound, ResolverErrorUnsupportedTld, + ResolverErrorWrongBlockchain, } from './resolver/errors' -import {AppApi, AppFrontendFeeTier, AppFrontendFeesResponse} from './api/app' +import {AppApi, AppFrontendFeesResponse, AppFrontendFeeTier} from './api/app' import { ApiFtMetadata, ApiFtMetadataRecord, @@ -138,31 +138,31 @@ import {ExchangeProvider} from './exchange/provider' import {ExchangeApi} from './exchange/api' import {ExchangeManager} from './exchange/manager' import { - LinksPartnerInfoParams, + LinksBrowserLaunchDappUrlParams, LinksExchangeShowCreateResultParams, - LinksTransferRequestAdaWithLinkParams, + LinksPartnerInfoParams, LinksTransferRequestAdaParams, - LinksYoroiActionInfo, + LinksTransferRequestAdaWithLinkParams, LinksYoroiAction, - LinksYoroiUriConfig, + LinksYoroiActionInfo, LinksYoroiModule, - LinksBrowserLaunchDappUrlParams, + LinksYoroiUriConfig, } from './links/yoroi' import { + PortfolioTokenApplication, PortfolioTokenId, - PortfolioTokenType, + PortfolioTokenNature, PortfolioTokenPropertyType, - PortfolioTokenApplication, PortfolioTokenSource, - PortfolioTokenNature, PortfolioTokenStatus, + PortfolioTokenType, } from './portfolio/token' import {PortfolioTokenDiscovery} from './portfolio/discovery' import {PortfolioTokenInfo} from './portfolio/info' import { + PortfolioPrimaryBreakdown, PortfolioTokenAmount, PortfolioTokenAmountRecords, - PortfolioPrimaryBreakdown, } from './portfolio/amount' import {PortfolioTokenPrice} from './portfolio/price' import {ChainNetwork, ChainSupportedNetworks} from './chain/network' @@ -170,8 +170,8 @@ import {NumbersErrorInvalidAtomicValue} from './numbers/errors' import {NumbersAtomicValue} from './numbers/atomic-value' import { AppErrorInvalidState, - AppErrorWrongPassword, AppErrorLibraryFailed, + AppErrorWrongPassword, } from './app/errors' import { PortfolioApi, @@ -183,14 +183,14 @@ import { } from './portfolio/api' import { PortfolioEventBalanceManager, - PortfolioEventSourceId, - PortfolioEventManagerOn, - PortfolioEventTokenManager, - PortfolioEventTokenManagerSync, - PortfolioEventTokenManagerHydrate, PortfolioEventBalanceManagerHydrate, PortfolioEventBalanceManagerRefresh, PortfolioEventBalanceManagerSync, + PortfolioEventManagerOn, + PortfolioEventSourceId, + PortfolioEventTokenManager, + PortfolioEventTokenManagerHydrate, + PortfolioEventTokenManagerSync, } from './portfolio/event' import { PortfolioStorageBalance, @@ -220,18 +220,18 @@ import { NetworkManager, } from './network/manager' import { - PortfolioTokenActivityRecord, PortfolioTokenActivity, + PortfolioTokenActivityRecord, PortfolioTokenActivityWindow, } from './portfolio/activity' import { + AppLoggerEntry, AppLoggerLevel, + AppLoggerManager, AppLoggerMessage, AppLoggerMetadata, AppLoggerTransporter, AppLoggerTransporterOptions, - AppLoggerEntry, - AppLoggerManager, } from './app/logger' import {ScanErrorUnknown, ScanErrorUnknownContent} from './scan/errors' import { @@ -259,6 +259,17 @@ import { PortfolioTokenHistory, PortfolioTokenHistoryPeriod, } from './portfolio/history' +import { + NotificationConfig, + NotificationEvent, + NotificationGroup, + NotificationManager, + NotificationManagerMakerProps, + NotificationPrimaryTokenPriceChangedEvent, + NotificationRewardsUpdatedEvent, + NotificationTransactionReceivedEvent, + NotificationTrigger, +} from './notifications/manager' import { SwapMakeOrderCalculation, SwapOrderCalculation, @@ -655,6 +666,20 @@ export namespace Network { export type EpochProgress = NetworkEpochProgress } +export namespace Notifications { + export type Config = NotificationConfig + export type Event = NotificationEvent + export type Group = NotificationGroup + export type Manager = NotificationManager + export type ManagerMakerProps = NotificationManagerMakerProps + export type TransactionReceivedEvent = NotificationTransactionReceivedEvent + export type RewardsUpdatedEvent = NotificationRewardsUpdatedEvent + export type PrimaryTokenPriceChangedEvent = + NotificationPrimaryTokenPriceChangedEvent + export const Trigger = NotificationTrigger + export type Trigger = NotificationTrigger +} + export namespace Scan { export namespace Errors { export class UnknownContent extends ScanErrorUnknownContent {} diff --git a/packages/types/src/notifications/manager.ts b/packages/types/src/notifications/manager.ts new file mode 100644 index 0000000000..2e302b4c12 --- /dev/null +++ b/packages/types/src/notifications/manager.ts @@ -0,0 +1,94 @@ +import {BehaviorSubject, Subject} from 'rxjs' +import {AppStorage} from '../app/storage' + +export enum NotificationTrigger { + 'TransactionReceived' = 'TransactionReceived', + 'RewardsUpdated' = 'RewardsUpdated', + 'PrimaryTokenPriceChanged' = 'PrimaryTokenPriceChanged', +} + +export type NotificationManagerMakerProps = { + eventsStorage: AppStorage + configStorage: AppStorage + subscriptions?: Partial< + Record> + > + display: (event: NotificationEvent) => void + eventsLimit?: number +} + +export interface NotificationTransactionReceivedEvent + extends NotificationEventBase { + trigger: NotificationTrigger.TransactionReceived + metadata: { + previousTxsCounter: number + nextTxsCounter: number + txId: string + isSentByUser: boolean // check local pending + } +} + +export interface NotificationRewardsUpdatedEvent extends NotificationEventBase { + trigger: NotificationTrigger.RewardsUpdated +} + +export interface NotificationPrimaryTokenPriceChangedEvent + extends NotificationEventBase { + trigger: NotificationTrigger.PrimaryTokenPriceChanged + metadata: { + previousPrice: number + nextPrice: number + } +} + +export type NotificationGroup = 'transaction-history' | 'portfolio' + +export type NotificationEvent = NotificationTransactionReceivedEvent + +type NotificationEventId = number + +interface NotificationEventBase { + id: NotificationEventId + date: string + isRead: boolean +} + +export type NotificationConfig = { + [NotificationTrigger.PrimaryTokenPriceChanged]: { + notify: boolean + thresholdInPercent: number + interval: '24h' | '1h' + } + [NotificationTrigger.TransactionReceived]: { + notify: boolean + } + [NotificationTrigger.RewardsUpdated]: { + notify: boolean + } +} + +export type NotificationManager = { + hydrate: () => void // build up subscriptions + + unreadCounterByGroup$: BehaviorSubject< + Readonly> + > + + // Events represent a notification event that was trigger by a config rule + events: { + markAllAsRead: () => Promise + markAsRead(id: NotificationEventId): Promise + read: () => Promise> + push: (event: Readonly) => Promise + clear: () => Promise + } + // Config sets the ground to what, when, and if should notify user + config: { + read: () => Promise> // return initial if empty + save: (config: Readonly) => Promise + reset: () => Promise + } + + destroy: () => Promise // tear down subscriptions + clear: () => Promise +} diff --git a/yarn.lock b/yarn.lock index 812aadb4f9..43fe23ca5a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2828,6 +2828,11 @@ resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== +"@jamsinclair/react-native-notifications@^5.1.1": + version "5.1.1" + resolved "https://registry.yarnpkg.com/@jamsinclair/react-native-notifications/-/react-native-notifications-5.1.1.tgz#7723735caca9ea4c3ae9685fd7dc407eb56737c5" + integrity sha512-LAbcDnwVLR5CSLtUuHXp6WQJtbtpjic2k9fgJ5OCbzFyvr8mZ6JHPFqTavQQOTawcMhKvwKpMVUGgxvEn5G4vg== + "@jest/console@^28.1.3": version "28.1.3" resolved "https://registry.yarnpkg.com/@jest/console/-/console-28.1.3.tgz#2030606ec03a18c31803b8a36382762e447655df" @@ -11391,6 +11396,13 @@ expo-asset@~8.9.1: path-browserify "^1.0.0" url-parse "^1.5.9" +expo-background-fetch@^11.8.1: + version "11.8.1" + resolved "https://registry.yarnpkg.com/expo-background-fetch/-/expo-background-fetch-11.8.1.tgz#7ead9ab486d8fcc97181541f9283163e257bc172" + integrity sha512-WkWgj1RZsBjIt/J2HvYhfNi+8WEM6vEptuUj6r/vW9w+kXzDo5z/+pkr7YnPgQIhc4P7Z/j7TlM43azUpIEFZA== + dependencies: + expo-task-manager "~11.7.2" + expo-barcode-scanner@~12.3.2: version "12.3.2" resolved "https://registry.yarnpkg.com/expo-barcode-scanner/-/expo-barcode-scanner-12.3.2.tgz#d0023e8c9a3a8cef769bbc2080b5c275188affe8" @@ -11502,6 +11514,13 @@ expo-system-ui@~2.2.1: "@react-native/normalize-color" "^2.0.0" debug "^4.3.2" +expo-task-manager@11.7.3, expo-task-manager@~11.7.2: + version "11.7.3" + resolved "https://registry.yarnpkg.com/expo-task-manager/-/expo-task-manager-11.7.3.tgz#2d8b0d983e2a73fba4f1f4d65f36ef0db9356ebd" + integrity sha512-ueIOi638o+gRYeCIb9GHVEbdIIvAKqAmm4AllcjDJIrmqB240uA+5a5YwfHm7WUrSthIqHlIzLMFcnMTFxf/9w== + dependencies: + unimodules-app-loader "~4.5.0" + "expo@>=48.0.0-0 <49.0.0": version "48.0.20" resolved "https://registry.yarnpkg.com/expo/-/expo-48.0.20.tgz#098a19b1eba81a15062fa853ae6941fdf9aef1f4" @@ -22447,6 +22466,11 @@ unicode-property-aliases-ecmascript@^2.0.0: resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz#43d41e3be698bd493ef911077c9b131f827e8ccd" integrity sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w== +unimodules-app-loader@~4.5.0: + version "4.5.1" + resolved "https://registry.yarnpkg.com/unimodules-app-loader/-/unimodules-app-loader-4.5.1.tgz#6c8cf83617b6556e99b102fac8a6bbf011abe605" + integrity sha512-yQAsoL0jsCjld1C/iWS4tB5a6LZTb8+enZ9mpt9t9zHYw7YZgeInmW7yz5QZAgVE93ZMPN3SudZHWiXGuD4Wfg== + union-value@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847" From 1bb2adce60c32631fc3d1c6d275507b80baf8d77 Mon Sep 17 00:00:00 2001 From: Michal S Date: Wed, 23 Oct 2024 09:22:42 +0100 Subject: [PATCH 040/113] chore(wallet-mobile): Bump CSL (#3685) --- apps/wallet-mobile/ios/Podfile.lock | 4 +- apps/wallet-mobile/package.json | 12 +-- .../src/features/Discover/common/errors.ts | 7 ++ .../ReviewTransaction/ReviewTransaction.tsx | 33 +++++--- .../Discover/useDappConnectorManager.ts | 11 +-- .../cardano/cip30/cip30-ledger.ts | 7 ++ .../src/yoroi-wallets/cardano/cip30/cip30.ts | 10 +-- packages/common/src/numbers/to-bigint.ts | 2 +- packages/dapp-connector/src/resolver.ts | 4 +- packages/swap/package.json | 4 +- packages/types/package.json | 2 +- yarn.lock | 76 +++++++++---------- 12 files changed, 98 insertions(+), 74 deletions(-) create mode 100644 apps/wallet-mobile/src/features/Discover/common/errors.ts diff --git a/apps/wallet-mobile/ios/Podfile.lock b/apps/wallet-mobile/ios/Podfile.lock index 2c535aff3e..ae5aa705ef 100644 --- a/apps/wallet-mobile/ios/Podfile.lock +++ b/apps/wallet-mobile/ios/Podfile.lock @@ -359,7 +359,7 @@ PODS: - react-native-config/App (= 1.5.1) - react-native-config/App (1.5.1): - React-Core - - react-native-haskell-shelley (6.1.0-beta.1): + - react-native-haskell-shelley (7.1.0): - React - react-native-message_signing-library (1.0.4): - React @@ -904,7 +904,7 @@ SPEC CHECKSUMS: react-native-background-timer: 17ea5e06803401a379ebf1f20505b793ac44d0fe react-native-ble-plx: f10240444452dfb2d2a13a0e4f58d7783e92d76e react-native-config: 86038147314e2e6d10ea9972022aa171e6b1d4d8 - react-native-haskell-shelley: f3eaa102ff7b60063a4aff8bd36d23cb4de49c2e + react-native-haskell-shelley: d69d9b9a7122067b9ee36b76c78c6436d4fc1aba react-native-message_signing-library: 040317fed382be05d79e2ecbe5852d1a20ce68df react-native-mmkv: e97c0c79403fb94577e5d902ab1ebd42b0715b43 react-native-notifications: 985b8e492eeceeaf697aa3f08a1ad30b76a21d9f diff --git a/apps/wallet-mobile/package.json b/apps/wallet-mobile/package.json index 00eba761db..d82e7c7268 100644 --- a/apps/wallet-mobile/package.json +++ b/apps/wallet-mobile/package.json @@ -95,13 +95,13 @@ "@cardano-foundation/ledgerjs-hw-app-cardano": "^7.1.3", "@emurgo/cip14-js": "^3.0.1", "@emurgo/cip4-js": "1.0.7", - "@emurgo/cross-csl-core": "^5.1.0-beta.1", - "@emurgo/cross-csl-mobile": "^5.1.0-beta.1", + "@emurgo/cross-csl-core": "^6.1.0", + "@emurgo/cross-csl-mobile": "^6.1.0", "@emurgo/cross-msl-mobile": "^1.0.1", - "@emurgo/csl-mobile-bridge": "6.1.0-beta.1", + "@emurgo/csl-mobile-bridge": "^7.1.0", "@emurgo/msl-mobile-bridge": "^1.0.4", "@emurgo/react-native-hid": "5.15.8", - "@emurgo/yoroi-lib": "^1.2.1", + "@emurgo/yoroi-lib": "^2.0.0", "@formatjs/intl-datetimeformat": "^6.7.0", "@formatjs/intl-getcanonicallocales": "^2.1.0", "@formatjs/intl-locale": "^3.2.1", @@ -228,8 +228,8 @@ "@babel/preset-env": "^7.20.0", "@babel/preset-react": "^7.16.7", "@babel/runtime": "^7.20.0", - "@emurgo/cardano-serialization-lib-nodejs": "~12.1.0-beta.1", - "@emurgo/cross-csl-nodejs": "^5.1.0-beta.1", + "@emurgo/cardano-serialization-lib-nodejs": "^13.1.0", + "@emurgo/cross-csl-nodejs": "^6.1.0", "@emurgo/cross-msl-nodejs": "^1.0.0", "@formatjs/cli": "^6.1.0", "@formatjs/ts-transformer": "^3.13.0", diff --git a/apps/wallet-mobile/src/features/Discover/common/errors.ts b/apps/wallet-mobile/src/features/Discover/common/errors.ts new file mode 100644 index 0000000000..550909018d --- /dev/null +++ b/apps/wallet-mobile/src/features/Discover/common/errors.ts @@ -0,0 +1,7 @@ +const USER_REJECTED_ERROR_MESSAGE = 'User rejected' + +export const userRejectedError = () => new Error(USER_REJECTED_ERROR_MESSAGE) + +export const isUserRejectedError = (error: Error): boolean => { + return error.message === USER_REJECTED_ERROR_MESSAGE +} diff --git a/apps/wallet-mobile/src/features/Discover/useCases/ReviewTransaction/ReviewTransaction.tsx b/apps/wallet-mobile/src/features/Discover/useCases/ReviewTransaction/ReviewTransaction.tsx index 6d503c4850..4b189642db 100644 --- a/apps/wallet-mobile/src/features/Discover/useCases/ReviewTransaction/ReviewTransaction.tsx +++ b/apps/wallet-mobile/src/features/Discover/useCases/ReviewTransaction/ReviewTransaction.tsx @@ -8,7 +8,7 @@ import {useEffect} from 'react' import {StyleSheet, View} from 'react-native' import {TouchableOpacity} from 'react-native-gesture-handler' import {SafeAreaView} from 'react-native-safe-area-context' -import {useQuery} from 'react-query' +import {useMutation, useQuery} from 'react-query' import {z} from 'zod' import {Button} from '../../../../components/Button/Button' @@ -17,6 +17,7 @@ import {Icon} from '../../../../components/Icon' import {ScrollView} from '../../../../components/ScrollView/ScrollView' import {Spacer} from '../../../../components/Spacer/Spacer' import {Text} from '../../../../components/Text' +import {logger} from '../../../../kernel/logger/logger' import {useParams} from '../../../../kernel/navigation' import {cip30LedgerExtensionMaker} from '../../../../yoroi-wallets/cardano/cip30/cip30-ledger' import {wrappedCsl} from '../../../../yoroi-wallets/cardano/wrappedCsl' @@ -25,6 +26,7 @@ import {asQuantity} from '../../../../yoroi-wallets/utils/utils' import {usePortfolioTokenInfos} from '../../../Portfolio/common/hooks/usePortfolioTokenInfos' import {useSelectedWallet} from '../../../WalletManager/common/hooks/useSelectedWallet' import {useConfirmHWConnectionModal} from '../../common/ConfirmHWConnectionModal' +import {isUserRejectedError, userRejectedError} from '../../common/errors' import {usePromptRootKey} from '../../common/hooks' import {useStrings} from '../../common/useStrings' @@ -54,7 +56,7 @@ export const ReviewTransaction = () => { const {styles} = useStyles() - const signTxWithHW = useSignTxWithHW() + const {sign: signTxWithHW} = useSignTxWithHW() const handleOnConfirm = async () => { if (!params.isHW) { @@ -63,8 +65,13 @@ export const ReviewTransaction = () => { return } - const signature = await signTxWithHW(params.cbor, params.partial) - params.onConfirm(signature) + signTxWithHW( + {cbor: params.cbor, partial: params.partial}, + { + onSuccess: (signature) => params.onConfirm(signature), + onError: (error) => logger.error('ReviewTransaction::handleOnConfirm', {error}), + }, + ) } useEffect(() => { @@ -437,7 +444,7 @@ const useConnectorPromptRootKey = () => { return Promise.resolve() }, onClose: () => { - if (shouldResolveOnClose) reject(new Error('User rejected')) + if (shouldResolveOnClose) reject(userRejectedError()) }, }) } catch (error) { @@ -451,15 +458,15 @@ export const useSignTxWithHW = () => { const {confirmHWConnection, closeModal} = useConfirmHWConnectionModal() const {wallet, meta} = useSelectedWallet() - return React.useCallback( - (cbor: string, partial?: boolean) => { + const mutationFn = React.useCallback( + (options: {cbor: string; partial?: boolean}) => { return new Promise((resolve, reject) => { let shouldResolveOnClose = true confirmHWConnection({ onConfirm: async ({transportType, deviceInfo}) => { try { const cip30 = cip30LedgerExtensionMaker(wallet, meta) - const tx = await cip30.signTx(cbor, partial ?? false, deviceInfo, transportType === 'USB') + const tx = await cip30.signTx(options.cbor, options.partial ?? false, deviceInfo, transportType === 'USB') shouldResolveOnClose = false return resolve(tx) } catch (error) { @@ -469,11 +476,19 @@ export const useSignTxWithHW = () => { } }, onClose: () => { - if (shouldResolveOnClose) reject(new Error('User rejected')) + if (shouldResolveOnClose) reject(userRejectedError()) }, }) }) }, [confirmHWConnection, wallet, meta, closeModal], ) + + const mutation = useMutation({ + mutationFn, + useErrorBoundary: (error) => !isUserRejectedError(error), + mutationKey: ['useSignTxWithHW'], + }) + + return {...mutation, sign: mutation.mutate} } diff --git a/apps/wallet-mobile/src/features/Discover/useDappConnectorManager.ts b/apps/wallet-mobile/src/features/Discover/useDappConnectorManager.ts index 0cf137510b..a0a0a3ad89 100644 --- a/apps/wallet-mobile/src/features/Discover/useDappConnectorManager.ts +++ b/apps/wallet-mobile/src/features/Discover/useDappConnectorManager.ts @@ -6,6 +6,7 @@ import {InteractionManager} from 'react-native' import {useSelectedWallet} from '../WalletManager/common/hooks/useSelectedWallet' import {useOpenConfirmConnectionModal} from './common/ConfirmConnectionModal' +import {userRejectedError} from './common/errors' import {createDappConnector} from './common/helpers' import {usePromptRootKey} from './common/hooks' import {useShowHWNotSupportedModal} from './common/HWNotSupportedModal' @@ -44,7 +45,7 @@ export const useDappConnectorManager = () => { onCancel: () => { if (!shouldResolve) return shouldResolve = false - reject(new Error('User rejected')) + reject(userRejectedError()) }, }) }) @@ -67,7 +68,7 @@ export const useDappConnectorManager = () => { onCancel: () => { if (!shouldResolve) return shouldResolve = false - reject(new Error('User rejected')) + reject(userRejectedError()) }, }) }) @@ -98,7 +99,7 @@ const useSignData = () => { return Promise.resolve() }, onClose: () => { - if (shouldResolveOnClose) reject(new Error('User rejected')) + if (shouldResolveOnClose) reject(userRejectedError()) }, }) } catch (error) { @@ -120,10 +121,10 @@ const useSignDataWithHW = () => { onConfirm: () => { closeModal() shouldResolveOnClose = false - return reject(new Error('User rejected')) + return reject(userRejectedError()) }, onClose: () => { - if (shouldResolveOnClose) reject(new Error('User rejected')) + if (shouldResolveOnClose) reject(userRejectedError()) }, }) }) diff --git a/apps/wallet-mobile/src/yoroi-wallets/cardano/cip30/cip30-ledger.ts b/apps/wallet-mobile/src/yoroi-wallets/cardano/cip30/cip30-ledger.ts index b375a2f489..533e6a1d53 100644 --- a/apps/wallet-mobile/src/yoroi-wallets/cardano/cip30/cip30-ledger.ts +++ b/apps/wallet-mobile/src/yoroi-wallets/cardano/cip30/cip30-ledger.ts @@ -1,4 +1,5 @@ import {Transaction, WasmModuleProxy} from '@emurgo/cross-csl-core' +import {has_transaction_set_tag, TransactionSetsState} from '@emurgo/csl-mobile-bridge' import {createSignedLedgerTxFromCbor} from '@emurgo/yoroi-lib' import {normalizeToAddress} from '@emurgo/yoroi-lib/dist/internals/utils/addresses' import {HW, Wallet} from '@yoroi/types' @@ -25,6 +26,12 @@ class CIP30LedgerExtension { if (!partial) await assertHasAllSigners(cbor, this.wallet, this.meta) const txBody = await tx.body() + const transactionSetTag = await has_transaction_set_tag(await tx.toBytes()) + + if (transactionSetTag === TransactionSetsState.MixedSets) { + throw new Error('CIP30LedgerExtension.signTx: Mixed transaction sets are not supported when using a HW wallet') + } + const payload = await toLedgerSignRequest( csl, txBody, diff --git a/apps/wallet-mobile/src/yoroi-wallets/cardano/cip30/cip30.ts b/apps/wallet-mobile/src/yoroi-wallets/cardano/cip30/cip30.ts index 5610c2496e..133edaa5fe 100644 --- a/apps/wallet-mobile/src/yoroi-wallets/cardano/cip30/cip30.ts +++ b/apps/wallet-mobile/src/yoroi-wallets/cardano/cip30/cip30.ts @@ -1,7 +1,7 @@ import * as CSL from '@emurgo/cross-csl-core' import {TransactionWitnessSet, WasmModuleProxy} from '@emurgo/cross-csl-core' import {init} from '@emurgo/cross-msl-mobile' -import {RemoteUnspentOutput, signRawTransaction, UtxoAsset} from '@emurgo/yoroi-lib' +import {hashTransaction, RemoteUnspentOutput, signRawTransaction, UtxoAsset} from '@emurgo/yoroi-lib' import {normalizeToAddress} from '@emurgo/yoroi-lib/dist/internals/utils/addresses' import {parseTokenList} from '@emurgo/yoroi-lib/dist/internals/utils/assets' import {Balance, Wallet} from '@yoroi/types' @@ -203,14 +203,10 @@ class CIP30Extension { const tx = await csl.Transaction.fromHex(cbor) const txBody = await tx.body() const signedTx = await csl.Transaction.new(txBody, witnesses, undefined) + const signedTxBytes = await signedTx.toBytes() - const txId = await signedTx - .body() - .then((txBody) => csl.hashTransaction(txBody)) - .then((hash) => hash.toBytes()) - .then((bytes) => Buffer.from(bytes).toString('hex')) + const txId = await (await hashTransaction(csl, signedTxBytes)).toHex() - const signedTxBytes = await signedTx.toBytes() await this.wallet.submitTransaction(Buffer.from(signedTxBytes).toString('base64')) return getTransactionUnspentOutput({ txId, diff --git a/packages/common/src/numbers/to-bigint.ts b/packages/common/src/numbers/to-bigint.ts index a1564a8424..7a631f51cc 100644 --- a/packages/common/src/numbers/to-bigint.ts +++ b/packages/common/src/numbers/to-bigint.ts @@ -26,7 +26,7 @@ export function toBigInt( typeof quantity === 'string' ? quantity.replace(/(?!^-)[^\d.-]/g, '') : quantity - const bigNumber = BigNumber(sanitized || 0) + const bigNumber = new BigNumber(sanitized || 0) const scaledNumber = bigNumber.shiftedBy(decimalPlaces) diff --git a/packages/dapp-connector/src/resolver.ts b/packages/dapp-connector/src/resolver.ts index 75ceed8a95..eb308a7b12 100644 --- a/packages/dapp-connector/src/resolver.ts +++ b/packages/dapp-connector/src/resolver.ts @@ -119,8 +119,8 @@ export const resolver: Resolver = { if (result === null || result.length === 0) { const balance = await context.wallet.getBalance('*') - const coin = BigNumber(await (await balance.coin()).toStr()) - if (coin.isGreaterThan(BigNumber(value))) { + const coin = new BigNumber(await (await balance.coin()).toStr()) + if (coin.isGreaterThan(new BigNumber(value))) { try { const utxo = await context.wallet.sendReorganisationTx(value) return [await utxo.toHex()] diff --git a/packages/swap/package.json b/packages/swap/package.json index 2b6158460c..bcb17ab2ca 100644 --- a/packages/swap/package.json +++ b/packages/swap/package.json @@ -139,7 +139,7 @@ }, "devDependencies": { "@commitlint/config-conventional": "^17.0.2", - "@emurgo/yoroi-lib": "^1.2.1", + "@emurgo/yoroi-lib": "^2.0.0", "@react-native-async-storage/async-storage": "^1.19.3", "@react-native-community/eslint-config": "^3.0.2", "@release-it/conventional-changelog": "^5.0.0", @@ -175,7 +175,7 @@ "typescript": "^5.3.3" }, "peerDependencies": { - "@emurgo/yoroi-lib": "^1.2.1", + "@emurgo/yoroi-lib": "^2.0.0", "@react-native-async-storage/async-storage": ">= 1.19.3 <= 1.20.0", "@yoroi/api": "1.5.2", "@yoroi/common": "1.5.4", diff --git a/packages/types/package.json b/packages/types/package.json index fe0f2833bd..506d58eda7 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -117,7 +117,7 @@ "typescript": "^5.3.3" }, "peerDependencies": { - "@emurgo/yoroi-lib": "^1.2.1", + "@emurgo/yoroi-lib": "^2.0.0", "axios": "^1.5.0", "bignumber.js": "^9.0.1", "rxjs": "^7.8.1" diff --git a/yarn.lock b/yarn.lock index 43fe23ca5a..86235eefed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2064,10 +2064,15 @@ resolved "https://registry.yarnpkg.com/@emurgo/cardano-message-signing-nodejs/-/cardano-message-signing-nodejs-1.0.1.tgz#b2fa1f7541055a6c4b8e805492b1a9362bea5835" integrity sha512-PoKh1tQnJX18f8iEr8Jk1KXxKCn9eqaSslMI1pyOJvYRJhQVDLCh0+9YReufjp0oFJIY1ShcrR+4/WnECVZUKQ== -"@emurgo/cardano-serialization-lib-nodejs@12.1.0-beta.1", "@emurgo/cardano-serialization-lib-nodejs@~12.1.0-beta.1": - version "12.1.0-beta.1" - resolved "https://registry.yarnpkg.com/@emurgo/cardano-serialization-lib-nodejs/-/cardano-serialization-lib-nodejs-12.1.0-beta.1.tgz#26fab89866bd7e3c2d52e7f7be29cf051abb77ad" - integrity sha512-7O5t8iab8cM6UIu51CRIpIOj8CzYRpbY7o2ddY8ozMu10mfSqE4vIT8BaN7q5fEk56BsVmkZckA4nwjtPZ1Vxg== +"@emurgo/cardano-serialization-lib-nodejs-gc@12.1.1": + version "12.1.1" + resolved "https://registry.yarnpkg.com/@emurgo/cardano-serialization-lib-nodejs-gc/-/cardano-serialization-lib-nodejs-gc-12.1.1.tgz#09340d7292588d63d9566bbcd5c54098aa6f8829" + integrity sha512-PRay6ceSQhH8OsOOUG4QwPU6u1wXFDMoxcHv4KENnIjLk8pKb0ggLr6S1GZ603YJRi9KLh+pw97yL08a8ZG0PA== + +"@emurgo/cardano-serialization-lib-nodejs@13.1.0", "@emurgo/cardano-serialization-lib-nodejs@^13.1.0": + version "13.1.0" + resolved "https://registry.yarnpkg.com/@emurgo/cardano-serialization-lib-nodejs/-/cardano-serialization-lib-nodejs-13.1.0.tgz#afa891a97a98dbbc36cfc5869da895ef5df11cbb" + integrity sha512-KBIBGEOjTWmilQ3VHTydtMgGSiJkVK14b5N+DwBp2kkO1IqwJyzFQOWDMkQGgEFuJ1Y4Yu05xyxph1JY9/m65w== "@emurgo/cip14-js@^3.0.1": version "3.0.1" @@ -2086,35 +2091,27 @@ buffer-crc32 "0.2.13" fnv-plus "1.3.1" -"@emurgo/cross-csl-core@5.1.0-beta.1", "@emurgo/cross-csl-core@^5.1.0-beta.1": - version "5.1.0-beta.1" - resolved "https://registry.yarnpkg.com/@emurgo/cross-csl-core/-/cross-csl-core-5.1.0-beta.1.tgz#b905acc50912e7a5cacd5fe537cb474e9b61d54c" - integrity sha512-Qn1haxl7Wrw7k9drLDkTjGfRx80k4SSgytWUfFa/pbyaJmusfM6no0oLwcb/D8Y09i9+GEo3AGzQz+L4d18OFA== - dependencies: - "@cardano-foundation/ledgerjs-hw-app-cardano" "^5.0.0" - "@types/mocha" "^9.1.1" - axios "^0.24.0" - bech32 "^2.0.0" - bignumber.js "^9.0.1" - blake2b "^2.1.4" - hash-wasm "^4.9.0" - mocha "^10.0.0" +"@emurgo/cross-csl-core@6.1.0", "@emurgo/cross-csl-core@^6.1.0": + version "6.1.0" + resolved "https://registry.yarnpkg.com/@emurgo/cross-csl-core/-/cross-csl-core-6.1.0.tgz#769dee3f5635a99cce0f9e1baa7b9b99690e111a" + integrity sha512-eM6jyhiYkLhie0AKyRpvM1bFpvm3hnbwrbJKy/AB47fZUqbqn/BAIQScQ3tXce0Z9qQ24QLQLBLdqU5u3mL80A== -"@emurgo/cross-csl-mobile@^5.1.0-beta.1": - version "5.1.0-beta.1" - resolved "https://registry.yarnpkg.com/@emurgo/cross-csl-mobile/-/cross-csl-mobile-5.1.0-beta.1.tgz#ef7f318687a89c7fca56387c9a01e13a340c0ee4" - integrity sha512-9+iWn6z9HXiWu/4hUzoAQXxm4oYF3ibz5XGLeP+WTAbtwP8L95TCtzYvZi0uwX0fiTvci6yR4jB0ll4bGR+Viw== +"@emurgo/cross-csl-mobile@^6.1.0": + version "6.1.0" + resolved "https://registry.yarnpkg.com/@emurgo/cross-csl-mobile/-/cross-csl-mobile-6.1.0.tgz#86c79fb0d9bad436f239faa8fd0b9e644c764ac9" + integrity sha512-xDtZyGiMv+BVkKzXM5SAGkl2w+tUGIoPm/1eNYz7dsDImcwzfEXLrsP4Uqw67UQcglVz/hSdkMSZTWRnkAL7Kw== dependencies: - "@emurgo/cross-csl-core" "5.1.0-beta.1" - "@emurgo/csl-mobile-bridge" "6.1.0-beta.1" + "@emurgo/cross-csl-core" "6.1.0" + "@emurgo/csl-mobile-bridge" "^7.1.0" -"@emurgo/cross-csl-nodejs@^5.1.0-beta.1": - version "5.1.0-beta.1" - resolved "https://registry.yarnpkg.com/@emurgo/cross-csl-nodejs/-/cross-csl-nodejs-5.1.0-beta.1.tgz#c7dc88c63cba5a76005fdf045464f9402783e729" - integrity sha512-i9+y4cewtjzqueEV57xOKPwBp7riOEE8PeK+8ruee0V4glbehcct5ImruB1kadoxV1v1Z3tFrqfLzoarecHHSQ== +"@emurgo/cross-csl-nodejs@^6.1.0": + version "6.1.0" + resolved "https://registry.yarnpkg.com/@emurgo/cross-csl-nodejs/-/cross-csl-nodejs-6.1.0.tgz#5683d96d5a7220c73a8b327a41c282796110553e" + integrity sha512-xcfAJNT+u4mUF9YEfLOl2m7tfOG/85AFQazuSh8nMVtfdqC8npkuGCSk/CtSB1wLfXG0Nd+e+S4vZfU4JCoVlQ== dependencies: - "@emurgo/cardano-serialization-lib-nodejs" "12.1.0-beta.1" - "@emurgo/cross-csl-core" "5.1.0-beta.1" + "@emurgo/cardano-serialization-lib-nodejs" "13.1.0" + "@emurgo/cardano-serialization-lib-nodejs-gc" "12.1.1" + "@emurgo/cross-csl-core" "6.1.0" "@emurgo/cross-msl-core@1.0.0": version "1.0.0" @@ -2145,10 +2142,10 @@ "@emurgo/cardano-message-signing-nodejs" "1.0.1" "@emurgo/cross-msl-core" "1.0.0" -"@emurgo/csl-mobile-bridge@6.1.0-beta.1": - version "6.1.0-beta.1" - resolved "https://registry.yarnpkg.com/@emurgo/csl-mobile-bridge/-/csl-mobile-bridge-6.1.0-beta.1.tgz#96b1c05da987aa0715409eaec97572c424614b36" - integrity sha512-CF+yaCcc0+v5yuKJr6hV3FOJx0OpeAa1AaqCmhsvtbDv0Q6w0KF7GGbdAmjJrd4kDQ956LJL/15jqUo1y4i1yw== +"@emurgo/csl-mobile-bridge@^7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@emurgo/csl-mobile-bridge/-/csl-mobile-bridge-7.1.0.tgz#aa77001eae610c64444ab5336ee65b7014b2c133" + integrity sha512-9J5kgD8cLBaKAkfUDa1XacpUTnR7I3gWhaT3Rpr6YaJqctWqMLW2XgU7Q8oOG6i5S8I4s5uju/bXVDqiHCEliA== dependencies: base-64 "0.1.0" @@ -2170,18 +2167,19 @@ "@ledgerhq/logs" "^5.15.0" rxjs "^6.5.5" -"@emurgo/yoroi-lib@^1.2.1": - version "1.2.1" - resolved "https://registry.yarnpkg.com/@emurgo/yoroi-lib/-/yoroi-lib-1.2.1.tgz#e8240f1826ae4c37575de54604e57b38b499600f" - integrity sha512-ocIGCBl3AUe/t56lHh3KRpmxTonT/whWpIFiEEFMlx33MWtpAAtV3EccxGqxDpJLBGTY0TL2S6EZVlWIetGFTQ== +"@emurgo/yoroi-lib@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@emurgo/yoroi-lib/-/yoroi-lib-2.0.0.tgz#5f8361a942f9aa8094c842ffece1893cc33f7b63" + integrity sha512-uXeUVHKEE633iBsEGxpbgK/NrXZzBvWRWGk1//rC9ZQsHNhGzSqgr99k4O9b6XjTBxZf7Yndrs8vdpEwEKVFyw== dependencies: "@cardano-foundation/ledgerjs-hw-app-cardano" "^7.1.3" - "@emurgo/cross-csl-core" "^5.1.0-beta.1" + "@emurgo/cross-csl-core" "6.1.0" "@noble/hashes" "^1.3.2" axios "^1.7.5" axios-cache-interceptor "^1.5.3" bech32 "^2.0.0" bignumber.js "^9.0.1" + blake2b "2.1.4" easy-crc "1.1.0" "@eslint-community/eslint-utils@^4.2.0": @@ -7787,7 +7785,7 @@ blake2b@2.1.3: blake2b-wasm "^1.1.0" nanoassert "^1.0.0" -blake2b@^2.1.4: +blake2b@2.1.4, blake2b@^2.1.4: version "2.1.4" resolved "https://registry.yarnpkg.com/blake2b/-/blake2b-2.1.4.tgz#817d278526ddb4cd673bfb1af16d1ad61e393ba3" integrity sha512-AyBuuJNI64gIvwx13qiICz6H6hpmjvYS5DGkG6jbXMOT8Z3WUJ3V1X0FlhIoT1b/5JtHE3ki+xjtMvu1nn+t9A== From 5a8dcc72d88ec70bddf6ba29daadabf94cd6548a Mon Sep 17 00:00:00 2001 From: banklesss <105349292+banklesss@users.noreply.github.com> Date: Thu, 24 Oct 2024 16:45:55 +0200 Subject: [PATCH 041/113] feature(wallet-mobile): new tx review for withdraw staking rewards (#3707) Co-authored-by: jorbuedo --- .../ReviewTx/common/ReviewTxProvider.tsx | 35 +- .../ReviewTx/common/hooks/useFormattedTx.tsx | 14 +- .../ReviewTx/common/hooks/useOnConfirm.tsx | 23 +- .../ReviewTx/common/hooks/useStrings.tsx | 15 + .../features/ReviewTx/common/operations.tsx | 108 ++++-- .../src/features/ReviewTx/common/types.ts | 43 ++- .../ReviewTxScreen/Overview/OverviewTab.tsx | 15 +- .../ReviewTxScreen/ReviewTxScreen.tsx | 2 +- .../Staking/Governance/common/helpers.tsx | 35 +- .../Governance/useCases/Home/HomeScreen.tsx | 3 - .../src/kernel/i18n/locales/en-US.json | 3 + .../WithdrawStakingRewards.tsx | 28 +- .../PoolTransition/usePoolTransition.tsx | 5 +- .../Staking/StakingCenter/StakingCenter.tsx | 11 +- .../src/yoroi-wallets/hooks/index.ts | 19 ++ .../ReviewTx/common/hooks/useStrings.json | 309 ++++++++++-------- .../WithdrawStakingRewards.json | 72 ++-- .../PoolTransition/usePoolTransition.json | 104 +++--- .../Staking/StakingCenter/StakingCenter.json | 16 +- 19 files changed, 511 insertions(+), 349 deletions(-) diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/ReviewTxProvider.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/ReviewTxProvider.tsx index 44274bd96c..c882fef6e7 100644 --- a/apps/wallet-mobile/src/features/ReviewTx/common/ReviewTxProvider.tsx +++ b/apps/wallet-mobile/src/features/ReviewTx/common/ReviewTxProvider.tsx @@ -31,9 +31,10 @@ export const ReviewTxProvider = ({ dispatch({type: ReviewTxActionType.OnSuccessChanged, onSuccess}), onErrorChanged: (onError: ReviewTxState['onError']) => dispatch({type: ReviewTxActionType.OnErrorChanged, onError}), onNotSupportedCIP1694Changed: (onNotSupportedCIP1694: ReviewTxState['onNotSupportedCIP1694']) => - dispatch({type: ReviewTxActionType.onNotSupportedCIP1694Changed, onNotSupportedCIP1694}), + dispatch({type: ReviewTxActionType.OnNotSupportedCIP1694Changed, onNotSupportedCIP1694}), onCIP36SupportChangeChanged: (onCIP36SupportChange: ReviewTxState['onCIP36SupportChange']) => - dispatch({type: ReviewTxActionType.onCIP36SupportChangeChanged, onCIP36SupportChange}), + dispatch({type: ReviewTxActionType.OnCIP36SupportChangeChanged, onCIP36SupportChange}), + reset: () => dispatch({type: ReviewTxActionType.Reset}), }).current const context = React.useMemo( @@ -78,14 +79,26 @@ const reviewTxReducer = (state: ReviewTxState, action: ReviewTxAction) => { draft.onError = action.onError break - case ReviewTxActionType.onNotSupportedCIP1694Changed: + case ReviewTxActionType.OnNotSupportedCIP1694Changed: draft.onNotSupportedCIP1694 = action.onNotSupportedCIP1694 break - case ReviewTxActionType.onCIP36SupportChangeChanged: + case ReviewTxActionType.OnCIP36SupportChangeChanged: draft.onCIP36SupportChange = action.onCIP36SupportChange break + case ReviewTxActionType.Reset: + draft.unsignedTx = castDraft(defaultState.unsignedTx) + draft.cbor = defaultState.cbor + draft.operations = defaultState.operations + draft.customReceiverTitle = defaultState.customReceiverTitle + draft.details = defaultState.details + draft.onSuccess = defaultState.onSuccess + draft.onError = defaultState.onError + draft.onNotSupportedCIP1694 = defaultState.onNotSupportedCIP1694 + draft.onCIP36SupportChange = defaultState.onCIP36SupportChange + break + default: throw new Error('[ReviewTxContext] invalid action') } @@ -122,13 +135,16 @@ type ReviewTxAction = onError: ReviewTxState['onError'] } | { - type: ReviewTxActionType.onNotSupportedCIP1694Changed + type: ReviewTxActionType.OnNotSupportedCIP1694Changed onNotSupportedCIP1694: ReviewTxState['onNotSupportedCIP1694'] } | { - type: ReviewTxActionType.onCIP36SupportChangeChanged + type: ReviewTxActionType.OnCIP36SupportChangeChanged onCIP36SupportChange: ReviewTxState['onCIP36SupportChange'] } + | { + type: ReviewTxActionType.Reset + } export type ReviewTxState = { unsignedTx: YoroiUnsignedTx | null @@ -152,6 +168,7 @@ type ReviewTxActions = { onErrorChanged: (onError: ReviewTxState['onError']) => void onNotSupportedCIP1694Changed: (onNotSupportedCIP1694: ReviewTxState['onNotSupportedCIP1694']) => void onCIP36SupportChangeChanged: (onCIP36SupportChange: ReviewTxState['onCIP36SupportChange']) => void + reset: () => void } const defaultState: ReviewTxState = Object.freeze({ @@ -181,6 +198,7 @@ const initialReviewTxContext: ReviewTxContext = { onErrorChanged: missingInit, onNotSupportedCIP1694Changed: missingInit, onCIP36SupportChangeChanged: missingInit, + reset: missingInit, } enum ReviewTxActionType { @@ -191,8 +209,9 @@ enum ReviewTxActionType { DetailsChanged = 'detailsChanged', OnSuccessChanged = 'onSuccessChanged', OnErrorChanged = 'onErrorChanged', - onNotSupportedCIP1694Changed = 'onNotSupportedCIP1694Changed', - onCIP36SupportChangeChanged = 'onCIP36SupportChangeChanged', + OnNotSupportedCIP1694Changed = 'onNotSupportedCIP1694Changed', + OnCIP36SupportChangeChanged = 'onCIP36SupportChangeChanged', + Reset = 'reset', } type ReviewTxContext = ReviewTxState & ReviewTxActions diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useFormattedTx.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useFormattedTx.tsx index e01bdae3ea..133c96b4e6 100644 --- a/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useFormattedTx.tsx +++ b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useFormattedTx.tsx @@ -1,4 +1,3 @@ -// import {CredKind} from '@emurgo/csl-mobile-bridge' import {CredKind} from '@emurgo/cross-csl-core' import {isNonNullable} from '@yoroi/common' import {infoExtractName} from '@yoroi/portfolio' @@ -14,6 +13,7 @@ import {asQuantity} from '../../../../yoroi-wallets/utils/utils' import {usePortfolioTokenInfos} from '../../../Portfolio/common/hooks/usePortfolioTokenInfos' import {useSelectedWallet} from '../../../WalletManager/common/hooks/useSelectedWallet' import { + FormattedCertificate, FormattedFee, FormattedInputs, FormattedOutputs, @@ -50,12 +50,13 @@ export const useFormattedTx = (data: TransactionBody): FormattedTx => { const formattedInputs = useFormattedInputs(wallet, inputs, portfolioTokenInfos) const formattedOutputs = useFormattedOutputs(wallet, outputs, portfolioTokenInfos) const formattedFee = formatFee(wallet, data) + const formattedCertificates = formatCertificates(data.certs) return { inputs: formattedInputs, outputs: formattedOutputs, fee: formattedFee, - certificates: data.certs ?? null, + certificates: formattedCertificates, } } @@ -220,6 +221,15 @@ export const formatFee = (wallet: YoroiWallet, data: TransactionBody): Formatted } } +const formatCertificates = (certificates: TransactionBody['certs']) => { + return ( + certificates?.map((cert) => { + const [type, certificate] = Object.entries(cert)[0] + return {type, certificate} as unknown as FormattedCertificate + }) ?? null + ) +} + const deriveAddress = async (address: string, chainId: number) => { try { return await deriveRewardAddressFromAddress(address, chainId) diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useOnConfirm.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useOnConfirm.tsx index b00d30458b..129affceba 100644 --- a/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useOnConfirm.tsx +++ b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useOnConfirm.tsx @@ -6,6 +6,7 @@ import {ConfirmTxWithSpendingPasswordModal} from '../../../../components/Confirm import {useModal} from '../../../../components/Modal/ModalContext' import {YoroiSignedTx, YoroiUnsignedTx} from '../../../../yoroi-wallets/types/yoroi' import {useSelectedWallet} from '../../../WalletManager/common/hooks/useSelectedWallet' +import {useReviewTx} from '../ReviewTxProvider' import {useStrings} from './useStrings' // TODO: make it compatible with CBOR signing @@ -28,6 +29,16 @@ export const useOnConfirm = ({ const {meta} = useSelectedWallet() const {openModal, closeModal} = useModal() const strings = useStrings() + const {reset} = useReviewTx() + + const handleOnSuccess = (signedTx: YoroiSignedTx) => { + onSuccess?.(signedTx) + reset() + } + const handleOnError = () => { + onError?.() + reset() + } const onConfirm = () => { if (meta.isHW) { @@ -36,7 +47,7 @@ export const useOnConfirm = ({ onSuccess?.(signedTx)} + onSuccess={handleOnSuccess} onNotSupportedCIP1694={() => { if (onNotSupportedCIP1694) { closeModal() @@ -55,8 +66,8 @@ export const useOnConfirm = ({ strings.signTransaction, onSuccess?.(signedTx)} - onError={onError ?? undefined} + onSuccess={handleOnSuccess} + onError={handleOnError} />, ) return @@ -65,11 +76,7 @@ export const useOnConfirm = ({ if (!meta.isHW && meta.isEasyConfirmationEnabled) { openModal( strings.signTransaction, - onSuccess?.(signedTx)} - onError={onError ?? undefined} - />, + , ) return } diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useStrings.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useStrings.tsx index 3858509fab..59311a3c62 100644 --- a/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useStrings.tsx +++ b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useStrings.tsx @@ -40,6 +40,9 @@ export const useStrings = () => { selectNoConfidence: intl.formatMessage(messages.selectNoConfidence), delegateVotingToDRep: intl.formatMessage(messages.delegateVotingToDRep), delegateStake: intl.formatMessage(messages.delegateStake), + deregisterStakingKey: intl.formatMessage(messages.deregisterStakingKey), + rewardsWithdrawalLabel: intl.formatMessage(messages.rewardsWithdrawalLabel), + rewardsWithdrawalText: intl.formatMessage(messages.rewardsWithdrawalText), } } @@ -160,6 +163,18 @@ const messages = defineMessages({ id: 'txReview.operations.registerStakingKey', defaultMessage: '!!!Register staking key deposit', }, + deregisterStakingKey: { + id: 'txReview.operations.deregisterStakingKey', + defaultMessage: '!!!Deregister staking key', + }, + rewardsWithdrawalLabel: { + id: 'txReview.operations.rewardsWithdrawal.label', + defaultMessage: '!!!Staking', + }, + rewardsWithdrawalText: { + id: 'txReview.operations.rewardsWithdrawal.text', + defaultMessage: '!!!Rewards withdrawal', + }, selectAbstain: { id: 'txReview.operations.selectAbstain', defaultMessage: '!!!Select abstain', diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/operations.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/operations.tsx index 37e67650b2..3c37ed4762 100644 --- a/apps/wallet-mobile/src/features/ReviewTx/common/operations.tsx +++ b/apps/wallet-mobile/src/features/ReviewTx/common/operations.tsx @@ -1,5 +1,3 @@ -import {PoolInfoApi} from '@emurgo/yoroi-lib' -import {useBech32DRepID} from '@yoroi/staking' import {useTheme} from '@yoroi/theme' import * as React from 'react' import {Linking, StyleSheet, Text, View} from 'react-native' @@ -7,14 +5,16 @@ import {TouchableOpacity} from 'react-native-gesture-handler' import {useQuery} from 'react-query' import {Space} from '../../../components/Space/Space' -import {getPoolBech32Id} from '../../../yoroi-wallets/cardano/delegationUtils' +import {wrappedCsl} from '../../../yoroi-wallets/cardano/wrappedCsl' +import {usePoolInfo} from '../../../yoroi-wallets/hooks' import {formatTokenWithText} from '../../../yoroi-wallets/utils/format' import {asQuantity} from '../../../yoroi-wallets/utils/utils' import {useSelectedNetwork} from '../../WalletManager/common/hooks/useSelectedNetwork' import {useSelectedWallet} from '../../WalletManager/common/hooks/useSelectedWallet' import {useStrings} from './hooks/useStrings' +import {CertificateType, FormattedTx} from './types' -export const RegisterStakingKeyOperation = () => { +export const StakeRegistrationOperation = () => { const {styles} = useStyles() const strings = useStrings() const {wallet} = useSelectedWallet() @@ -31,7 +31,32 @@ export const RegisterStakingKeyOperation = () => { ) } -export const DelegateStakeOperation = ({poolId}: {poolId: string}) => { + +export const StakeDeregistrationOperation = () => { + const {styles} = useStyles() + const strings = useStrings() + + return ( + + {strings.deregisterStakingKey} + + ) +} + +export const StakeRewardsWithdrawalOperation = () => { + const {styles} = useStyles() + const strings = useStrings() + + return ( + + {strings.rewardsWithdrawalLabel} + + {strings.rewardsWithdrawalText} + + ) +} + +export const StakeDelegateOperation = ({poolId}: {poolId: string}) => { const {styles} = useStyles() const strings = useStrings() const poolInfo = usePoolInfo({poolId}) @@ -55,23 +80,6 @@ export const DelegateStakeOperation = ({poolId}: {poolId: string}) => { ) } -export const usePoolInfo = ({poolId}: {poolId: string}) => { - const {networkManager} = useSelectedNetwork() - const poolInfoApi = React.useMemo( - () => new PoolInfoApi(networkManager.legacyApiBaseUrl), - [networkManager.legacyApiBaseUrl], - ) - const poolInfo = useQuery({ - queryKey: ['usePoolInfoStakeOperation', poolId], - queryFn: async () => { - const poolBech32Id = await getPoolBech32Id(poolId) - return poolInfoApi.getSingleExplorerPoolInfo(poolBech32Id) - }, - }) - - return poolInfo?.data ?? null -} - export const AbstainOperation = () => { const {styles} = useStyles() const strings = useStrings() @@ -94,11 +102,11 @@ export const NoConfidenceOperation = () => { ) } -export const DelegateVotingToDrepOperation = ({drepID}: {drepID: string}) => { +export const VoteDelegationOperation = ({drepID}: {drepID: string}) => { const {styles} = useStyles() const strings = useStrings() - const {data: bech32DrepId} = useBech32DRepID(drepID) + const bech32DrepId = useDrepBech32Id(drepID) return ( @@ -111,6 +119,58 @@ export const DelegateVotingToDrepOperation = ({drepID}: {drepID: string}) => { ) } +export const useOperations = (certificates: FormattedTx['certificates']) => { + if (certificates === null) return [] + + return certificates.reduce((acc, certificate, index) => { + switch (certificate.type) { + case CertificateType.StakeRegistration: + return [...acc, ] + + case CertificateType.StakeDeregistration: + return [...acc, ] + + case CertificateType.StakeDelegation: { + const poolKeyHash = certificate.value.pool_keyhash ?? null + if (poolKeyHash == null) return acc + return [...acc, ] + } + + case CertificateType.VoteDelegation: { + const drep = certificate.value.drep + + if (drep === 'AlwaysAbstain') return [...acc, ] + if (drep === 'AlwaysNoConfidence') return [...acc, ] + + const drepId = ('KeyHash' in drep ? drep.KeyHash : drep.ScriptHash) ?? '' + return [...acc, ] + } + + default: + return acc + } + }, []) +} + +export const getDrepBech32Id = async (poolId: string) => { + const {csl, release} = wrappedCsl() + try { + const keyHash = await csl.Ed25519KeyHash.fromHex(poolId) + return keyHash.toBech32('drep') + } finally { + release() + } +} + +export const useDrepBech32Id = (poolId: string) => { + const query = useQuery({ + queryKey: ['drepBech32', poolId], + queryFn: () => getDrepBech32Id(poolId), + }) + + return query?.data ?? null +} + const useStyles = () => { const {color, atoms} = useTheme() diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/types.ts b/apps/wallet-mobile/src/features/ReviewTx/common/types.ts index f06f1d2530..fa11a20cf3 100644 --- a/apps/wallet-mobile/src/features/ReviewTx/common/types.ts +++ b/apps/wallet-mobile/src/features/ReviewTx/common/types.ts @@ -1,5 +1,5 @@ import { - CertificatesJSON, + CertificateJSON, TransactionBodyJSON, TransactionInputsJSON, TransactionOutputsJSON, @@ -57,7 +57,7 @@ export type FormattedTx = { inputs: FormattedInputs outputs: FormattedOutputs fee: FormattedFee - certificates: Certificates + certificates: FormattedCertificate[] | null } export type FormattedMetadata = { @@ -65,4 +65,41 @@ export type FormattedMetadata = { metadata: {msg: Array} | null } -export type Certificates = CertificatesJSON | null +type AssertEqual = T extends Expected + ? Expected extends T + ? true + : ['Type', Expected, 'is not equal to', T] + : ['Type', T, 'is not equal to', Expected] + +type UnionToIntersection = (U extends unknown ? (x: U) => void : never) extends (x: infer I) => void ? I : never + +type Transformed = { + [K in keyof UnionToIntersection]: {type: K; value: UnionToIntersection[K]} +}[keyof UnionToIntersection] + +export type FormattedCertificate = Transformed + +export const CertificateType = { + StakeRegistration: 'StakeRegistration', + StakeDeregistration: 'StakeDeregistration', + StakeDelegation: 'StakeDelegation', + PoolRegistration: 'PoolRegistration', + PoolRetirement: 'PoolRetirement', + GenesisKeyDelegation: 'GenesisKeyDelegation', + MoveInstantaneousRewardsCert: 'MoveInstantaneousRewardsCert', + CommitteeHotAuth: 'CommitteeHotAuth', + CommitteeColdResign: 'CommitteeColdResign', + DRepDeregistration: 'DRepDeregistration', + DRepRegistration: 'DRepRegistration', + DRepUpdate: 'DRepUpdate', + StakeAndVoteDelegation: 'StakeAndVoteDelegation', + StakeRegistrationAndDelegation: 'StakeRegistrationAndDelegation', + StakeVoteRegistrationAndDelegation: 'StakeVoteRegistrationAndDelegation', + VoteDelegation: 'VoteDelegation', + VoteRegistrationAndDelegation: 'VoteRegistrationAndDelegation', +} as const + +export type CerificateType = (typeof CertificateType)[keyof typeof CertificateType] + +// Makes sure CertificateType lists all the certificates in CertificateJSON +export type AssertAllImplementedCertTypes = AssertEqual> diff --git a/apps/wallet-mobile/src/features/ReviewTx/useCases/ReviewTxScreen/Overview/OverviewTab.tsx b/apps/wallet-mobile/src/features/ReviewTx/useCases/ReviewTxScreen/Overview/OverviewTab.tsx index cd04aeeb68..640a2783eb 100644 --- a/apps/wallet-mobile/src/features/ReviewTx/useCases/ReviewTxScreen/Overview/OverviewTab.tsx +++ b/apps/wallet-mobile/src/features/ReviewTx/useCases/ReviewTxScreen/Overview/OverviewTab.tsx @@ -17,17 +17,18 @@ import {useWalletManager} from '../../../../WalletManager/context/WalletManagerP import {Accordion} from '../../../common/Accordion' import {CopiableText} from '../../../common/CopiableText' import {useStrings} from '../../../common/hooks/useStrings' +import {useOperations} from '../../../common/operations' import {ReviewTxState, useReviewTx} from '../../../common/ReviewTxProvider' import {TokenItem} from '../../../common/TokenItem' import {FormattedOutputs, FormattedTx} from '../../../common/types' export const OverviewTab = ({ tx, - operations, + extraOperations, details, }: { tx: FormattedTx - operations: ReviewTxState['operations'] + extraOperations: ReviewTxState['operations'] details: ReviewTxState['details'] }) => { const {styles} = useStyles() @@ -45,7 +46,7 @@ export const OverviewTab = ({ - +
@@ -212,8 +213,10 @@ const ReceiverSection = ({notOwnedOutputs}: {notOwnedOutputs: FormattedOutputs}) ) } -const OperationsSection = ({operations}: {operations: ReviewTxState['operations']}) => { - if (operations === null || (Array.isArray(operations) && operations.length === 0)) return null +const OperationsSection = ({tx, extraOperations}: {tx: FormattedTx; extraOperations: ReviewTxState['operations']}) => { + const operations = useOperations(tx.certificates) + + if (extraOperations === null && tx.certificates === null) return null return ( @@ -222,7 +225,7 @@ const OperationsSection = ({operations}: {operations: ReviewTxState['operations' - {operations.map((operation, index) => { + {[...operations, ...(extraOperations ?? [])].map((operation, index) => { if (index === 0) return operation return ( diff --git a/apps/wallet-mobile/src/features/ReviewTx/useCases/ReviewTxScreen/ReviewTxScreen.tsx b/apps/wallet-mobile/src/features/ReviewTx/useCases/ReviewTxScreen/ReviewTxScreen.tsx index 48f54294af..1b3344465d 100644 --- a/apps/wallet-mobile/src/features/ReviewTx/useCases/ReviewTxScreen/ReviewTxScreen.tsx +++ b/apps/wallet-mobile/src/features/ReviewTx/useCases/ReviewTxScreen/ReviewTxScreen.tsx @@ -64,7 +64,7 @@ export const ReviewTxScreen = () => { {() => ( /* TODO: make scrollview general to use button border */ - + )} diff --git a/apps/wallet-mobile/src/features/Staking/Governance/common/helpers.tsx b/apps/wallet-mobile/src/features/Staking/Governance/common/helpers.tsx index b8ec96328e..500b7e42c5 100644 --- a/apps/wallet-mobile/src/features/Staking/Governance/common/helpers.tsx +++ b/apps/wallet-mobile/src/features/Staking/Governance/common/helpers.tsx @@ -3,8 +3,6 @@ import { type StakingKeyState, governanceApiMaker, governanceManagerMaker, - GovernanceProvider, - useGovernance, useStakingKeyState, useUpdateLatestGovernanceAction, } from '@yoroi/staking' @@ -16,12 +14,6 @@ import {YoroiWallet} from '../../../../yoroi-wallets/cardano/types' import {useStakingKey} from '../../../../yoroi-wallets/hooks' import {YoroiUnsignedTx} from '../../../../yoroi-wallets/types/yoroi' import {CardanoMobile} from '../../../../yoroi-wallets/wallets' -import { - AbstainOperation, - DelegateVotingToDrepOperation, - NoConfidenceOperation, - RegisterStakingKeyOperation, -} from '../../../ReviewTx/common/operations' import {useReviewTx} from '../../../ReviewTx/common/ReviewTxProvider' import {useBestBlock} from '../../../WalletManager/common/hooks/useBestBlock' import {useSelectedWallet} from '../../../WalletManager/common/hooks/useSelectedWallet' @@ -78,34 +70,21 @@ export const useGovernanceManagerMaker = () => { } export const useGovernanceActions = () => { - const {manager} = useGovernance() const {wallet} = useSelectedWallet() const navigateTo = useNavigateTo() - const {unsignedTxChanged, onSuccessChanged, onErrorChanged, operationsChanged, onNotSupportedCIP1694Changed} = - useReviewTx() + const {unsignedTxChanged, onSuccessChanged, onErrorChanged, onNotSupportedCIP1694Changed} = useReviewTx() const {updateLatestGovernanceAction} = useUpdateLatestGovernanceAction(wallet.id) const {navigateToTxReview} = useWalletNavigation() const handleDelegateAction = ({ drepID, unsignedTx, - hasStakeCert = false, navigateToStakingOnSuccess = false, }: { drepID: string unsignedTx: YoroiUnsignedTx - hasStakeCert?: boolean navigateToStakingOnSuccess?: boolean }) => { - let operations = [ - - - , - ] - - if (hasStakeCert) operations = [, ...operations] - - operationsChanged(operations) onSuccessChanged((signedTx) => { updateLatestGovernanceAction({kind: 'delegate-to-drep', drepID, txID: signedTx.signedTx.id}) navigateTo.txSuccess({navigateToStaking: navigateToStakingOnSuccess ?? false, kind: 'delegate'}) @@ -121,17 +100,11 @@ export const useGovernanceActions = () => { const handleAbstainAction = ({ unsignedTx, - hasStakeCert = false, navigateToStakingOnSuccess = false, }: { unsignedTx: YoroiUnsignedTx - hasStakeCert?: boolean navigateToStakingOnSuccess?: boolean }) => { - let operations = [] - if (hasStakeCert) operations = [, ...operations] - - operationsChanged(operations) onSuccessChanged((signedTx) => { updateLatestGovernanceAction({kind: 'vote', vote: 'abstain', txID: signedTx.signedTx.id}) navigateTo.txSuccess({navigateToStaking: navigateToStakingOnSuccess ?? false, kind: 'abstain'}) @@ -147,17 +120,11 @@ export const useGovernanceActions = () => { const handleNoConfidenceAction = ({ unsignedTx, - hasStakeCert = false, navigateToStakingOnSuccess = false, }: { unsignedTx: YoroiUnsignedTx - hasStakeCert?: boolean navigateToStakingOnSuccess?: boolean }) => { - let operations = [] - if (hasStakeCert) operations = [, ...operations] - - operationsChanged(operations) onSuccessChanged((signedTx) => { updateLatestGovernanceAction({kind: 'vote', vote: 'no-confidence', txID: signedTx.signedTx.id}) navigateTo.txSuccess({ diff --git a/apps/wallet-mobile/src/features/Staking/Governance/useCases/Home/HomeScreen.tsx b/apps/wallet-mobile/src/features/Staking/Governance/useCases/Home/HomeScreen.tsx index 9f43088d3e..19f03a4fcf 100644 --- a/apps/wallet-mobile/src/features/Staking/Governance/useCases/Home/HomeScreen.tsx +++ b/apps/wallet-mobile/src/features/Staking/Governance/useCases/Home/HomeScreen.tsx @@ -255,7 +255,6 @@ const NeverParticipatedInGovernanceVariant = () => { unsignedTx, drepID, navigateToStakingOnSuccess: params?.navigateToStakingOnSuccess, - hasStakeCert: stakeCert !== null, }) }, }, @@ -281,7 +280,6 @@ const NeverParticipatedInGovernanceVariant = () => { governanceActions.handleAbstainAction({ unsignedTx, navigateToStakingOnSuccess: params?.navigateToStakingOnSuccess, - hasStakeCert: stakeCert !== null, }) }, }, @@ -306,7 +304,6 @@ const NeverParticipatedInGovernanceVariant = () => { governanceActions.handleNoConfidenceAction({ unsignedTx, navigateToStakingOnSuccess: params?.navigateToStakingOnSuccess, - hasStakeCert: stakeCert !== null, }) }, }, diff --git a/apps/wallet-mobile/src/kernel/i18n/locales/en-US.json b/apps/wallet-mobile/src/kernel/i18n/locales/en-US.json index caeffe72a9..56f0b8cc98 100644 --- a/apps/wallet-mobile/src/kernel/i18n/locales/en-US.json +++ b/apps/wallet-mobile/src/kernel/i18n/locales/en-US.json @@ -1248,6 +1248,9 @@ "txReview.tokenDetails.overViewTab.details.label": "Details on", "txReview.tokenDetails.title": "Asset Details", "txReview.operations.registerStakingKey": "Register staking key deposit", + "txReview.operations.deregisterStakingKey": "Deregister staking key", + "txReview.operations.rewardsWithdrawal.label": "Staking", + "txReview.operations.rewardsWithdrawal.text": "Rewards withdrawal", "txReview.operations.selectAbstain": "Select abstain", "txReview.operations.selectNoConfidence": "Select no confidence", "txReview.operations.delegateVotingToDRep": "Delegate voting to", diff --git a/apps/wallet-mobile/src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.tsx b/apps/wallet-mobile/src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.tsx index c85860ddd7..18e000913d 100644 --- a/apps/wallet-mobile/src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.tsx +++ b/apps/wallet-mobile/src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.tsx @@ -11,42 +11,32 @@ import {PleaseWaitView} from '../../../components/PleaseWaitModal' import {ScrollView, useScrollView} from '../../../components/ScrollView/ScrollView' import {Space} from '../../../components/Space/Space' import {Warning} from '../../../components/Warning/Warning' +import {StakeRewardsWithdrawalOperation} from '../../../features/ReviewTx/common/operations' +import {useReviewTx} from '../../../features/ReviewTx/common/ReviewTxProvider' import {useSelectedWallet} from '../../../features/WalletManager/common/hooks/useSelectedWallet' import globalMessages, {confirmationMessages, ledgerMessages, txLabels} from '../../../kernel/i18n/global-messages' import {useWalletNavigation} from '../../../kernel/navigation' import {YoroiWallet} from '../../../yoroi-wallets/cardano/types' import {useWithdrawalTx} from '../../../yoroi-wallets/hooks' import {YoroiUnsignedTx} from '../../../yoroi-wallets/types/yoroi' -import {delay} from '../../../yoroi-wallets/utils/timeUtils' import {Quantities} from '../../../yoroi-wallets/utils/utils' import {useStakingInfo} from '../StakePoolInfos' -import {ConfirmTx} from './ConfirmTx/ConfirmTx' type Props = { wallet: YoroiWallet } export const WithdrawStakingRewards = ({wallet}: Props) => { const strings = useWithdrawStakingRewardsStrings() - const {closeModal, openModal} = useModal() - const {resetToTxHistory} = useWalletNavigation() + const {closeModal} = useModal() + const {navigateToTxReview} = useWalletNavigation() + const {unsignedTxChanged, operationsChanged} = useReviewTx() - const handleOnConfirm = async (withdrawalTx: YoroiUnsignedTx) => { + const handleOnConfirm = (withdrawalTx: YoroiUnsignedTx) => { closeModal() - await delay(1000) - - openModal( - strings.confirmTx, - - resetToTxHistory()} - onCancel={() => closeModal()} - /> - , - 450, - ) + unsignedTxChanged(withdrawalTx) + operationsChanged([]) + navigateToTxReview() } return ( diff --git a/apps/wallet-mobile/src/legacy/Staking/PoolTransition/usePoolTransition.tsx b/apps/wallet-mobile/src/legacy/Staking/PoolTransition/usePoolTransition.tsx index d6bd535031..e4dd55e330 100644 --- a/apps/wallet-mobile/src/legacy/Staking/PoolTransition/usePoolTransition.tsx +++ b/apps/wallet-mobile/src/legacy/Staking/PoolTransition/usePoolTransition.tsx @@ -7,7 +7,6 @@ import * as React from 'react' import {defineMessages, useIntl} from 'react-intl' import {useQuery} from 'react-query' -import {DelegateStakeOperation} from '../../../features/ReviewTx/common/operations' import {useReviewTx} from '../../../features/ReviewTx/common/ReviewTxProvider' import {useSelectedNetwork} from '../../../features/WalletManager/common/hooks/useSelectedNetwork' import {useSelectedWallet} from '../../../features/WalletManager/common/hooks/useSelectedWallet' @@ -41,7 +40,7 @@ export const usePoolTransition = () => { const {wallet, meta} = useSelectedWallet() const {networkManager} = useSelectedNetwork() const {navigateToTxReview, resetToTxHistory} = useWalletNavigation() - const {unsignedTxChanged, onSuccessChanged, onErrorChanged, operationsChanged} = useReviewTx() + const {unsignedTxChanged, onSuccessChanged, onErrorChanged} = useReviewTx() const {stakingInfo, isLoading} = useStakingInfo(wallet) const poolInfoApi = React.useMemo( () => new PoolInfoApi(networkManager.legacyApiBaseUrl), @@ -65,7 +64,6 @@ export const usePoolTransition = () => { const navigateToUpdate = React.useCallback(async () => { try { const yoroiUnsignedTx = await createDelegationTx(wallet, poolId, meta) - operationsChanged([]) unsignedTxChanged(yoroiUnsignedTx) onSuccessChanged(() => { resetToTxHistory() @@ -85,7 +83,6 @@ export const usePoolTransition = () => { wallet, poolId, meta, - operationsChanged, unsignedTxChanged, onSuccessChanged, onErrorChanged, diff --git a/apps/wallet-mobile/src/legacy/Staking/StakingCenter/StakingCenter.tsx b/apps/wallet-mobile/src/legacy/Staking/StakingCenter/StakingCenter.tsx index 425290317f..2986550471 100644 --- a/apps/wallet-mobile/src/legacy/Staking/StakingCenter/StakingCenter.tsx +++ b/apps/wallet-mobile/src/legacy/Staking/StakingCenter/StakingCenter.tsx @@ -9,7 +9,6 @@ import {useQueryClient} from 'react-query' import {PleaseWaitModal} from '../../../components/PleaseWaitModal' import {Spacer} from '../../../components/Spacer/Spacer' -import {DelegateStakeOperation, RegisterStakingKeyOperation} from '../../../features/ReviewTx/common/operations' import {useReviewTx} from '../../../features/ReviewTx/common/ReviewTxProvider' import {useSelectedWallet} from '../../../features/WalletManager/common/hooks/useSelectedWallet' import {useWalletManager} from '../../../features/WalletManager/context/WalletManagerProvider' @@ -20,7 +19,7 @@ import {logger} from '../../../kernel/logger/logger' import {useMetrics} from '../../../kernel/metrics/metricsManager' import {StakingCenterRouteNavigation, useWalletNavigation} from '../../../kernel/navigation' import {NotEnoughMoneyToSendError} from '../../../yoroi-wallets/cardano/types' -import {useStakingInfo, useStakingTx} from '../../Dashboard/StakePoolInfos' +import {useStakingTx} from '../../Dashboard/StakePoolInfos' import {PoolDetailScreen} from '../PoolDetails' export const StakingCenter = () => { @@ -36,9 +35,7 @@ export const StakingCenter = () => { const {track} = useMetrics() const {plate} = walletManager.checksum(wallet.publicKeyHex) const {navigateToTxReview, resetToTxHistory} = useWalletNavigation() - const {unsignedTxChanged, onSuccessChanged, onErrorChanged, operationsChanged} = useReviewTx() - const stakingInfo = useStakingInfo(wallet, {suspense: true}) - const hasStakingKeyRegistered = stakingInfo?.data?.status !== 'not-registered' + const {unsignedTxChanged, onSuccessChanged, onErrorChanged} = useReviewTx() useFocusEffect( React.useCallback(() => { @@ -57,10 +54,6 @@ export const StakingCenter = () => { onSuccess: (yoroiUnsignedTx) => { if (selectedPoolId == null) return - let operations = [] - if (!hasStakingKeyRegistered) operations = [, ...operations] - - operationsChanged(operations) unsignedTxChanged(yoroiUnsignedTx) onSuccessChanged(() => { queryClient.resetQueries([wallet.id, 'stakingInfo']) diff --git a/apps/wallet-mobile/src/yoroi-wallets/hooks/index.ts b/apps/wallet-mobile/src/yoroi-wallets/hooks/index.ts index 29dbd086a9..ce610714ec 100644 --- a/apps/wallet-mobile/src/yoroi-wallets/hooks/index.ts +++ b/apps/wallet-mobile/src/yoroi-wallets/hooks/index.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import {walletChecksum} from '@emurgo/cip4-js' import {Certificate} from '@emurgo/cross-csl-core' +import {PoolInfoApi} from '@emurgo/yoroi-lib' import AsyncStorage, {AsyncStorageStatic} from '@react-native-async-storage/async-storage' import {mountMMKVStorage, observableStorageMaker, parseBoolean, useMutationWithInvalidations} from '@yoroi/common' import {themeStorageMaker} from '@yoroi/theme' @@ -25,6 +26,7 @@ import {isDev, isNightly} from '../../kernel/env' import {logger} from '../../kernel/logger/logger' import {deriveAddressFromXPub} from '../cardano/account-manager/derive-address-from-xpub' import {getSpendingKey, getStakingKey} from '../cardano/addressInfo/addressInfo' +import {getPoolBech32Id} from '../cardano/delegationUtils' import {WalletEvent, YoroiWallet} from '../cardano/types' import {TRANSACTION_DIRECTION, TRANSACTION_STATUS, TxSubmissionStatus} from '../types/other' import {YoroiSignedTx, YoroiUnsignedTx} from '../types/yoroi' @@ -702,3 +704,20 @@ export const useThemeStorageMaker = () => { return themeStorage } + +export const usePoolInfo = ({poolId}: {poolId: string}) => { + const {networkManager} = useSelectedNetwork() + const poolInfoApi = React.useMemo( + () => new PoolInfoApi(networkManager.legacyApiBaseUrl), + [networkManager.legacyApiBaseUrl], + ) + const poolInfo = useQuery({ + queryKey: ['usePoolInfoStakeOperation', poolId], + queryFn: async () => { + const poolBech32Id = await getPoolBech32Id(poolId) + return poolInfoApi.getSingleExplorerPoolInfo(poolBech32Id) + }, + }) + + return poolInfo?.data ?? null +} diff --git a/apps/wallet-mobile/translations/messages/src/features/ReviewTx/common/hooks/useStrings.json b/apps/wallet-mobile/translations/messages/src/features/ReviewTx/common/hooks/useStrings.json index 9e1a48b052..63f5ba1400 100644 --- a/apps/wallet-mobile/translations/messages/src/features/ReviewTx/common/hooks/useStrings.json +++ b/apps/wallet-mobile/translations/messages/src/features/ReviewTx/common/hooks/useStrings.json @@ -4,14 +4,14 @@ "defaultMessage": "!!!Confirm", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 47, + "line": 50, "column": 11, - "index": 2325 + "index": 2562 }, "end": { - "line": 50, + "line": 53, "column": 3, - "index": 2392 + "index": 2629 } }, { @@ -19,14 +19,14 @@ "defaultMessage": "!!!UTxOs", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 51, + "line": 54, "column": 9, - "index": 2403 + "index": 2640 }, "end": { - "line": 54, + "line": 57, "column": 3, - "index": 2466 + "index": 2703 } }, { @@ -34,14 +34,14 @@ "defaultMessage": "!!!UTxOs", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 55, + "line": 58, "column": 12, - "index": 2480 + "index": 2717 }, "end": { - "line": 58, + "line": 61, "column": 3, - "index": 2552 + "index": 2789 } }, { @@ -49,14 +49,14 @@ "defaultMessage": "!!!Overview", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 59, + "line": 62, "column": 15, - "index": 2569 + "index": 2806 }, "end": { - "line": 62, + "line": 65, "column": 3, - "index": 2647 + "index": 2884 } }, { @@ -64,14 +64,14 @@ "defaultMessage": "!!!Metadata", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 63, + "line": 66, "column": 15, - "index": 2664 + "index": 2901 }, "end": { - "line": 66, + "line": 69, "column": 3, - "index": 2745 + "index": 2982 } }, { @@ -79,14 +79,14 @@ "defaultMessage": "!!!Metadata hash", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 67, + "line": 70, "column": 16, - "index": 2763 + "index": 3000 }, "end": { - "line": 70, + "line": 73, "column": 3, - "index": 2850 + "index": 3087 } }, { @@ -94,14 +94,14 @@ "defaultMessage": "!!!Metadata", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 71, + "line": 74, "column": 21, - "index": 2873 + "index": 3110 }, "end": { - "line": 74, + "line": 77, "column": 3, - "index": 2960 + "index": 3197 } }, { @@ -109,14 +109,14 @@ "defaultMessage": "!!!Wallet", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 75, + "line": 78, "column": 15, - "index": 2977 + "index": 3214 }, "end": { - "line": 78, + "line": 81, "column": 3, - "index": 3051 + "index": 3288 } }, { @@ -124,14 +124,14 @@ "defaultMessage": "!!!Fee", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 79, + "line": 82, "column": 12, - "index": 3065 + "index": 3302 }, "end": { - "line": 82, + "line": 85, "column": 3, - "index": 3124 + "index": 3361 } }, { @@ -139,14 +139,14 @@ "defaultMessage": "!!!Your Wallet", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 83, + "line": 86, "column": 17, - "index": 3143 + "index": 3380 }, "end": { - "line": 86, + "line": 89, "column": 3, - "index": 3229 + "index": 3466 } }, { @@ -154,14 +154,14 @@ "defaultMessage": "!!!Send", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 87, + "line": 90, "column": 13, - "index": 3244 + "index": 3481 }, "end": { - "line": 90, + "line": 93, "column": 3, - "index": 3319 + "index": 3556 } }, { @@ -169,14 +169,14 @@ "defaultMessage": "!!!To", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 91, + "line": 94, "column": 18, - "index": 3339 + "index": 3576 }, "end": { - "line": 94, + "line": 97, "column": 3, - "index": 3417 + "index": 3654 } }, { @@ -184,14 +184,14 @@ "defaultMessage": "!!!To script", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 95, + "line": 98, "column": 24, - "index": 3443 + "index": 3680 }, "end": { - "line": 98, + "line": 101, "column": 3, - "index": 3534 + "index": 3771 } }, { @@ -199,14 +199,14 @@ "defaultMessage": "!!!Inputs", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 99, + "line": 102, "column": 20, - "index": 3556 + "index": 3793 }, "end": { - "line": 102, + "line": 105, "column": 3, - "index": 3637 + "index": 3874 } }, { @@ -214,14 +214,14 @@ "defaultMessage": "!!!Outputs", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 103, + "line": 106, "column": 21, - "index": 3660 + "index": 3897 }, "end": { - "line": 106, + "line": 109, "column": 3, - "index": 3743 + "index": 3980 } }, { @@ -229,14 +229,14 @@ "defaultMessage": "!!!Your address", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 107, + "line": 110, "column": 25, - "index": 3770 + "index": 4007 }, "end": { - "line": 110, + "line": 113, "column": 3, - "index": 3862 + "index": 4099 } }, { @@ -244,14 +244,14 @@ "defaultMessage": "!!!Foreign address", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 111, + "line": 114, "column": 28, - "index": 3892 + "index": 4129 }, "end": { - "line": 114, + "line": 117, "column": 3, - "index": 3990 + "index": 4227 } }, { @@ -259,14 +259,14 @@ "defaultMessage": "!!!Overview", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 115, + "line": 118, "column": 12, - "index": 4004 + "index": 4241 }, "end": { - "line": 118, + "line": 121, "column": 3, - "index": 4095 + "index": 4332 } }, { @@ -274,14 +274,14 @@ "defaultMessage": "!!!JSON", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 119, + "line": 122, "column": 8, - "index": 4105 + "index": 4342 }, "end": { - "line": 122, + "line": 125, "column": 3, - "index": 4188 + "index": 4425 } }, { @@ -289,14 +289,14 @@ "defaultMessage": "!!!Metadata", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 123, + "line": 126, "column": 12, - "index": 4202 + "index": 4439 }, "end": { - "line": 126, + "line": 129, "column": 3, - "index": 4292 + "index": 4529 } }, { @@ -304,14 +304,14 @@ "defaultMessage": "!!!Policy ID", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 127, + "line": 130, "column": 12, - "index": 4306 + "index": 4543 }, "end": { - "line": 130, + "line": 133, "column": 3, - "index": 4395 + "index": 4632 } }, { @@ -319,14 +319,14 @@ "defaultMessage": "!!!Fingerprint", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 131, + "line": 134, "column": 15, - "index": 4412 + "index": 4649 }, "end": { - "line": 134, + "line": 137, "column": 3, - "index": 4506 + "index": 4743 } }, { @@ -334,14 +334,14 @@ "defaultMessage": "!!!Name", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 135, + "line": 138, "column": 8, - "index": 4516 + "index": 4753 }, "end": { - "line": 138, + "line": 141, "column": 3, - "index": 4608 + "index": 4845 } }, { @@ -349,14 +349,14 @@ "defaultMessage": "!!!Token Supply", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 139, + "line": 142, "column": 15, - "index": 4625 + "index": 4862 }, "end": { - "line": 142, + "line": 145, "column": 3, - "index": 4732 + "index": 4969 } }, { @@ -364,14 +364,14 @@ "defaultMessage": "!!!Symbol", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 143, + "line": 146, "column": 10, - "index": 4744 + "index": 4981 }, "end": { - "line": 146, + "line": 149, "column": 3, - "index": 4840 + "index": 5077 } }, { @@ -379,14 +379,14 @@ "defaultMessage": "!!!Description", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 147, + "line": 150, "column": 15, - "index": 4857 + "index": 5094 }, "end": { - "line": 150, + "line": 153, "column": 3, - "index": 4963 + "index": 5200 } }, { @@ -394,14 +394,14 @@ "defaultMessage": "!!!Details on", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 151, + "line": 154, "column": 11, - "index": 4976 + "index": 5213 }, "end": { - "line": 154, + "line": 157, "column": 3, - "index": 5077 + "index": 5314 } }, { @@ -409,14 +409,14 @@ "defaultMessage": "!!!Asset Details", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 155, + "line": 158, "column": 21, - "index": 5100 + "index": 5337 }, "end": { - "line": 158, + "line": 161, "column": 3, - "index": 5184 + "index": 5421 } }, { @@ -424,14 +424,59 @@ "defaultMessage": "!!!Register staking key deposit", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 159, + "line": 162, "column": 22, - "index": 5208 + "index": 5445 }, "end": { - "line": 162, + "line": 165, + "column": 3, + "index": 5555 + } + }, + { + "id": "txReview.operations.deregisterStakingKey", + "defaultMessage": "!!!Deregister staking key", + "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", + "start": { + "line": 166, + "column": 24, + "index": 5581 + }, + "end": { + "line": 169, "column": 3, - "index": 5318 + "index": 5687 + } + }, + { + "id": "txReview.operations.rewardsWithdrawal.label", + "defaultMessage": "!!!Staking", + "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", + "start": { + "line": 170, + "column": 26, + "index": 5715 + }, + "end": { + "line": 173, + "column": 3, + "index": 5809 + } + }, + { + "id": "txReview.operations.rewardsWithdrawal.text", + "defaultMessage": "!!!Rewards withdrawal", + "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", + "start": { + "line": 174, + "column": 25, + "index": 5836 + }, + "end": { + "line": 177, + "column": 3, + "index": 5940 } }, { @@ -439,14 +484,14 @@ "defaultMessage": "!!!Select abstain", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 163, + "line": 178, "column": 17, - "index": 5337 + "index": 5959 }, "end": { - "line": 166, + "line": 181, "column": 3, - "index": 5428 + "index": 6050 } }, { @@ -454,14 +499,14 @@ "defaultMessage": "!!!Select no confidence", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 167, + "line": 182, "column": 22, - "index": 5452 + "index": 6074 }, "end": { - "line": 170, + "line": 185, "column": 3, - "index": 5554 + "index": 6176 } }, { @@ -469,14 +514,14 @@ "defaultMessage": "!!!Delegate voting to", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 171, + "line": 186, "column": 24, - "index": 5580 + "index": 6202 }, "end": { - "line": 174, + "line": 189, "column": 3, - "index": 5682 + "index": 6304 } }, { @@ -484,14 +529,14 @@ "defaultMessage": "!!!Stake entire wallet balance to", "file": "src/features/ReviewTx/common/hooks/useStrings.tsx", "start": { - "line": 175, + "line": 190, "column": 17, - "index": 5701 + "index": 6323 }, "end": { - "line": 178, + "line": 193, "column": 3, - "index": 5808 + "index": 6430 } } ] \ No newline at end of file diff --git a/apps/wallet-mobile/translations/messages/src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.json b/apps/wallet-mobile/translations/messages/src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.json index d0e1d39363..e34cf33ec5 100644 --- a/apps/wallet-mobile/translations/messages/src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.json +++ b/apps/wallet-mobile/translations/messages/src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.json @@ -4,14 +4,14 @@ "defaultMessage": "!!!Also deregister staking key?", "file": "src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.tsx", "start": { - "line": 166, + "line": 156, "column": 21, - "index": 6114 + "index": 6060 }, "end": { - "line": 169, + "line": 159, "column": 3, - "index": 6242 + "index": 6188 } }, { @@ -19,14 +19,14 @@ "defaultMessage": "!!!When withdrawing rewards, you also have the option to deregister the staking key.", "file": "src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.tsx", "start": { - "line": 170, + "line": 160, "column": 16, - "index": 6260 + "index": 6206 }, "end": { - "line": 173, + "line": 163, "column": 3, - "index": 6443 + "index": 6389 } }, { @@ -34,14 +34,14 @@ "defaultMessage": "!!!Keeping the staking key will allow you to withdraw the rewards, but continue delegating to the same pool.", "file": "src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.tsx", "start": { - "line": 174, + "line": 164, "column": 16, - "index": 6461 + "index": 6407 }, "end": { - "line": 179, + "line": 169, "column": 3, - "index": 6685 + "index": 6631 } }, { @@ -49,14 +49,14 @@ "defaultMessage": "!!!Deregistering the staking key will give you back your deposit and undelegate the key from any pool.", "file": "src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.tsx", "start": { - "line": 180, + "line": 170, "column": 16, - "index": 6703 + "index": 6649 }, "end": { - "line": 184, + "line": 174, "column": 3, - "index": 6910 + "index": 6856 } }, { @@ -64,14 +64,14 @@ "defaultMessage": "!!!You do NOT need to deregister to delegate to a different stake pool. You can change your delegation preference at any time.", "file": "src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.tsx", "start": { - "line": 185, + "line": 175, "column": 12, - "index": 6924 + "index": 6870 }, "end": { - "line": 190, + "line": 180, "column": 3, - "index": 7155 + "index": 7101 } }, { @@ -79,14 +79,14 @@ "defaultMessage": "!!!You should NOT deregister if this staking key is used as a stake pool's reward account, as this will cause all pool operator rewards to be sent back to the reserve.", "file": "src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.tsx", "start": { - "line": 191, + "line": 181, "column": 12, - "index": 7169 + "index": 7115 }, "end": { - "line": 197, + "line": 187, "column": 3, - "index": 7452 + "index": 7398 } }, { @@ -94,14 +94,14 @@ "defaultMessage": "!!!Deregistering means this key will no longer receive rewards until you re-register the staking key (usually by delegating to a pool again)", "file": "src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.tsx", "start": { - "line": 198, + "line": 188, "column": 12, - "index": 7466 + "index": 7412 }, "end": { - "line": 203, + "line": 193, "column": 3, - "index": 7711 + "index": 7657 } }, { @@ -109,14 +109,14 @@ "defaultMessage": "!!!Keep registered", "file": "src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.tsx", "start": { - "line": 204, + "line": 194, "column": 14, - "index": 7727 + "index": 7673 }, "end": { - "line": 207, + "line": 197, "column": 3, - "index": 7835 + "index": 7781 } }, { @@ -124,14 +124,14 @@ "defaultMessage": "!!!Deregister", "file": "src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.tsx", "start": { - "line": 208, + "line": 198, "column": 20, - "index": 7857 + "index": 7803 }, "end": { - "line": 211, + "line": 201, "column": 3, - "index": 7966 + "index": 7912 } } ] \ No newline at end of file diff --git a/apps/wallet-mobile/translations/messages/src/legacy/Staking/PoolTransition/usePoolTransition.json b/apps/wallet-mobile/translations/messages/src/legacy/Staking/PoolTransition/usePoolTransition.json index 1ec5530969..0c7ddff6bc 100644 --- a/apps/wallet-mobile/translations/messages/src/legacy/Staking/PoolTransition/usePoolTransition.json +++ b/apps/wallet-mobile/translations/messages/src/legacy/Staking/PoolTransition/usePoolTransition.json @@ -4,14 +4,14 @@ "defaultMessage": "!!!Upgrade your stake pool", "file": "src/legacy/Staking/PoolTransition/usePoolTransition.tsx", "start": { - "line": 127, + "line": 124, "column": 9, - "index": 4719 + "index": 4515 }, "end": { - "line": 130, + "line": 127, "column": 3, - "index": 4817 + "index": 4613 } }, { @@ -19,14 +19,14 @@ "defaultMessage": "!!!The current stake pool you're using will soon close. Migrate to the new EMURGO pool to sustain reward generation.", "file": "src/legacy/Staking/PoolTransition/usePoolTransition.tsx", "start": { - "line": 131, + "line": 128, "column": 11, - "index": 4830 + "index": 4626 }, "end": { - "line": 135, + "line": 132, "column": 3, - "index": 5026 + "index": 4822 } }, { @@ -34,14 +34,14 @@ "defaultMessage": "!!!The current stake pool you're using is decommissioned and NOT generating reward anymore. Update it to continue earning", "file": "src/legacy/Staking/PoolTransition/usePoolTransition.tsx", "start": { - "line": 136, + "line": 133, "column": 16, - "index": 5044 + "index": 4840 }, "end": { - "line": 140, + "line": 137, "column": 3, - "index": 5250 + "index": 5046 } }, { @@ -49,14 +49,14 @@ "defaultMessage": "!!!Current pool", "file": "src/legacy/Staking/PoolTransition/usePoolTransition.tsx", "start": { - "line": 141, + "line": 138, "column": 15, - "index": 5267 + "index": 5063 }, "end": { - "line": 144, + "line": 141, "column": 3, - "index": 5360 + "index": 5156 } }, { @@ -64,14 +64,14 @@ "defaultMessage": "!!!New pool", "file": "src/legacy/Staking/PoolTransition/usePoolTransition.tsx", "start": { - "line": 145, + "line": 142, "column": 11, - "index": 5373 + "index": 5169 }, "end": { - "line": 148, + "line": 145, "column": 3, - "index": 5458 + "index": 5254 } }, { @@ -79,14 +79,14 @@ "defaultMessage": "!!!Estimated ROA", "file": "src/legacy/Staking/PoolTransition/usePoolTransition.tsx", "start": { - "line": 149, + "line": 146, "column": 16, - "index": 5476 + "index": 5272 }, "end": { - "line": 152, + "line": 149, "column": 3, - "index": 5571 + "index": 5367 } }, { @@ -94,14 +94,14 @@ "defaultMessage": "!!!Fee", "file": "src/legacy/Staking/PoolTransition/usePoolTransition.tsx", "start": { - "line": 153, + "line": 150, "column": 7, - "index": 5580 + "index": 5376 }, "end": { - "line": 156, + "line": 153, "column": 3, - "index": 5656 + "index": 5452 } }, { @@ -109,14 +109,14 @@ "defaultMessage": "!!!This pool continues to generate staking rewards", "file": "src/legacy/Staking/PoolTransition/usePoolTransition.tsx", "start": { - "line": 157, + "line": 154, "column": 24, - "index": 5682 + "index": 5478 }, "end": { - "line": 160, + "line": 157, "column": 3, - "index": 5819 + "index": 5615 } }, { @@ -124,14 +124,14 @@ "defaultMessage": "!!!This pool is NOT generating staking rewards anymore", "file": "src/legacy/Staking/PoolTransition/usePoolTransition.tsx", "start": { - "line": 161, + "line": 158, "column": 17, - "index": 5838 + "index": 5634 }, "end": { - "line": 164, + "line": 161, "column": 3, - "index": 5972 + "index": 5768 } }, { @@ -139,14 +139,14 @@ "defaultMessage": "!!!This pool will stop generating rewards in", "file": "src/legacy/Staking/PoolTransition/usePoolTransition.tsx", "start": { - "line": 165, + "line": 162, "column": 23, - "index": 5997 + "index": 5793 }, "end": { - "line": 168, + "line": 165, "column": 3, - "index": 6127 + "index": 5923 } }, { @@ -154,14 +154,14 @@ "defaultMessage": "!!!Skip and stop receiving rewards", "file": "src/legacy/Staking/PoolTransition/usePoolTransition.tsx", "start": { - "line": 169, + "line": 166, "column": 17, - "index": 6146 + "index": 5942 }, "end": { - "line": 172, + "line": 169, "column": 3, - "index": 6260 + "index": 6056 } }, { @@ -169,14 +169,14 @@ "defaultMessage": "!!!Update now and keep earning", "file": "src/legacy/Staking/PoolTransition/usePoolTransition.tsx", "start": { - "line": 173, + "line": 170, "column": 21, - "index": 6283 + "index": 6079 }, "end": { - "line": 176, + "line": 173, "column": 3, - "index": 6397 + "index": 6193 } }, { @@ -184,14 +184,14 @@ "defaultMessage": "!!!Update pool", "file": "src/legacy/Staking/PoolTransition/usePoolTransition.tsx", "start": { - "line": 177, + "line": 174, "column": 10, - "index": 6409 + "index": 6205 }, "end": { - "line": 180, + "line": 177, "column": 3, - "index": 6496 + "index": 6292 } } ] \ No newline at end of file diff --git a/apps/wallet-mobile/translations/messages/src/legacy/Staking/StakingCenter/StakingCenter.json b/apps/wallet-mobile/translations/messages/src/legacy/Staking/StakingCenter/StakingCenter.json index 4f55b6775b..24ceee0b8c 100644 --- a/apps/wallet-mobile/translations/messages/src/legacy/Staking/StakingCenter/StakingCenter.json +++ b/apps/wallet-mobile/translations/messages/src/legacy/Staking/StakingCenter/StakingCenter.json @@ -4,14 +4,14 @@ "defaultMessage": "!!!Invalid Pool Data", "file": "src/legacy/Staking/StakingCenter/StakingCenter.tsx", "start": { - "line": 146, + "line": 139, "column": 9, - "index": 5740 + "index": 5214 }, "end": { - "line": 149, + "line": 142, "column": 3, - "index": 5848 + "index": 5322 } }, { @@ -19,14 +19,14 @@ "defaultMessage": "!!!The data from the stake pool(s) you selected is invalid. Please try again", "file": "src/legacy/Staking/StakingCenter/StakingCenter.tsx", "start": { - "line": 150, + "line": 143, "column": 11, - "index": 5861 + "index": 5335 }, "end": { - "line": 153, + "line": 146, "column": 3, - "index": 6027 + "index": 5501 } } ] \ No newline at end of file From bc9338d7e0da738fef8e1278a384aeaa54e0d3ef Mon Sep 17 00:00:00 2001 From: Michal S Date: Fri, 25 Oct 2024 13:23:04 +0100 Subject: [PATCH 042/113] feat(wallet-mobile): Add PT price changed notification (#3711) --- .../Notifications/useCases/common/hooks.ts | 2 + .../useCases/common/notification-manager.ts | 2 + .../useCases/common/notifications.ts | 17 ++- ...rimary-token-price-changed-notification.ts | 115 ++++++++++++++++++ .../Notifications/useCases/common/storage.ts | 29 +++++ .../transaction-received-notification.ts | 51 +++----- .../Currency/CurrencyContext.tsx | 27 ++-- .../src/notification-manager.test.ts | 3 +- .../notifications/src/notification-manager.ts | 9 +- packages/types/src/notifications/manager.ts | 12 +- 10 files changed, 214 insertions(+), 53 deletions(-) create mode 100644 apps/wallet-mobile/src/features/Notifications/useCases/common/primary-token-price-changed-notification.ts create mode 100644 apps/wallet-mobile/src/features/Notifications/useCases/common/storage.ts diff --git a/apps/wallet-mobile/src/features/Notifications/useCases/common/hooks.ts b/apps/wallet-mobile/src/features/Notifications/useCases/common/hooks.ts index b0736a3b0d..21e427fe42 100644 --- a/apps/wallet-mobile/src/features/Notifications/useCases/common/hooks.ts +++ b/apps/wallet-mobile/src/features/Notifications/useCases/common/hooks.ts @@ -5,6 +5,7 @@ import {PermissionsAndroid} from 'react-native' import {notificationManager} from './notification-manager' import {parseNotificationId} from './notifications' +import {usePrimaryTokenPriceChangedNotification} from './primary-token-price-changed-notification' import {useTransactionReceivedNotifications} from './transaction-received-notification' let initialized = false @@ -39,4 +40,5 @@ const init = () => { export const useInitNotifications = ({enabled}: {enabled: boolean}) => { React.useEffect(() => (enabled ? init() : undefined), [enabled]) useTransactionReceivedNotifications({enabled}) + usePrimaryTokenPriceChangedNotification({enabled}) } diff --git a/apps/wallet-mobile/src/features/Notifications/useCases/common/notification-manager.ts b/apps/wallet-mobile/src/features/Notifications/useCases/common/notification-manager.ts index 0dc2dc6d22..c18f248e2c 100644 --- a/apps/wallet-mobile/src/features/Notifications/useCases/common/notification-manager.ts +++ b/apps/wallet-mobile/src/features/Notifications/useCases/common/notification-manager.ts @@ -3,6 +3,7 @@ import {notificationManagerMaker} from '@yoroi/notifications' import {Notifications} from '@yoroi/types' import {displayNotificationEvent} from './notifications' +import {primaryTokenPriceChangedSubject} from './primary-token-price-changed-notification' import {transactionReceivedSubject} from './transaction-received-notification' const appStorage = mountAsyncStorage({path: '/'}) @@ -14,5 +15,6 @@ export const notificationManager = notificationManagerMaker({ display: displayNotificationEvent, subscriptions: { [Notifications.Trigger.TransactionReceived]: transactionReceivedSubject, + [Notifications.Trigger.PrimaryTokenPriceChanged]: primaryTokenPriceChangedSubject, }, }) diff --git a/apps/wallet-mobile/src/features/Notifications/useCases/common/notifications.ts b/apps/wallet-mobile/src/features/Notifications/useCases/common/notifications.ts index ca4b688668..11356dba56 100644 --- a/apps/wallet-mobile/src/features/Notifications/useCases/common/notifications.ts +++ b/apps/wallet-mobile/src/features/Notifications/useCases/common/notifications.ts @@ -1,6 +1,9 @@ import {Notification, Notifications} from '@jamsinclair/react-native-notifications' +import {mountAsyncStorage} from '@yoroi/common' import {Notifications as NotificationTypes} from '@yoroi/types' +import {formatCurrency, getCurrencySymbol} from '../../../Settings/useCases/changeAppSettings/Currency/CurrencyContext' + export const generateNotificationId = (): number => { return generateRandomInteger(0, Number.MAX_SAFE_INTEGER) } @@ -13,7 +16,7 @@ const generateRandomInteger = (min: number, max: number): number => { return Math.floor(Math.random() * (max - min + 1)) + min } -export const displayNotificationEvent = (notificationEvent: NotificationTypes.Event) => { +export const displayNotificationEvent = async (notificationEvent: NotificationTypes.Event) => { if (notificationEvent.trigger === NotificationTypes.Trigger.TransactionReceived) { sendNotification({ title: 'Transaction received', @@ -21,6 +24,18 @@ export const displayNotificationEvent = (notificationEvent: NotificationTypes.Ev id: notificationEvent.id, }) } + + if (notificationEvent.trigger === NotificationTypes.Trigger.PrimaryTokenPriceChanged) { + const appStorage = mountAsyncStorage({path: '/'}) + const currencyCode = await getCurrencySymbol(appStorage) + const newPrice = formatCurrency(notificationEvent.metadata.nextPrice, currencyCode) + + sendNotification({ + title: 'Primary token price changed', + body: `The price of the primary token has changed to ${newPrice}.`, + id: notificationEvent.id, + }) + } } const sendNotification = (options: {title: string; body: string; id: number}) => { diff --git a/apps/wallet-mobile/src/features/Notifications/useCases/common/primary-token-price-changed-notification.ts b/apps/wallet-mobile/src/features/Notifications/useCases/common/primary-token-price-changed-notification.ts new file mode 100644 index 0000000000..438c8a77ce --- /dev/null +++ b/apps/wallet-mobile/src/features/Notifications/useCases/common/primary-token-price-changed-notification.ts @@ -0,0 +1,115 @@ +import {isRight, useAsyncStorage} from '@yoroi/common' +import {mountAsyncStorage} from '@yoroi/common/src' +import {App, Notifications as NotificationTypes} from '@yoroi/types' +import * as BackgroundFetch from 'expo-background-fetch' +import * as TaskManager from 'expo-task-manager' +import * as React from 'react' +import {Subject} from 'rxjs' + +import {time} from '../../../../kernel/constants' +import {fetchPtPriceActivity} from '../../../../yoroi-wallets/cardano/usePrimaryTokenActivity' +import {getCurrencySymbol} from '../../../Settings/useCases/changeAppSettings/Currency/CurrencyContext' +import {useWalletManager} from '../../../WalletManager/context/WalletManagerProvider' +import {notificationManager} from './notification-manager' +import {generateNotificationId} from './notifications' +import {buildProcessedNotificationsStorage} from './storage' + +const backgroundTaskId = 'yoroi-primary-token-price-changed-background-fetch' +const refetchIntervalInSeconds = 60 * 10 +const refetchIntervalInMilliseconds = refetchIntervalInSeconds * 1000 +const storageKey = 'notifications/primary-token-price-changed/' + +// Check is needed for hot reloading, as task can not be defined twice +if (!TaskManager.isTaskDefined(backgroundTaskId)) { + const appStorage = mountAsyncStorage({path: '/'}) + TaskManager.defineTask(backgroundTaskId, async () => { + const notifications = await buildNotifications(appStorage) + notifications.forEach((notification) => notificationManager.events.push(notification)) + + const hasNewData = notifications.length > 0 + return hasNewData ? BackgroundFetch.BackgroundFetchResult.NewData : BackgroundFetch.BackgroundFetchResult.NoData + }) +} + +const buildNotifications = async ( + appStorage: App.Storage, +): Promise => { + const notifications: NotificationTypes.PrimaryTokenPriceChangedEvent[] = [] + const storage = buildProcessedNotificationsStorage(appStorage.join(storageKey)) + const date = new Date() + const dateString = date.toDateString() + + if (await storage.includes(dateString)) { + return [] + } + + const response = await fetchPtPriceActivity([Date.now(), Date.now() - time.oneDay]) + const currency = await getCurrencySymbol(appStorage) + const notificationsConfig = await notificationManager.config.read() + const primaryTokenChangeNotificationConfig = notificationsConfig[NotificationTypes.Trigger.PrimaryTokenPriceChanged] + + if (isRight(response)) { + const tickers = response.value.data.tickers + const close = tickers[0]?.prices[currency] ?? 1 + const open = tickers[1]?.prices[currency] ?? 1 + const changeInPercent = (Math.abs(close - open) / open) * 100 + + if (changeInPercent >= primaryTokenChangeNotificationConfig.thresholdInPercent) { + const event = createPrimaryTokenPriceChangedNotification({previousPrice: open, nextPrice: close}) + notifications.push(event) + await storage.addValues([dateString]) + } + } + + return notifications +} + +export const primaryTokenPriceChangedSubject = new Subject() + +export const usePrimaryTokenPriceChangedNotification = ({enabled}: {enabled: boolean}) => { + const {walletManager} = useWalletManager() + const asyncStorage = useAsyncStorage() + + React.useEffect(() => { + if (!enabled) return + registerBackgroundFetchAsync() + return () => { + unregisterBackgroundFetchAsync() + } + }, [enabled]) + + React.useEffect(() => { + if (!enabled) return + + const interval = setInterval(async () => { + const notifications = await buildNotifications(asyncStorage) + notifications.forEach((notification) => primaryTokenPriceChangedSubject.next(notification)) + }, refetchIntervalInMilliseconds) + + return () => clearInterval(interval) + }, [walletManager, asyncStorage, enabled]) +} + +const registerBackgroundFetchAsync = () => { + return BackgroundFetch.registerTaskAsync(backgroundTaskId, { + minimumInterval: refetchIntervalInSeconds, + stopOnTerminate: false, + startOnBoot: true, + }) +} + +const unregisterBackgroundFetchAsync = () => { + return BackgroundFetch.unregisterTaskAsync(backgroundTaskId) +} + +const createPrimaryTokenPriceChangedNotification = ( + metadata: NotificationTypes.PrimaryTokenPriceChangedEvent['metadata'], +): NotificationTypes.PrimaryTokenPriceChangedEvent => { + return { + trigger: NotificationTypes.Trigger.PrimaryTokenPriceChanged, + id: generateNotificationId(), + date: new Date().toISOString(), + isRead: false, + metadata, + } as const +} diff --git a/apps/wallet-mobile/src/features/Notifications/useCases/common/storage.ts b/apps/wallet-mobile/src/features/Notifications/useCases/common/storage.ts new file mode 100644 index 0000000000..3cfee7308a --- /dev/null +++ b/apps/wallet-mobile/src/features/Notifications/useCases/common/storage.ts @@ -0,0 +1,29 @@ +import {App} from '@yoroi/types' + +export const buildProcessedNotificationsStorage = (storage: App.Storage) => { + const getValues = async () => { + return (await storage.getItem('processed')) || [] + } + + const addValues = async (values: string[]) => { + const processed = await getValues() + const newProcessed = [...processed, ...values] + await storage.setItem('processed', newProcessed) + } + + const includes = async (value: string) => { + const processed = await getValues() + return processed.includes(value) + } + + const clear = async () => { + await storage.setItem('processed', []) + } + + return { + getValues, + addValues, + includes, + clear, + } +} diff --git a/apps/wallet-mobile/src/features/Notifications/useCases/common/transaction-received-notification.ts b/apps/wallet-mobile/src/features/Notifications/useCases/common/transaction-received-notification.ts index b84b38c098..dcd4bab540 100644 --- a/apps/wallet-mobile/src/features/Notifications/useCases/common/transaction-received-notification.ts +++ b/apps/wallet-mobile/src/features/Notifications/useCases/common/transaction-received-notification.ts @@ -9,18 +9,20 @@ import {Subject} from 'rxjs' import {YoroiWallet} from '../../../../yoroi-wallets/cardano/types' import {TRANSACTION_DIRECTION} from '../../../../yoroi-wallets/types/other' import {useWalletManager} from '../../../WalletManager/context/WalletManagerProvider' -import {WalletManager, walletManager} from '../../../WalletManager/wallet-manager' +import {walletManager} from '../../../WalletManager/wallet-manager' import {notificationManager} from './notification-manager' import {generateNotificationId} from './notifications' +import {buildProcessedNotificationsStorage} from './storage' -const BACKGROUND_FETCH_TASK = 'yoroi-transaction-received-notifications-background-fetch' +const backgroundTaskId = 'yoroi-transaction-received-notifications-background-fetch' +const storageKey = 'transaction-received-notification-history' // Check is needed for hot reloading, as task can not be defined twice -if (!TaskManager.isTaskDefined(BACKGROUND_FETCH_TASK)) { +if (!TaskManager.isTaskDefined(backgroundTaskId)) { const appStorage = mountAsyncStorage({path: '/'}) - TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => { - await syncAllWallets(walletManager) - const notifications = await checkForNewTransactions(walletManager, appStorage) + TaskManager.defineTask(backgroundTaskId, async () => { + await syncAllWallets() + const notifications = await buildNotifications(appStorage) notifications.forEach((notification) => notificationManager.events.push(notification)) const hasNewData = notifications.length > 0 return hasNewData ? BackgroundFetch.BackgroundFetchResult.NewData : BackgroundFetch.BackgroundFetchResult.NoData @@ -28,7 +30,7 @@ if (!TaskManager.isTaskDefined(BACKGROUND_FETCH_TASK)) { } const registerBackgroundFetchAsync = () => { - return BackgroundFetch.registerTaskAsync(BACKGROUND_FETCH_TASK, { + return BackgroundFetch.registerTaskAsync(backgroundTaskId, { minimumInterval: 60 * 10, stopOnTerminate: false, startOnBoot: true, @@ -36,10 +38,10 @@ const registerBackgroundFetchAsync = () => { } const unregisterBackgroundFetchAsync = () => { - return BackgroundFetch.unregisterTaskAsync(BACKGROUND_FETCH_TASK) + return BackgroundFetch.unregisterTaskAsync(backgroundTaskId) } -const syncAllWallets = async (walletManager: WalletManager) => { +const syncAllWallets = async () => { const ids = [...walletManager.walletMetas.keys()] for (const id of ids) { const wallet = walletManager.getWalletById(id) @@ -48,19 +50,19 @@ const syncAllWallets = async (walletManager: WalletManager) => { } } -const checkForNewTransactions = async (walletManager: WalletManager, appStorage: App.Storage) => { +const buildNotifications = async (appStorage: App.Storage) => { const walletIds = [...walletManager.walletMetas.keys()] const notifications: NotificationTypes.TransactionReceivedEvent[] = [] for (const walletId of walletIds) { const wallet = walletManager.getWalletById(walletId) if (!wallet) continue - const storage = buildStorage(appStorage, walletId) - const processed = await storage.getProcessedTransactions() + const storage = buildProcessedNotificationsStorage(appStorage.join(`wallet/${walletId}/${storageKey}/`)) + const processed = await storage.getValues() const allTxIds = getTxIds(wallet) if (processed.length === 0) { - await storage.addProcessedTransactions(allTxIds) + await storage.addValues(allTxIds) continue } @@ -70,7 +72,7 @@ const checkForNewTransactions = async (walletManager: WalletManager, appStorage: continue } - await storage.addProcessedTransactions(newTxIds) + await storage.addValues(newTxIds) newTxIds.forEach((id) => { const metadata: NotificationTypes.TransactionReceivedEvent['metadata'] = { @@ -125,7 +127,7 @@ export const useTransactionReceivedNotifications = ({enabled}: {enabled: boolean const areAllDone = walletsDoneSyncing.length === walletInfos.length if (!areAllDone) return - const notifications = await checkForNewTransactions(walletManager, asyncStorage) + const notifications = await buildNotifications(asyncStorage) notifications.forEach((notification) => transactionReceivedSubject.next(notification)) }) @@ -134,22 +136,3 @@ export const useTransactionReceivedNotifications = ({enabled}: {enabled: boolean } }, [walletManager, asyncStorage, enabled]) } - -const buildStorage = (appStorage: App.Storage, walletId: string) => { - const storage = appStorage.join(`wallet/${walletId}/transaction-received-notification-history/`) - - const getProcessedTransactions = async () => { - return (await storage.getItem('processed')) || [] - } - - const addProcessedTransactions = async (txIds: string[]) => { - const processed = await getProcessedTransactions() - const newProcessed = [...processed, ...txIds] - await storage.setItem('processed', newProcessed) - } - - return { - getProcessedTransactions, - addProcessedTransactions, - } -} diff --git a/apps/wallet-mobile/src/features/Settings/useCases/changeAppSettings/Currency/CurrencyContext.tsx b/apps/wallet-mobile/src/features/Settings/useCases/changeAppSettings/Currency/CurrencyContext.tsx index c6f8a481ec..430c5b548b 100644 --- a/apps/wallet-mobile/src/features/Settings/useCases/changeAppSettings/Currency/CurrencyContext.tsx +++ b/apps/wallet-mobile/src/features/Settings/useCases/changeAppSettings/Currency/CurrencyContext.tsx @@ -1,4 +1,5 @@ import {parseSafe, useAsyncStorage} from '@yoroi/common' +import {App} from '@yoroi/types' import React from 'react' import {useMutation, UseMutationOptions, useQuery, useQueryClient} from 'react-query' @@ -37,21 +38,27 @@ const useCurrency = () => { const storage = useAsyncStorage() const query = useQuery({ queryKey: ['currencySymbol'], - queryFn: async () => { - const currencySymbol = await storage.join('appSettings/').getItem('currencySymbol', parseCurrencySymbol) - - if (currencySymbol != null) { - const stillSupported = Object.values(supportedCurrencies).includes(currencySymbol) - if (stillSupported) return currencySymbol - } - - return defaultCurrency - }, + queryFn: () => getCurrencySymbol(storage), }) return query.data ?? defaultCurrency } +export const getCurrencySymbol = async (storage: App.Storage) => { + const currencySymbol = await storage.join('appSettings/').getItem('currencySymbol', parseCurrencySymbol) + + if (currencySymbol != null) { + const stillSupported = Object.values(supportedCurrencies).includes(currencySymbol) + if (stillSupported) return currencySymbol + } + + return defaultCurrency +} + +export const formatCurrency = (value: number, currency: CurrencySymbol) => { + return `${value.toFixed(configCurrencies[currency].decimals)} ${currency}` +} + const useSaveCurrency = ({onSuccess, ...options}: UseMutationOptions = {}) => { const queryClient = useQueryClient() const storage = useAsyncStorage() diff --git a/packages/notifications/src/notification-manager.test.ts b/packages/notifications/src/notification-manager.test.ts index 752b72ef71..eaef7cbdfa 100644 --- a/packages/notifications/src/notification-manager.test.ts +++ b/packages/notifications/src/notification-manager.test.ts @@ -245,7 +245,8 @@ describe('NotificationManager', () => { const event = createTransactionReceivedEvent() - const notificationSubscription = new BehaviorSubject(event) + const notificationSubscription = + new BehaviorSubject(event) const manager = notificationManagerMaker({ eventsStorage, diff --git a/packages/notifications/src/notification-manager.ts b/packages/notifications/src/notification-manager.ts index 6e97ecca68..4c938d904b 100644 --- a/packages/notifications/src/notification-manager.ts +++ b/packages/notifications/src/notification-manager.ts @@ -1,4 +1,4 @@ -import {BehaviorSubject, Subscription} from 'rxjs' +import {BehaviorSubject, Subject, Subscription} from 'rxjs' import {App, Notifications} from '@yoroi/types' type EventsStorageData = ReadonlyArray @@ -19,8 +19,11 @@ export const notificationManagerMaker = ({ const hydrate = () => { const triggers = getAllTriggers() triggers.forEach((trigger) => { - const subscription = subscriptions?.[trigger]?.subscribe( - (event: Notifications.Event) => events.push(event), + const observable = subscriptions?.[trigger] as + | Subject + | undefined + const subscription = observable?.subscribe((event: Notifications.Event) => + events.push(event), ) if (subscription) { localSubscriptions.push(subscription) diff --git a/packages/types/src/notifications/manager.ts b/packages/types/src/notifications/manager.ts index 2e302b4c12..21766d7be0 100644 --- a/packages/types/src/notifications/manager.ts +++ b/packages/types/src/notifications/manager.ts @@ -10,9 +10,11 @@ export enum NotificationTrigger { export type NotificationManagerMakerProps = { eventsStorage: AppStorage configStorage: AppStorage - subscriptions?: Partial< - Record> - > + subscriptions?: Partial<{ + [NotificationTrigger.TransactionReceived]: Subject + [NotificationTrigger.RewardsUpdated]: Subject + [NotificationTrigger.PrimaryTokenPriceChanged]: Subject + }> display: (event: NotificationEvent) => void eventsLimit?: number } @@ -43,7 +45,9 @@ export interface NotificationPrimaryTokenPriceChangedEvent export type NotificationGroup = 'transaction-history' | 'portfolio' -export type NotificationEvent = NotificationTransactionReceivedEvent +export type NotificationEvent = + | NotificationTransactionReceivedEvent + | NotificationPrimaryTokenPriceChangedEvent type NotificationEventId = number From c14e5451573c617481bd193dc885ec22fcdb1393 Mon Sep 17 00:00:00 2001 From: banklesss <105349292+banklesss@users.noreply.github.com> Date: Sun, 27 Oct 2024 12:11:46 +0100 Subject: [PATCH 043/113] feature(wallet-mobile): new tx review success and error screens (#3712) --- .../ConfirmTxWithHwModal.tsx | 2 +- .../LedgerTransportSwitch.tsx | 12 +- .../common/ConfirmHWConnectionModal.tsx | 2 +- .../useCases/ConfirmPin/ConfirmPin.tsx | 14 +- .../features/ReviewTx/ReviewTxNavigator.tsx | 6 + .../ReviewTx/common/ReviewTxProvider.tsx | 65 +-- .../ReviewTx/common/hooks/useNavigateTo.tsx | 13 + .../ReviewTx/common/hooks/useOnConfirm.tsx | 8 +- .../ReviewTx/common/hooks/useSignTx.tsx | 69 ++++ .../ReviewTx/common/hooks/useStrings.tsx | 30 ++ .../ReviewTx/illustrations/FailedTxIcon.tsx | 46 +++ .../illustrations/SuccessfulTxIcon.tsx | 79 ++++ .../ReviewTxScreen/ReviewTxScreen.tsx | 13 +- .../ShowFailedTxScreen/FailedTxScreen.tsx | 75 ++++ .../SubmittedTxScreen.tsx | 76 ++++ .../src/features/Send/common/navigation.ts | 2 - .../ConfirmTx/FailedTx/FailedTxImage.tsx | 62 --- .../FailedTx/FailedTxScreen.stories.tsx | 8 - .../ConfirmTx/FailedTx/FailedTxScreen.tsx | 62 --- .../SubmittedTx/SubmittedTxImage.tsx | 78 ---- .../SubmittedTx/SubmittedTxScreen.stories.tsx | 8 - .../SubmittedTx/SubmittedTxScreen.tsx | 65 --- .../ConfirmTx/Summary/BalanceAfter.tsx | 43 -- .../ConfirmTx/Summary/CurrentBalance.tsx | 43 -- .../Send/useCases/ConfirmTx/Summary/Fees.tsx | 29 -- .../ConfirmTx/Summary/PrimaryTotal.tsx | 46 --- .../ConfirmTx/Summary/ReceiverInfo.tsx | 67 ---- .../ConfirmTx/Summary/SecondaryTotals.tsx | 58 --- .../ListAmountsToSendScreen.tsx | 8 +- .../Settings/SettingsScreenNavigator.tsx | 19 - .../ConfirmTx/FailedTx/FailedTxImage.tsx | 62 --- .../FailedTx/FailedTxScreen.stories.tsx | 8 - .../ConfirmTx/FailedTx/FailedTxScreen.tsx | 60 --- .../SubmittedTx/SubmittedTxImage.tsx | 78 ---- .../SubmittedTx/SubmittedTxScreen.stories.tsx | 8 - .../SubmittedTx/SubmittedTxScreen.tsx | 62 --- .../ManageCollateralScreen.tsx | 13 +- .../ManageCollateral/navigation.ts | 14 - .../Governance/GovernanceNavigator.tsx | 6 - .../Staking/Governance/common/helpers.tsx | 76 +--- .../Staking/Governance/common/navigation.ts | 10 +- .../FailedTx/FailedTxScreen.stories.tsx | 9 - .../useCases/FailedTx/FailedTxScreen.tsx | 75 ---- .../Governance/useCases/Home/HomeScreen.tsx | 7 +- .../SuccessTx/SuccessTxScreen.stories.tsx | 9 - .../useCases/SuccessTx/SuccessTxScreen.tsx | 106 ----- .../ConfirmRawTx/ConfirmRawTxWithHW.tsx | 2 +- .../src/features/Swap/common/navigation.ts | 2 - .../ShowFailedTxScreen/FailedTxImage.tsx | 62 --- .../ShowFailedTxScreen.stories.tsx | 8 - .../ShowFailedTxScreen/ShowFailedTxScreen.tsx | 61 --- .../ShowSubmittedTxScreen.stories.tsx | 20 - .../ShowSubmittedTxScreen.tsx | 96 ----- .../SubmittedTxImage.tsx | 78 ---- .../Swap/useCases/ReviewSwap/ReviewSwap.tsx | 21 +- .../src/features/Swap/useCases/index.ts | 2 - .../Transactions/TxHistoryNavigator.tsx | 33 +- .../src/kernel/i18n/locales/en-US.json | 8 +- apps/wallet-mobile/src/kernel/navigation.tsx | 27 +- .../src/legacy/Dashboard/Dashboard.tsx | 2 +- .../legacy/Dashboard/DashboardNavigator.tsx | 3 - .../legacy/Staking/FailedTx/FailedTxImage.tsx | 47 --- .../FailedTx/FailedTxScreen.stories.tsx | 8 - .../Staking/FailedTx/FailedTxScreen.tsx | 121 ------ .../PoolTransition/usePoolTransition.tsx | 41 +- .../Staking/StakingCenter/StakingCenter.tsx | 25 +- .../messages/src/AppNavigator.json | 32 +- .../ReviewTx/common/hooks/useStrings.json | 378 +++++++++++------- .../ListAmountsToSendScreen.json | 8 +- .../Settings/SettingsScreenNavigator.json | 160 ++++---- .../Transactions/TxHistoryNavigator.json | 184 ++++----- .../src/legacy/Dashboard/Dashboard.json | 4 +- .../legacy/Dashboard/DashboardNavigator.json | 8 +- .../PoolTransition/usePoolTransition.json | 104 ++--- .../Staking/StakingCenter/StakingCenter.json | 16 +- 75 files changed, 979 insertions(+), 2293 deletions(-) rename apps/wallet-mobile/src/{features/Swap/useCases/ConfirmTxScreen => components/LedgerTransportSwitch}/LedgerTransportSwitch.tsx (82%) create mode 100644 apps/wallet-mobile/src/features/ReviewTx/common/hooks/useNavigateTo.tsx create mode 100644 apps/wallet-mobile/src/features/ReviewTx/common/hooks/useSignTx.tsx create mode 100644 apps/wallet-mobile/src/features/ReviewTx/illustrations/FailedTxIcon.tsx create mode 100644 apps/wallet-mobile/src/features/ReviewTx/illustrations/SuccessfulTxIcon.tsx create mode 100644 apps/wallet-mobile/src/features/ReviewTx/useCases/ShowFailedTxScreen/FailedTxScreen.tsx create mode 100644 apps/wallet-mobile/src/features/ReviewTx/useCases/ShowSubmittedTxScreen/SubmittedTxScreen.tsx delete mode 100644 apps/wallet-mobile/src/features/Send/useCases/ConfirmTx/FailedTx/FailedTxImage.tsx delete mode 100644 apps/wallet-mobile/src/features/Send/useCases/ConfirmTx/FailedTx/FailedTxScreen.stories.tsx delete mode 100644 apps/wallet-mobile/src/features/Send/useCases/ConfirmTx/FailedTx/FailedTxScreen.tsx delete mode 100644 apps/wallet-mobile/src/features/Send/useCases/ConfirmTx/SubmittedTx/SubmittedTxImage.tsx delete mode 100644 apps/wallet-mobile/src/features/Send/useCases/ConfirmTx/SubmittedTx/SubmittedTxScreen.stories.tsx delete mode 100644 apps/wallet-mobile/src/features/Send/useCases/ConfirmTx/SubmittedTx/SubmittedTxScreen.tsx delete mode 100644 apps/wallet-mobile/src/features/Send/useCases/ConfirmTx/Summary/BalanceAfter.tsx delete mode 100644 apps/wallet-mobile/src/features/Send/useCases/ConfirmTx/Summary/CurrentBalance.tsx delete mode 100644 apps/wallet-mobile/src/features/Send/useCases/ConfirmTx/Summary/Fees.tsx delete mode 100644 apps/wallet-mobile/src/features/Send/useCases/ConfirmTx/Summary/PrimaryTotal.tsx delete mode 100644 apps/wallet-mobile/src/features/Send/useCases/ConfirmTx/Summary/ReceiverInfo.tsx delete mode 100644 apps/wallet-mobile/src/features/Send/useCases/ConfirmTx/Summary/SecondaryTotals.tsx delete mode 100644 apps/wallet-mobile/src/features/Settings/useCases/changeWalletSettings/ManageCollateral/ConfirmTx/FailedTx/FailedTxImage.tsx delete mode 100644 apps/wallet-mobile/src/features/Settings/useCases/changeWalletSettings/ManageCollateral/ConfirmTx/FailedTx/FailedTxScreen.stories.tsx delete mode 100644 apps/wallet-mobile/src/features/Settings/useCases/changeWalletSettings/ManageCollateral/ConfirmTx/FailedTx/FailedTxScreen.tsx delete mode 100644 apps/wallet-mobile/src/features/Settings/useCases/changeWalletSettings/ManageCollateral/ConfirmTx/SubmittedTx/SubmittedTxImage.tsx delete mode 100644 apps/wallet-mobile/src/features/Settings/useCases/changeWalletSettings/ManageCollateral/ConfirmTx/SubmittedTx/SubmittedTxScreen.stories.tsx delete mode 100644 apps/wallet-mobile/src/features/Settings/useCases/changeWalletSettings/ManageCollateral/ConfirmTx/SubmittedTx/SubmittedTxScreen.tsx delete mode 100644 apps/wallet-mobile/src/features/Settings/useCases/changeWalletSettings/ManageCollateral/navigation.ts delete mode 100644 apps/wallet-mobile/src/features/Staking/Governance/useCases/FailedTx/FailedTxScreen.stories.tsx delete mode 100644 apps/wallet-mobile/src/features/Staking/Governance/useCases/FailedTx/FailedTxScreen.tsx delete mode 100644 apps/wallet-mobile/src/features/Staking/Governance/useCases/SuccessTx/SuccessTxScreen.stories.tsx delete mode 100644 apps/wallet-mobile/src/features/Staking/Governance/useCases/SuccessTx/SuccessTxScreen.tsx delete mode 100644 apps/wallet-mobile/src/features/Swap/useCases/ConfirmTxScreen/ShowFailedTxScreen/FailedTxImage.tsx delete mode 100644 apps/wallet-mobile/src/features/Swap/useCases/ConfirmTxScreen/ShowFailedTxScreen/ShowFailedTxScreen.stories.tsx delete mode 100644 apps/wallet-mobile/src/features/Swap/useCases/ConfirmTxScreen/ShowFailedTxScreen/ShowFailedTxScreen.tsx delete mode 100644 apps/wallet-mobile/src/features/Swap/useCases/ConfirmTxScreen/ShowSubmittedTxScreen/ShowSubmittedTxScreen.stories.tsx delete mode 100644 apps/wallet-mobile/src/features/Swap/useCases/ConfirmTxScreen/ShowSubmittedTxScreen/ShowSubmittedTxScreen.tsx delete mode 100644 apps/wallet-mobile/src/features/Swap/useCases/ConfirmTxScreen/ShowSubmittedTxScreen/SubmittedTxImage.tsx delete mode 100644 apps/wallet-mobile/src/legacy/Staking/FailedTx/FailedTxImage.tsx delete mode 100644 apps/wallet-mobile/src/legacy/Staking/FailedTx/FailedTxScreen.stories.tsx delete mode 100644 apps/wallet-mobile/src/legacy/Staking/FailedTx/FailedTxScreen.tsx diff --git a/apps/wallet-mobile/src/components/ConfirmTxWithHwModal/ConfirmTxWithHwModal.tsx b/apps/wallet-mobile/src/components/ConfirmTxWithHwModal/ConfirmTxWithHwModal.tsx index 7846df0cd9..4c00a066fa 100644 --- a/apps/wallet-mobile/src/components/ConfirmTxWithHwModal/ConfirmTxWithHwModal.tsx +++ b/apps/wallet-mobile/src/components/ConfirmTxWithHwModal/ConfirmTxWithHwModal.tsx @@ -4,7 +4,6 @@ import React, {useState} from 'react' import {ErrorBoundary} from 'react-error-boundary' import {ActivityIndicator, ScrollView, StyleSheet, View} from 'react-native' -import {LedgerTransportSwitch} from '../../features/Swap/useCases/ConfirmTxScreen/LedgerTransportSwitch' import {useSelectedWallet} from '../../features/WalletManager/common/hooks/useSelectedWallet' import {useWalletManager} from '../../features/WalletManager/context/WalletManagerProvider' import {LedgerConnect} from '../../legacy/HW' @@ -12,6 +11,7 @@ import {useSignTxWithHW, useSubmitTx} from '../../yoroi-wallets/hooks' import {withBLE, withUSB} from '../../yoroi-wallets/hw/hwWallet' import {YoroiSignedTx, YoroiUnsignedTx} from '../../yoroi-wallets/types/yoroi' import {delay} from '../../yoroi-wallets/utils/timeUtils' +import {LedgerTransportSwitch} from '../LedgerTransportSwitch/LedgerTransportSwitch' import {ModalError} from '../ModalError/ModalError' import {Text} from '../Text' import {useStrings} from './strings' diff --git a/apps/wallet-mobile/src/features/Swap/useCases/ConfirmTxScreen/LedgerTransportSwitch.tsx b/apps/wallet-mobile/src/components/LedgerTransportSwitch/LedgerTransportSwitch.tsx similarity index 82% rename from apps/wallet-mobile/src/features/Swap/useCases/ConfirmTxScreen/LedgerTransportSwitch.tsx rename to apps/wallet-mobile/src/components/LedgerTransportSwitch/LedgerTransportSwitch.tsx index bd06825b7f..9ba7b95e38 100644 --- a/apps/wallet-mobile/src/features/Swap/useCases/ConfirmTxScreen/LedgerTransportSwitch.tsx +++ b/apps/wallet-mobile/src/components/LedgerTransportSwitch/LedgerTransportSwitch.tsx @@ -2,12 +2,12 @@ import {useTheme} from '@yoroi/theme' import React from 'react' import {Alert, ScrollView, StyleSheet, View} from 'react-native' -import {Button, ButtonType} from '../../../../components/Button/Button' -import {Spacer} from '../../../../components/Spacer/Spacer' -import {Text} from '../../../../components/Text' -import {useIsUsbSupported} from '../../../../legacy/HW' -import {HARDWARE_WALLETS, useLedgerPermissions} from '../../../../yoroi-wallets/hw/hw' -import {useStrings} from '../../common/strings' +import {useStrings} from '../../features/Swap/common/strings' +import {useIsUsbSupported} from '../../legacy/HW' +import {HARDWARE_WALLETS, useLedgerPermissions} from '../../yoroi-wallets/hw/hw' +import {Button, ButtonType} from '../Button/Button' +import {Spacer} from '../Spacer/Spacer' +import {Text} from '../Text' type Props = { onSelectUSB: () => void diff --git a/apps/wallet-mobile/src/features/Discover/common/ConfirmHWConnectionModal.tsx b/apps/wallet-mobile/src/features/Discover/common/ConfirmHWConnectionModal.tsx index 2da7d51285..7558ab1255 100644 --- a/apps/wallet-mobile/src/features/Discover/common/ConfirmHWConnectionModal.tsx +++ b/apps/wallet-mobile/src/features/Discover/common/ConfirmHWConnectionModal.tsx @@ -3,11 +3,11 @@ import {HW} from '@yoroi/types' import React, {useCallback, useState} from 'react' import {ActivityIndicator, ScrollView, StyleSheet, View} from 'react-native' +import {LedgerTransportSwitch} from '../../../components/LedgerTransportSwitch/LedgerTransportSwitch' import {useModal} from '../../../components/Modal/ModalContext' import {Text} from '../../../components/Text' import {LedgerConnect} from '../../../legacy/HW' import {withBLE, withUSB} from '../../../yoroi-wallets/hw/hwWallet' -import {LedgerTransportSwitch} from '../../Swap/useCases/ConfirmTxScreen/LedgerTransportSwitch' import {useSelectedWallet} from '../../WalletManager/common/hooks/useSelectedWallet' import {useWalletManager} from '../../WalletManager/context/WalletManagerProvider' import {useStrings} from './useStrings' diff --git a/apps/wallet-mobile/src/features/RegisterCatalyst/useCases/ConfirmPin/ConfirmPin.tsx b/apps/wallet-mobile/src/features/RegisterCatalyst/useCases/ConfirmPin/ConfirmPin.tsx index 90c3fda793..f69850c9a5 100644 --- a/apps/wallet-mobile/src/features/RegisterCatalyst/useCases/ConfirmPin/ConfirmPin.tsx +++ b/apps/wallet-mobile/src/features/RegisterCatalyst/useCases/ConfirmPin/ConfirmPin.tsx @@ -26,7 +26,7 @@ export const ConfirmPin = () => { const navigateTo = useNavigateTo() const [currentActivePin, setCurrentActivePin] = React.useState(1) const {wallet, meta} = useSelectedWallet() - const {onCIP36SupportChangeChanged, unsignedTxChanged, onSuccessChanged} = useReviewTx() + const {unsignedTxChanged} = useReviewTx() const {navigateToTxReview} = useWalletNavigation() const {generateVotingKeys, isLoading} = useGenerateVotingKeys({ @@ -40,13 +40,13 @@ export const ConfirmPin = () => { }) unsignedTxChanged(votingRegTx.votingRegTx) - onCIP36SupportChangeChanged(async (supportsCIP36: boolean) => { - votingRegTx = await wallet.createVotingRegTx({catalystKeyHex, supportsCIP36, addressMode: meta.addressMode}) - unsignedTxChanged(votingRegTx.votingRegTx) + navigateToTxReview({ + onCIP36SupportChange: async (supportsCIP36: boolean) => { + votingRegTx = await wallet.createVotingRegTx({catalystKeyHex, supportsCIP36, addressMode: meta.addressMode}) + unsignedTxChanged(votingRegTx.votingRegTx) + }, + onSuccess: navigateTo.qrCode, }) - onSuccessChanged(navigateTo.qrCode) - - navigateToTxReview() }, }) diff --git a/apps/wallet-mobile/src/features/ReviewTx/ReviewTxNavigator.tsx b/apps/wallet-mobile/src/features/ReviewTx/ReviewTxNavigator.tsx index 4f28322aff..1ae41aecb4 100644 --- a/apps/wallet-mobile/src/features/ReviewTx/ReviewTxNavigator.tsx +++ b/apps/wallet-mobile/src/features/ReviewTx/ReviewTxNavigator.tsx @@ -6,6 +6,8 @@ import {Boundary} from '../../components/Boundary/Boundary' import {defaultStackNavigationOptions, ReviewTxRoutes} from '../../kernel/navigation' import {useStrings} from './common/hooks/useStrings' import {ReviewTxScreen} from './useCases/ReviewTxScreen/ReviewTxScreen' +import {FailedTxScreen} from './useCases/ShowFailedTxScreen/FailedTxScreen' +import {SubmittedTxScreen} from './useCases/ShowSubmittedTxScreen/SubmittedTxScreen' export const Stack = createStackNavigator() @@ -26,6 +28,10 @@ export const ReviewTxNavigator = () => { )} + + + + ) } diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/ReviewTxProvider.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/ReviewTxProvider.tsx index c882fef6e7..9e6c7ab203 100644 --- a/apps/wallet-mobile/src/features/ReviewTx/common/ReviewTxProvider.tsx +++ b/apps/wallet-mobile/src/features/ReviewTx/common/ReviewTxProvider.tsx @@ -2,7 +2,7 @@ import {castDraft, produce} from 'immer' import _ from 'lodash' import React from 'react' -import {YoroiSignedTx, YoroiUnsignedTx} from '../../../yoroi-wallets/types/yoroi' +import {YoroiUnsignedTx} from '../../../yoroi-wallets/types/yoroi' export const useReviewTx = () => React.useContext(ReviewTxContext) @@ -27,13 +27,6 @@ export const ReviewTxProvider = ({ customReceiverTitleChanged: (customReceiverTitle: ReviewTxState['customReceiverTitle']) => dispatch({type: ReviewTxActionType.CustomReceiverTitleChanged, customReceiverTitle}), detailsChanged: (details: ReviewTxState['details']) => dispatch({type: ReviewTxActionType.DetailsChanged, details}), - onSuccessChanged: (onSuccess: ReviewTxState['onSuccess']) => - dispatch({type: ReviewTxActionType.OnSuccessChanged, onSuccess}), - onErrorChanged: (onError: ReviewTxState['onError']) => dispatch({type: ReviewTxActionType.OnErrorChanged, onError}), - onNotSupportedCIP1694Changed: (onNotSupportedCIP1694: ReviewTxState['onNotSupportedCIP1694']) => - dispatch({type: ReviewTxActionType.OnNotSupportedCIP1694Changed, onNotSupportedCIP1694}), - onCIP36SupportChangeChanged: (onCIP36SupportChange: ReviewTxState['onCIP36SupportChange']) => - dispatch({type: ReviewTxActionType.OnCIP36SupportChangeChanged, onCIP36SupportChange}), reset: () => dispatch({type: ReviewTxActionType.Reset}), }).current @@ -71,32 +64,12 @@ const reviewTxReducer = (state: ReviewTxState, action: ReviewTxAction) => { draft.details = action.details break - case ReviewTxActionType.OnSuccessChanged: - draft.onSuccess = action.onSuccess - break - - case ReviewTxActionType.OnErrorChanged: - draft.onError = action.onError - break - - case ReviewTxActionType.OnNotSupportedCIP1694Changed: - draft.onNotSupportedCIP1694 = action.onNotSupportedCIP1694 - break - - case ReviewTxActionType.OnCIP36SupportChangeChanged: - draft.onCIP36SupportChange = action.onCIP36SupportChange - break - case ReviewTxActionType.Reset: draft.unsignedTx = castDraft(defaultState.unsignedTx) draft.cbor = defaultState.cbor draft.operations = defaultState.operations draft.customReceiverTitle = defaultState.customReceiverTitle draft.details = defaultState.details - draft.onSuccess = defaultState.onSuccess - draft.onError = defaultState.onError - draft.onNotSupportedCIP1694 = defaultState.onNotSupportedCIP1694 - draft.onCIP36SupportChange = defaultState.onCIP36SupportChange break default: @@ -126,22 +99,6 @@ type ReviewTxAction = type: ReviewTxActionType.DetailsChanged details: ReviewTxState['details'] } - | { - type: ReviewTxActionType.OnSuccessChanged - onSuccess: ReviewTxState['onSuccess'] - } - | { - type: ReviewTxActionType.OnErrorChanged - onError: ReviewTxState['onError'] - } - | { - type: ReviewTxActionType.OnNotSupportedCIP1694Changed - onNotSupportedCIP1694: ReviewTxState['onNotSupportedCIP1694'] - } - | { - type: ReviewTxActionType.OnCIP36SupportChangeChanged - onCIP36SupportChange: ReviewTxState['onCIP36SupportChange'] - } | { type: ReviewTxActionType.Reset } @@ -152,10 +109,6 @@ export type ReviewTxState = { operations: Array | null customReceiverTitle: React.ReactNode | null details: {title: string; component: React.ReactNode} | null - onSuccess: ((signedTx: YoroiSignedTx) => void) | null - onError: (() => void) | null - onNotSupportedCIP1694: (() => void) | null - onCIP36SupportChange: ((isCIP36Supported: boolean) => void) | null } type ReviewTxActions = { @@ -164,10 +117,6 @@ type ReviewTxActions = { operationsChanged: (operations: ReviewTxState['operations']) => void customReceiverTitleChanged: (customReceiverTitle: ReviewTxState['customReceiverTitle']) => void detailsChanged: (details: ReviewTxState['details']) => void - onSuccessChanged: (onSuccess: ReviewTxState['onSuccess']) => void - onErrorChanged: (onError: ReviewTxState['onError']) => void - onNotSupportedCIP1694Changed: (onNotSupportedCIP1694: ReviewTxState['onNotSupportedCIP1694']) => void - onCIP36SupportChangeChanged: (onCIP36SupportChange: ReviewTxState['onCIP36SupportChange']) => void reset: () => void } @@ -177,10 +126,6 @@ const defaultState: ReviewTxState = Object.freeze({ operations: null, customReceiverTitle: null, details: null, - onSuccess: null, - onError: null, - onNotSupportedCIP1694: null, - onCIP36SupportChange: null, }) function missingInit() { @@ -194,10 +139,6 @@ const initialReviewTxContext: ReviewTxContext = { operationsChanged: missingInit, customReceiverTitleChanged: missingInit, detailsChanged: missingInit, - onSuccessChanged: missingInit, - onErrorChanged: missingInit, - onNotSupportedCIP1694Changed: missingInit, - onCIP36SupportChangeChanged: missingInit, reset: missingInit, } @@ -207,10 +148,6 @@ enum ReviewTxActionType { OperationsChanged = 'operationsChanged', CustomReceiverTitleChanged = 'customReceiverTitleChanged', DetailsChanged = 'detailsChanged', - OnSuccessChanged = 'onSuccessChanged', - OnErrorChanged = 'onErrorChanged', - OnNotSupportedCIP1694Changed = 'onNotSupportedCIP1694Changed', - OnCIP36SupportChangeChanged = 'onCIP36SupportChangeChanged', Reset = 'reset', } diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useNavigateTo.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useNavigateTo.tsx new file mode 100644 index 0000000000..f5a15da331 --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useNavigateTo.tsx @@ -0,0 +1,13 @@ +import {NavigationProp, useNavigation} from '@react-navigation/native' +import * as React from 'react' + +import {ReviewTxRoutes} from '../../../../kernel/navigation' + +export const useNavigateTo = () => { + const navigation = useNavigation>() + + return React.useRef({ + showSubmittedTxScreen: () => navigation.navigate('review-tx-submitted-tx'), + showFailedTxScreen: () => navigation.navigate('review-tx-failed-tx'), + } as const).current +} diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useOnConfirm.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useOnConfirm.tsx index 129affceba..35e6a4bf9c 100644 --- a/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useOnConfirm.tsx +++ b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useOnConfirm.tsx @@ -6,7 +6,7 @@ import {ConfirmTxWithSpendingPasswordModal} from '../../../../components/Confirm import {useModal} from '../../../../components/Modal/ModalContext' import {YoroiSignedTx, YoroiUnsignedTx} from '../../../../yoroi-wallets/types/yoroi' import {useSelectedWallet} from '../../../WalletManager/common/hooks/useSelectedWallet' -import {useReviewTx} from '../ReviewTxProvider' +import {useNavigateTo} from './useNavigateTo' import {useStrings} from './useStrings' // TODO: make it compatible with CBOR signing @@ -29,15 +29,15 @@ export const useOnConfirm = ({ const {meta} = useSelectedWallet() const {openModal, closeModal} = useModal() const strings = useStrings() - const {reset} = useReviewTx() + const navigateTo = useNavigateTo() const handleOnSuccess = (signedTx: YoroiSignedTx) => { onSuccess?.(signedTx) - reset() + navigateTo.showSubmittedTxScreen() } const handleOnError = () => { onError?.() - reset() + navigateTo.showFailedTxScreen() } const onConfirm = () => { diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useSignTx.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useSignTx.tsx new file mode 100644 index 0000000000..87f0a7c15b --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useSignTx.tsx @@ -0,0 +1,69 @@ +import {TransactionWitnessSet} from '@emurgo/cross-csl-core' +import {WalletMeta} from '@yoroi/types/lib/typescript/wallet/meta' +import * as React from 'react' + +import {useModal} from '../../../../components/Modal/ModalContext' +import {cip30ExtensionMaker} from '../../../../yoroi-wallets/cardano/cip30/cip30' +import {YoroiWallet} from '../../../../yoroi-wallets/cardano/types' +import {wrappedCsl} from '../../../../yoroi-wallets/cardano/wrappedCsl' +import {useStrings} from '../../../Discover/common/useStrings' +import {ConfirmRawTxWithOs} from '../../../Swap/common/ConfirmRawTx/ConfirmRawTxWithOs' +import {ConfirmRawTxWithPassword} from '../../../Swap/common/ConfirmRawTx/ConfirmRawTxWithPassword' +import {useSelectedWallet} from '../../../WalletManager/common/hooks/useSelectedWallet' + +export const useSignTx = () => { + const {openModal, closeModal} = useModal() + const {meta, wallet} = useSelectedWallet() + const strings = useStrings() + const modalHeight = 350 + + return React.useCallback( + (cbor: string) => { + const handleOnConfirm = async (rootKey: string) => { + console.log('signTx-123') + const witnesses = await signTx(Buffer.from(cbor).toString('hex'), rootKey, wallet, meta) + + if (!witnesses) throw new Error('kdkdkdk') + await submitTx(cbor, witnesses, wallet) + closeModal() + return + } + + if (meta.isHW) { + throw new Error('Not implemented yet') + } + + if (meta.isEasyConfirmationEnabled) { + openModal(strings.confirmTx, , modalHeight) + return + } + + openModal(strings.confirmTx, , modalHeight) + }, + [meta, openModal, strings.confirmTx, wallet, closeModal], + ) +} + +export const signTx = async (cbor: string, rootKey: string, wallet: YoroiWallet, meta: WalletMeta) => { + const cip30 = cip30ExtensionMaker(wallet, meta) + try { + return cip30.signTx(rootKey, cbor, false) + } catch { + console.log('smksksksksk') + } +} + +export const submitTx = async (cbor: string, witnesses: TransactionWitnessSet, wallet: YoroiWallet) => { + const {csl, release} = wrappedCsl() + + try { + const tx = await csl.Transaction.fromHex(cbor) + const txBody = await tx.body() + const signedTx = await csl.Transaction.new(txBody, witnesses, undefined) + const signedTxBytes = await signedTx.toBytes() + + await wallet.submitTransaction(Buffer.from(signedTxBytes).toString('base64')) + } finally { + release() + } +} diff --git a/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useStrings.tsx b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useStrings.tsx index 59311a3c62..81fa9da683 100644 --- a/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useStrings.tsx +++ b/apps/wallet-mobile/src/features/ReviewTx/common/hooks/useStrings.tsx @@ -43,6 +43,12 @@ export const useStrings = () => { deregisterStakingKey: intl.formatMessage(messages.deregisterStakingKey), rewardsWithdrawalLabel: intl.formatMessage(messages.rewardsWithdrawalLabel), rewardsWithdrawalText: intl.formatMessage(messages.rewardsWithdrawalText), + submittedTxTitle: intl.formatMessage(messages.submittedTxTitle), + submittedTxText: intl.formatMessage(messages.submittedTxText), + submittedTxButton: intl.formatMessage(messages.submittedTxButton), + failedTxTitle: intl.formatMessage(messages.failedTxTitle), + failedTxText: intl.formatMessage(messages.failedTxText), + failedTxButton: intl.formatMessage(messages.failedTxButton), } } @@ -191,4 +197,28 @@ const messages = defineMessages({ id: 'txReview.operations.delegateStake', defaultMessage: '!!!Stake entire wallet balance to', }, + submittedTxTitle: { + id: 'txReview.submittedTxTitle', + defaultMessage: '!!!Transaction submitted', + }, + submittedTxText: { + id: 'txReview.submittedTxText', + defaultMessage: '!!!Check this transaction in the list of wallet transactions', + }, + submittedTxButton: { + id: 'txReview.submittedTxButton', + defaultMessage: '!!!Go to transactions', + }, + failedTxTitle: { + id: 'txReview.failedTxTitle', + defaultMessage: '!!!Transaction failed', + }, + failedTxText: { + id: 'txReview.failedTxText', + defaultMessage: '!!!Your transaction has not been processed properly due to technical issues', + }, + failedTxButton: { + id: 'txReview.failedTxButton', + defaultMessage: '!!!Go to transactions', + }, }) diff --git a/apps/wallet-mobile/src/features/ReviewTx/illustrations/FailedTxIcon.tsx b/apps/wallet-mobile/src/features/ReviewTx/illustrations/FailedTxIcon.tsx new file mode 100644 index 0000000000..48f7cf371c --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/illustrations/FailedTxIcon.tsx @@ -0,0 +1,46 @@ +import * as React from 'react' +import Svg, {Defs, G, LinearGradient, Path, Stop} from 'react-native-svg' + +export const FailedTxIcon = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/wallet-mobile/src/features/ReviewTx/illustrations/SuccessfulTxIcon.tsx b/apps/wallet-mobile/src/features/ReviewTx/illustrations/SuccessfulTxIcon.tsx new file mode 100644 index 0000000000..9729385194 --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/illustrations/SuccessfulTxIcon.tsx @@ -0,0 +1,79 @@ +import * as React from 'react' +import Svg, {Defs, LinearGradient, Path, Stop} from 'react-native-svg' + +export const SuccessfulTxIcon = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/wallet-mobile/src/features/ReviewTx/useCases/ReviewTxScreen/ReviewTxScreen.tsx b/apps/wallet-mobile/src/features/ReviewTx/useCases/ReviewTxScreen/ReviewTxScreen.tsx index 1b3344465d..1ab3d4d038 100644 --- a/apps/wallet-mobile/src/features/ReviewTx/useCases/ReviewTxScreen/ReviewTxScreen.tsx +++ b/apps/wallet-mobile/src/features/ReviewTx/useCases/ReviewTxScreen/ReviewTxScreen.tsx @@ -15,6 +15,7 @@ import { import {Button} from '../../../../components/Button/Button' import {SafeArea} from '../../../../components/SafeArea' import {ScrollView} from '../../../../components/ScrollView/ScrollView' +import {ReviewTxRoutes, useUnsafeParams} from '../../../../kernel/navigation' import {isEmptyString} from '../../../../kernel/utils' import {formatMetadata} from '../../common/hooks/useFormattedMetadata' import {useFormattedTx} from '../../common/hooks/useFormattedTx' @@ -31,17 +32,17 @@ const MaterialTab = createMaterialTopTabNavigator() export const ReviewTxScreen = () => { const {styles} = useStyles() const strings = useStrings() - const {unsignedTx, operations, details, onSuccess, onError, onNotSupportedCIP1694, onCIP36SupportChange} = - useReviewTx() + const {unsignedTx, operations, details} = useReviewTx() + const params = useUnsafeParams() if (unsignedTx === null) throw new Error('ReviewTxScreen: missing unsignedTx') const {onConfirm} = useOnConfirm({ unsignedTx, - onSuccess, - onError, - onNotSupportedCIP1694, - onCIP36SupportChange, + onSuccess: params?.onSuccess, + onError: params?.onError, + onNotSupportedCIP1694: params?.onNotSupportedCIP1694, + onCIP36SupportChange: params?.onCIP36SupportChange, }) // TODO: apply cbor diff --git a/apps/wallet-mobile/src/features/ReviewTx/useCases/ShowFailedTxScreen/FailedTxScreen.tsx b/apps/wallet-mobile/src/features/ReviewTx/useCases/ShowFailedTxScreen/FailedTxScreen.tsx new file mode 100644 index 0000000000..38fc137ff9 --- /dev/null +++ b/apps/wallet-mobile/src/features/ReviewTx/useCases/ShowFailedTxScreen/FailedTxScreen.tsx @@ -0,0 +1,75 @@ +import {useTheme} from '@yoroi/theme' +import React from 'react' +import {StyleSheet, Text, View} from 'react-native' + +import {Button} from '../../../../components/Button/Button' +import {SafeArea} from '../../../../components/SafeArea' +import {Space} from '../../../../components/Space/Space' +import {Spacer} from '../../../../components/Spacer/Spacer' +import {useBlockGoBack, useWalletNavigation} from '../../../../kernel/navigation' +import {useStrings} from '../../common/hooks/useStrings' +import {FailedTxIcon} from '../../illustrations/FailedTxIcon' + +export const FailedTxScreen = () => { + useBlockGoBack() + const strings = useStrings() + const {styles} = useStyles() + const {resetToTxHistory} = useWalletNavigation() + + return ( + + + + + + + + {strings.failedTxTitle} + + {strings.failedTxText} + + + + +