diff --git a/.env.base b/.env.base index 44d2a7252c3..aab9475c90f 100644 --- a/.env.base +++ b/.env.base @@ -20,7 +20,6 @@ REACT_APP_FEATURE_WHEREVER=true REACT_APP_FEATURE_YAT=true REACT_APP_FEATURE_NFT_METADATA=false REACT_APP_FEATURE_CHATWOOT=false -REACT_APP_FEATURE_COINBASE_WALLET=true REACT_APP_FEATURE_ADVANCED_SLIPPAGE=true REACT_APP_FEATURE_LEDGER_WALLET=true REACT_APP_FEATURE_WALLET_CONNECT_V2=true @@ -170,8 +169,6 @@ REACT_APP_SNAP_ID=npm:@shapeshiftoss/metamask-snaps REACT_APP_SNAP_VERSION=1.0.9 # REACT_APP_SNAP_ID=local:http://localhost:9000 -REACT_APP_EXPERIMENTAL_MM_SNAPPY_FINGERS=true - # Experemental features (not production ready) REACT_APP_EXPERIMENTAL_CUSTOM_SEND_NONCE=false diff --git a/.env.develop b/.env.develop index fec09cb3922..af8a2bb3196 100644 --- a/.env.develop +++ b/.env.develop @@ -1,4 +1,5 @@ # feature flags +REACT_APP_FEATURE_LIMIT_ORDERS=true # mixpanel REACT_APP_MIXPANEL_TOKEN=1c1369f6ea23a6404bac41b42817cc4b diff --git a/package.json b/package.json index 3fdef3c3966..56286126628 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,6 @@ "@ledgerhq/hw-transport-webusb": "^6.29.2", "@lifi/sdk": "^3.1.5", "@lukemorales/query-key-factory": "^1.3.4", - "@metamask/detect-provider": "^2.0.0", "@react-spring/web": "^9.7.4", "@reduxjs/toolkit": "^1.9.7", "@sentry-internal/browser-utils": "^8.26.0", @@ -93,20 +92,18 @@ "@shapeshiftoss/caip": "workspace:^", "@shapeshiftoss/chain-adapters": "workspace:^", "@shapeshiftoss/errors": "workspace:^", - "@shapeshiftoss/hdwallet-coinbase": "1.55.9", - "@shapeshiftoss/hdwallet-core": "1.55.9", - "@shapeshiftoss/hdwallet-keepkey": "1.55.9", - "@shapeshiftoss/hdwallet-keepkey-webusb": "1.55.9", - "@shapeshiftoss/hdwallet-keplr": "1.55.9", - "@shapeshiftoss/hdwallet-ledger": "1.55.9", - "@shapeshiftoss/hdwallet-ledger-webusb": "1.55.9", - "@shapeshiftoss/hdwallet-metamask": "1.55.9", - "@shapeshiftoss/hdwallet-native": "1.55.9", - "@shapeshiftoss/hdwallet-native-vault": "1.55.9", - "@shapeshiftoss/hdwallet-phantom": "1.55.9", - "@shapeshiftoss/hdwallet-shapeshift-multichain": "1.55.9", - "@shapeshiftoss/hdwallet-walletconnectv2": "1.55.9", - "@shapeshiftoss/hdwallet-xdefi": "1.55.9", + "@shapeshiftoss/hdwallet-coinbase": "1.55.11-mipd.5", + "@shapeshiftoss/hdwallet-core": "1.55.11-mipd.5", + "@shapeshiftoss/hdwallet-keepkey": "1.55.11-mipd.5", + "@shapeshiftoss/hdwallet-keepkey-webusb": "1.55.11-mipd.5", + "@shapeshiftoss/hdwallet-keplr": "1.55.11-mipd.5", + "@shapeshiftoss/hdwallet-ledger": "1.55.11-mipd.5", + "@shapeshiftoss/hdwallet-ledger-webusb": "1.55.11-mipd.5", + "@shapeshiftoss/hdwallet-metamask-multichain": "1.55.11-mipd.5", + "@shapeshiftoss/hdwallet-native": "1.55.11-mipd.5", + "@shapeshiftoss/hdwallet-native-vault": "1.55.11-mipd.5", + "@shapeshiftoss/hdwallet-phantom": "1.55.11-mipd.5", + "@shapeshiftoss/hdwallet-walletconnectv2": "1.55.11-mipd.5", "@shapeshiftoss/swapper": "workspace:^", "@shapeshiftoss/types": "workspace:^", "@shapeshiftoss/unchained-client": "workspace:^", @@ -159,6 +156,7 @@ "localforage": "^1.10.0", "lodash": "^4.17.21", "match-sorter": "^6.3.0", + "mipd": "^0.0.7", "mixpanel-browser": "^2.45.0", "myzod": "^1.10.1", "node-polyglot": "^2.4.0", diff --git a/packages/chain-adapters/package.json b/packages/chain-adapters/package.json index 226578dcef8..65e5a6d6e43 100644 --- a/packages/chain-adapters/package.json +++ b/packages/chain-adapters/package.json @@ -21,6 +21,7 @@ "@shapeshiftoss/types": "workspace:^", "@shapeshiftoss/unchained-client": "workspace:^", "@shapeshiftoss/utils": "workspace:^", + "@solana/web3.js": "^1.95.3", "bech32": "^2.0.0", "coinselect": "^3.1.13", "multicoin-address-validator": "^0.5.12", diff --git a/packages/chain-adapters/src/index.ts b/packages/chain-adapters/src/index.ts index 58f466a97bd..f6399218305 100644 --- a/packages/chain-adapters/src/index.ts +++ b/packages/chain-adapters/src/index.ts @@ -5,3 +5,4 @@ export * from './types' export * from './evm' export * from './utxo' export * from './cosmossdk' +export * as solana from './solana' diff --git a/packages/chain-adapters/src/solana/SolanaChainAdapter.ts b/packages/chain-adapters/src/solana/SolanaChainAdapter.ts new file mode 100644 index 00000000000..e4ee69389de --- /dev/null +++ b/packages/chain-adapters/src/solana/SolanaChainAdapter.ts @@ -0,0 +1,360 @@ +import type { AssetId, ChainId } from '@shapeshiftoss/caip' +import { + ASSET_NAMESPACE, + ASSET_REFERENCE, + solanaChainId, + solAssetId, + toAssetId, +} from '@shapeshiftoss/caip' +import type { SolanaSignTx } from '@shapeshiftoss/hdwallet-core' +import { supportsSolana } from '@shapeshiftoss/hdwallet-core' +import type { BIP44Params } from '@shapeshiftoss/types' +import { KnownChainIds } from '@shapeshiftoss/types' +import * as unchained from '@shapeshiftoss/unchained-client' +import { bn } from '@shapeshiftoss/utils' +import { Connection, PublicKey } from '@solana/web3.js' +import PQueue from 'p-queue' + +import type { ChainAdapter as IChainAdapter } from '../api' +import { ErrorHandler } from '../error/ErrorHandler' +import type { + Account, + BroadcastTransactionInput, + BuildSendTxInput, + FeeDataEstimate, + GetAddressInput, + GetBIP44ParamsInput, + GetFeeDataInput, + SignAndBroadcastTransactionInput, + SignTx, + SignTxInput, + SubscribeError, + SubscribeTxsInput, + Transaction, + TxHistoryInput, + TxHistoryResponse, + ValidAddressResult, +} from '../types' +import { ChainAdapterDisplayName, CONTRACT_INTERACTION, ValidAddressResultType } from '../types' +import { toAddressNList, toRootDerivationPath } from '../utils' +import { assertAddressNotSanctioned } from '../utils/validateAddress' +import { microLamportsToLamports } from './utils' + +export interface ChainAdapterArgs { + providers: { + http: unchained.solana.Api + ws: unchained.ws.Client + } + rpcUrl: string +} + +export class ChainAdapter implements IChainAdapter { + static readonly defaultBIP44Params: BIP44Params = { + purpose: 44, + coinType: Number(ASSET_REFERENCE.Solana), + accountNumber: 0, + } + + protected readonly chainId = solanaChainId + protected readonly assetId = solAssetId + + protected readonly providers: { + http: unchained.solana.Api + ws: unchained.ws.Client + } + + protected connection: Connection + protected parser: unchained.solana.TransactionParser + + constructor(args: ChainAdapterArgs) { + this.providers = args.providers + + this.connection = new Connection(args.rpcUrl) + + this.parser = new unchained.solana.TransactionParser({ + assetId: this.assetId, + chainId: this.chainId, + }) + } + + getName() { + const enumIndex = Object.values(ChainAdapterDisplayName).indexOf(ChainAdapterDisplayName.Solana) + return Object.keys(ChainAdapterDisplayName)[enumIndex] + } + + getDisplayName() { + return ChainAdapterDisplayName.Solana + } + + getType(): KnownChainIds.SolanaMainnet { + return KnownChainIds.SolanaMainnet + } + + getFeeAssetId(): AssetId { + return this.assetId + } + + getChainId(): ChainId { + return this.chainId + } + + getBIP44Params({ accountNumber }: GetBIP44ParamsInput): BIP44Params { + if (accountNumber < 0) { + throw new Error('accountNumber must be >= 0') + } + return { ...ChainAdapter.defaultBIP44Params, accountNumber } + } + + async getAddress(input: GetAddressInput): Promise { + try { + const { accountNumber, pubKey, wallet, showOnDevice = false } = input + + if (pubKey) return pubKey + + if (!supportsSolana(wallet)) throw new Error('Wallet does not support Solana.') + + const address = await wallet.solanaGetAddress({ + addressNList: toAddressNList(this.getBIP44Params({ accountNumber })), + showDisplay: showOnDevice, + }) + + if (!address) throw new Error('Unable to generate Solana address.') + + return address + } catch (err) { + return ErrorHandler(err) + } + } + + async getAccount(pubkey: string): Promise> { + try { + const data = await this.providers.http.getAccount({ pubkey }) + + const balance = BigInt(data.balance) + BigInt(data.unconfirmedBalance) + + return { + balance: balance.toString(), + chainId: this.chainId, + assetId: this.assetId, + chain: this.getType(), + chainSpecific: { + tokens: data.tokens.map(token => ({ + assetId: toAssetId({ + chainId: this.chainId, + assetNamespace: ASSET_NAMESPACE.splToken, + assetReference: token.id, + }), + balance: token.balance, + name: token.name, + precision: token.decimals, + symbol: token.symbol, + })), + }, + pubkey, + } + } catch (err) { + return ErrorHandler(err) + } + } + + async getTxHistory(input: TxHistoryInput): Promise { + const requestQueue = input.requestQueue ?? new PQueue() + + try { + const data = await requestQueue.add(() => + this.providers.http.getTxHistory({ + pubkey: input.pubkey, + pageSize: input.pageSize, + cursor: input.cursor, + }), + ) + + const txs = await Promise.all( + data.txs.map(tx => requestQueue.add(() => this.parseTx(tx, input.pubkey))), + ) + + return { + cursor: data.cursor ?? '', + pubkey: input.pubkey, + transactions: txs, + } + } catch (err) { + return ErrorHandler(err) + } + } + + async buildSendTransaction(input: BuildSendTxInput): Promise<{ + txToSign: SignTx + }> { + try { + const { accountNumber, to, value, chainSpecific } = input + + const { blockhash } = await this.connection.getLatestBlockhash() + + const computeUnitLimit = chainSpecific.computeUnitLimit + ? Number(chainSpecific.computeUnitLimit) + : undefined + + const computeUnitPrice = chainSpecific.computeUnitPrice + ? Number(chainSpecific.computeUnitPrice) + : undefined + + const txToSign: SignTx = { + addressNList: toAddressNList(this.getBIP44Params({ accountNumber })), + blockHash: blockhash, + computeUnitLimit, + computeUnitPrice, + // TODO: handle extra instructions + instructions: undefined, + to, + value, + } + + return { txToSign } + } catch (err) { + return ErrorHandler(err) + } + } + + async signTransaction(signTxInput: SignTxInput): Promise { + try { + const { txToSign, wallet } = signTxInput + + if (!supportsSolana(wallet)) throw new Error('Wallet does not support Solana.') + + const signedTx = await wallet.solanaSignTx(txToSign) + + if (!signedTx?.serialized) throw new Error('Error signing tx') + + return signedTx.serialized + } catch (err) { + return ErrorHandler(err) + } + } + + async signAndBroadcastTransaction({ + senderAddress, + receiverAddress, + signTxInput, + }: SignAndBroadcastTransactionInput): Promise { + try { + const { txToSign, wallet } = signTxInput + + await Promise.all([ + assertAddressNotSanctioned(senderAddress), + receiverAddress !== CONTRACT_INTERACTION && assertAddressNotSanctioned(receiverAddress), + ]) + + if (!supportsSolana(wallet)) throw new Error('Wallet does not support Solana.') + + const tx = await wallet.solanaSendTx?.(txToSign) + + if (!tx) throw new Error('Error signing & broadcasting tx') + + return tx.signature + } catch (err) { + return ErrorHandler(err) + } + } + + async broadcastTransaction({ + senderAddress, + receiverAddress, + hex, + }: BroadcastTransactionInput): Promise { + try { + await Promise.all([ + assertAddressNotSanctioned(senderAddress), + receiverAddress !== CONTRACT_INTERACTION && assertAddressNotSanctioned(receiverAddress), + ]) + + return this.providers.http.sendTx({ sendTxBody: { hex } }) + } catch (err) { + return ErrorHandler(err) + } + } + + async getFeeData( + input: GetFeeDataInput, + ): Promise> { + const { baseFee, fast, average, slow } = await this.providers.http.getPriorityFees() + + const computeUnits = await this.providers.http.estimateFees({ + estimateFeesBody: { message: input.chainSpecific?.message }, + }) + + return { + fast: { + txFee: bn(microLamportsToLamports(fast)).times(computeUnits).plus(baseFee).toFixed(), + chainSpecific: { computeUnits }, + }, + average: { + txFee: bn(microLamportsToLamports(average)).times(computeUnits).plus(baseFee).toFixed(), + chainSpecific: { computeUnits }, + }, + slow: { + txFee: bn(microLamportsToLamports(slow)).times(computeUnits).plus(baseFee).toFixed(), + chainSpecific: { computeUnits }, + }, + } + } + + // eslint-disable-next-line require-await + async validateAddress(address: string): Promise { + try { + new PublicKey(address) + return { valid: true, result: ValidAddressResultType.Valid } + } catch (err) { + return { valid: false, result: ValidAddressResultType.Invalid } + } + } + + async subscribeTxs( + input: SubscribeTxsInput, + onMessage: (msg: Transaction) => void, + onError: (err: SubscribeError) => void, + ): Promise { + const { pubKey, accountNumber, wallet } = input + + const bip44Params = this.getBIP44Params({ accountNumber }) + const address = await this.getAddress({ accountNumber, wallet, pubKey }) + const subscriptionId = toRootDerivationPath(bip44Params) + + await this.providers.ws.subscribeTxs( + subscriptionId, + { topic: 'txs', addresses: [address] }, + async msg => onMessage(await this.parseTx(msg.data, msg.address)), + err => onError({ message: err.message }), + ) + } + + unsubscribeTxs(input?: SubscribeTxsInput): void { + if (!input) return this.providers.ws.unsubscribeTxs() + + const { accountNumber } = input + const bip44Params = this.getBIP44Params({ accountNumber }) + const subscriptionId = toRootDerivationPath(bip44Params) + + this.providers.ws.unsubscribeTxs(subscriptionId, { topic: 'txs', addresses: [] }) + } + + closeTxs(): void { + this.providers.ws.close('txs') + } + + protected async parseTx(tx: unchained.solana.Tx, pubkey: string): Promise { + const { address: _, ...parsedTx } = await this.parser.parse(tx, pubkey) + + return { + ...parsedTx, + pubkey, + transfers: parsedTx.transfers.map(transfer => ({ + assetId: transfer.assetId, + from: [transfer.from], + to: [transfer.to], + type: transfer.type, + value: transfer.totalValue, + })), + } + } +} diff --git a/packages/chain-adapters/src/solana/index.ts b/packages/chain-adapters/src/solana/index.ts new file mode 100644 index 00000000000..79fd113c81a --- /dev/null +++ b/packages/chain-adapters/src/solana/index.ts @@ -0,0 +1,3 @@ +export { ChainAdapter } from './SolanaChainAdapter' + +export * from './types' diff --git a/packages/chain-adapters/src/solana/types.ts b/packages/chain-adapters/src/solana/types.ts new file mode 100644 index 00000000000..cc60abbd761 --- /dev/null +++ b/packages/chain-adapters/src/solana/types.ts @@ -0,0 +1,41 @@ +import type { SolanaTxInstruction } from '@shapeshiftoss/hdwallet-core' +import type { CosmosSdkChainId } from '@shapeshiftoss/types' + +import type * as types from '../types' + +export type Account = { + tokens?: Token[] +} + +export type Token = types.AssetBalance & { + symbol: string + name: string + precision: number +} + +export type BuildTransactionInput = { + account: types.Account + accountNumber: number + memo?: string +} & types.ChainSpecificBuildTxData + +export type BuildTxInput = { + computeUnitLimit?: string + computeUnitPrice?: string + instructions?: SolanaTxInstruction[] +} + +export type GetFeeDataInput = { + message: string +} + +export type FeeData = { + computeUnits: string +} + +export type PriorityFeeData = { + baseFee: string + [types.FeeDataKey.Fast]: string + [types.FeeDataKey.Average]: string + [types.FeeDataKey.Slow]: string +} diff --git a/packages/chain-adapters/src/solana/utils.ts b/packages/chain-adapters/src/solana/utils.ts new file mode 100644 index 00000000000..f8e6e1ed087 --- /dev/null +++ b/packages/chain-adapters/src/solana/utils.ts @@ -0,0 +1,5 @@ +import { bn, bnOrZero } from '@shapeshiftoss/utils' + +export const microLamportsToLamports = (microLamports: string): string => { + return bnOrZero(microLamports).div(bn(10).pow(6)).toFixed() +} diff --git a/packages/chain-adapters/src/types.ts b/packages/chain-adapters/src/types.ts index 29cc7a7e4fe..15158d40771 100644 --- a/packages/chain-adapters/src/types.ts +++ b/packages/chain-adapters/src/types.ts @@ -4,6 +4,7 @@ import type { CosmosSignTx, ETHSignTx, HDWallet, + SolanaSignTx, ThorchainSignTx, } from '@shapeshiftoss/hdwallet-core' import type { @@ -17,6 +18,7 @@ import type PQueue from 'p-queue' import type * as cosmossdk from './cosmossdk/types' import type * as evm from './evm/types' +import type * as solana from './solana/types' import type * as utxo from './utxo/types' // this placeholder forces us to be explicit about transactions not transferring funds to humans @@ -41,6 +43,7 @@ type ChainSpecificAccount = ChainSpecific< [KnownChainIds.LitecoinMainnet]: utxo.Account [KnownChainIds.CosmosMainnet]: cosmossdk.Account [KnownChainIds.ThorchainMainnet]: cosmossdk.Account + [KnownChainIds.SolanaMainnet]: solana.Account } > @@ -81,6 +84,7 @@ type ChainSpecificFeeData = ChainSpecific< [KnownChainIds.LitecoinMainnet]: utxo.FeeData [KnownChainIds.CosmosMainnet]: cosmossdk.FeeData [KnownChainIds.ThorchainMainnet]: cosmossdk.FeeData + [KnownChainIds.SolanaMainnet]: solana.FeeData } > @@ -154,6 +158,7 @@ export type ChainSignTx = { [KnownChainIds.LitecoinMainnet]: BTCSignTx [KnownChainIds.CosmosMainnet]: CosmosSignTx [KnownChainIds.ThorchainMainnet]: ThorchainSignTx + [KnownChainIds.SolanaMainnet]: SolanaSignTx } export type SignTx = T extends keyof ChainSignTx ? ChainSignTx[T] : never @@ -196,6 +201,7 @@ export type ChainSpecificBuildTxData = ChainSpecific< [KnownChainIds.LitecoinMainnet]: utxo.BuildTxInput [KnownChainIds.CosmosMainnet]: cosmossdk.BuildTxInput [KnownChainIds.ThorchainMainnet]: cosmossdk.BuildTxInput + [KnownChainIds.SolanaMainnet]: solana.BuildTxInput } > @@ -288,6 +294,7 @@ type ChainSpecificGetFeeDataInput = ChainSpecific< [KnownChainIds.BitcoinCashMainnet]: utxo.GetFeeDataInput [KnownChainIds.DogecoinMainnet]: utxo.GetFeeDataInput [KnownChainIds.LitecoinMainnet]: utxo.GetFeeDataInput + [KnownChainIds.SolanaMainnet]: solana.GetFeeDataInput } > export type GetFeeDataInput = { @@ -349,6 +356,7 @@ export enum ChainAdapterDisplayName { BitcoinCash = 'Bitcoin Cash', Dogecoin = 'Dogecoin', Litecoin = 'Litecoin', + Solana = 'Solana', } export type BroadcastTransactionInput = { diff --git a/packages/unchained-client/openapitools.json b/packages/unchained-client/openapitools.json index 3e66633fe22..a8062c8f840 100644 --- a/packages/unchained-client/openapitools.json +++ b/packages/unchained-client/openapitools.json @@ -187,6 +187,19 @@ "useSingleRequestParameter": true } }, + "solana": { + "inputSpec": "https://raw.githubusercontent.com/shapeshift/unchained/develop/node/coinstacks/solana/api/src/swagger.json", + "generatorName": "typescript-fetch", + "output": "#{cwd}/src/generated/solana", + "enablePostProcessFile": true, + "reservedWordsMappings": { + "in": "in" + }, + "additionalProperties": { + "supportsES6": "true", + "useSingleRequestParameter": true + } + }, "thorchain": { "inputSpec": "https://raw.githubusercontent.com/shapeshift/unchained/develop/go/coinstacks/thorchain/api/swagger.json", "generatorName": "typescript-fetch", diff --git a/packages/unchained-client/src/index.ts b/packages/unchained-client/src/index.ts index 7ea558079ae..185b08a6e48 100644 --- a/packages/unchained-client/src/index.ts +++ b/packages/unchained-client/src/index.ts @@ -5,6 +5,7 @@ export * as ws from './websocket' export * as evm from './evm' export * as utxo from './utxo' export * as cosmossdk from './cosmossdk' +export * as solana from './solana' export * as ethereum from './evm/ethereum' export * as avalanche from './evm/avalanche' diff --git a/packages/unchained-client/src/solana/index.ts b/packages/unchained-client/src/solana/index.ts new file mode 100644 index 00000000000..f498d679d1c --- /dev/null +++ b/packages/unchained-client/src/solana/index.ts @@ -0,0 +1,5 @@ +import type { V1Api } from '../generated/solana' + +export type Api = V1Api + +export * from './parser' diff --git a/packages/unchained-client/src/solana/parser/__tests__/mockData/solSelfSend.ts b/packages/unchained-client/src/solana/parser/__tests__/mockData/solSelfSend.ts new file mode 100644 index 00000000000..53da088c75f --- /dev/null +++ b/packages/unchained-client/src/solana/parser/__tests__/mockData/solSelfSend.ts @@ -0,0 +1,72 @@ +import type { Tx } from '../../..' + +const tx: Tx = { + txid: '3owXWn8Em7FE7Dyao3kPLkTPySiGGSSo9e7VGiWDifk6GfQRrm2JYHdHStBzVRr6b6o1PztbGpuDsXb8o2yPxoV3', + blockHeight: 293321352, + description: + 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV transferred 0.000000001 SOL to DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV.', + type: 'TRANSFER', + source: 'SYSTEM_PROGRAM', + fee: 25000, + feePayer: 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + signature: + '3owXWn8Em7FE7Dyao3kPLkTPySiGGSSo9e7VGiWDifk6GfQRrm2JYHdHStBzVRr6b6o1PztbGpuDsXb8o2yPxoV3', + slot: 293321352, + timestamp: 1727896282, + tokenTransfers: [], + nativeTransfers: [ + { + fromUserAccount: 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + toUserAccount: 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + amount: 1, + }, + ], + accountData: [ + { + account: 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + nativeBalanceChange: -25000, + tokenBalanceChanges: [], + }, + { + account: 'ComputeBudget111111111111111111111111111111', + nativeBalanceChange: 0, + tokenBalanceChanges: [], + }, + { + account: '11111111111111111111111111111111', + nativeBalanceChange: 0, + tokenBalanceChanges: [], + }, + ], + transactionError: null, + instructions: [ + { + accounts: [], + data: '3gJqkocMWaMm', + programId: 'ComputeBudget111111111111111111111111111111', + innerInstructions: [], + }, + { + accounts: [], + data: 'Fj2Eoy', + programId: 'ComputeBudget111111111111111111111111111111', + innerInstructions: [], + }, + { + accounts: [ + 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV', + ], + data: '3Bxs412MvVNQj175', + programId: '11111111111111111111111111111111', + innerInstructions: [], + }, + ], + events: { + compressed: null, + nft: null, + swap: null, + }, +} + +export default { tx } diff --git a/packages/unchained-client/src/solana/parser/__tests__/mockData/solStandard.ts b/packages/unchained-client/src/solana/parser/__tests__/mockData/solStandard.ts new file mode 100644 index 00000000000..6a2c59109a2 --- /dev/null +++ b/packages/unchained-client/src/solana/parser/__tests__/mockData/solStandard.ts @@ -0,0 +1,60 @@ +import type { Tx } from '../../..' + +const tx: Tx = { + txid: 'qN3jbqvw2ypfmTVJuUiohgLQgV4mq8oZ6QzuKhNeM8MX1bdAxCK7EoXJbvBUD61mhGmrFr1KQi5FqgcadfYi7CS', + blockHeight: 294850279, + description: + 'B1fnGVnz6Q2eZPXG1FPa8wix88yyNApwGhJTURHPh4qW transferred 0.010000388 SOL to DttWaMuVvTiduZRnguLF7jNxTgiMBZ1hyAumKUiL2KRL.', + type: 'TRANSFER', + source: 'SYSTEM_PROGRAM', + fee: 5000, + feePayer: 'B1fnGVnz6Q2eZPXG1FPa8wix88yyNApwGhJTURHPh4qW', + signature: + 'qN3jbqvw2ypfmTVJuUiohgLQgV4mq8oZ6QzuKhNeM8MX1bdAxCK7EoXJbvBUD61mhGmrFr1KQi5FqgcadfYi7CS', + slot: 294850279, + timestamp: 1728580091, + tokenTransfers: [], + nativeTransfers: [ + { + fromUserAccount: 'B1fnGVnz6Q2eZPXG1FPa8wix88yyNApwGhJTURHPh4qW', + toUserAccount: 'DttWaMuVvTiduZRnguLF7jNxTgiMBZ1hyAumKUiL2KRL', + amount: 10000388, + }, + ], + accountData: [ + { + account: 'B1fnGVnz6Q2eZPXG1FPa8wix88yyNApwGhJTURHPh4qW', + nativeBalanceChange: -10005388, + tokenBalanceChanges: [], + }, + { + account: 'DttWaMuVvTiduZRnguLF7jNxTgiMBZ1hyAumKUiL2KRL', + nativeBalanceChange: 10000388, + tokenBalanceChanges: [], + }, + { + account: '11111111111111111111111111111111', + nativeBalanceChange: 0, + tokenBalanceChanges: [], + }, + ], + transactionError: null, + instructions: [ + { + accounts: [ + 'B1fnGVnz6Q2eZPXG1FPa8wix88yyNApwGhJTURHPh4qW', + 'DttWaMuVvTiduZRnguLF7jNxTgiMBZ1hyAumKUiL2KRL', + ], + data: '3Bxs41dFLGCCYtUF', + programId: '11111111111111111111111111111111', + innerInstructions: [], + }, + ], + events: { + compressed: null, + nft: null, + swap: null, + }, +} + +export default { tx } diff --git a/packages/unchained-client/src/solana/parser/__tests__/solana.test.ts b/packages/unchained-client/src/solana/parser/__tests__/solana.test.ts new file mode 100644 index 00000000000..d6a5eb71511 --- /dev/null +++ b/packages/unchained-client/src/solana/parser/__tests__/solana.test.ts @@ -0,0 +1,126 @@ +import { solanaChainId, solAssetId } from '@shapeshiftoss/caip' +import { beforeAll, describe, expect, it, vi } from 'vitest' + +import { TransferType, TxStatus } from '../../../types' +import type { ParsedTx } from '../../parser' +import { TransactionParser } from '../index' +import solSelfSend from './mockData/solSelfSend' +import solStandard from './mockData/solStandard' + +const txParser = new TransactionParser({ assetId: solAssetId, chainId: solanaChainId }) + +describe('parseTx', () => { + beforeAll(() => { + vi.clearAllMocks() + }) + + describe('standard', () => { + it('should be able to parse sol send', async () => { + const { tx } = solStandard + const address = 'B1fnGVnz6Q2eZPXG1FPa8wix88yyNApwGhJTURHPh4qW' + + const expected: ParsedTx = { + address, + blockHash: tx.blockHash, + blockHeight: tx.blockHeight, + blockTime: tx.timestamp, + chainId: solanaChainId, + confirmations: 1, + fee: { + assetId: solAssetId, + value: '5000', + }, + status: TxStatus.Confirmed, + transfers: [ + { + assetId: solAssetId, + components: [{ value: '10000388' }], + from: address, + to: 'DttWaMuVvTiduZRnguLF7jNxTgiMBZ1hyAumKUiL2KRL', + totalValue: '10000388', + type: TransferType.Send, + }, + ], + txid: tx.txid, + } + + const actual = await txParser.parse(tx, address) + + expect(actual).toEqual(expected) + }) + + it('should be able to parse sol receive', async () => { + const { tx } = solStandard + const address = 'DttWaMuVvTiduZRnguLF7jNxTgiMBZ1hyAumKUiL2KRL' + + const expected: ParsedTx = { + address, + blockHash: tx.blockHash, + blockHeight: tx.blockHeight, + blockTime: tx.timestamp, + chainId: solanaChainId, + confirmations: 1, + status: TxStatus.Confirmed, + transfers: [ + { + assetId: solAssetId, + components: [{ value: '10000388' }], + from: 'B1fnGVnz6Q2eZPXG1FPa8wix88yyNApwGhJTURHPh4qW', + to: address, + totalValue: '10000388', + type: TransferType.Receive, + }, + ], + txid: tx.txid, + } + + const actual = await txParser.parse(tx, address) + + expect(actual).toEqual(expected) + }) + }) + + describe('self send', () => { + it('should be able to parse sol', async () => { + const { tx } = solSelfSend + const address = 'DsYwEVzeSNMkU5PVwjwtZ8EDRQxaR6paXfFAdhMQxmaV' + + const expected: ParsedTx = { + txid: tx.txid, + blockHash: tx.blockHash, + blockHeight: tx.blockHeight, + blockTime: tx.timestamp, + address, + chainId: solanaChainId, + confirmations: 1, + status: TxStatus.Confirmed, + fee: { + value: '25000', + assetId: solAssetId, + }, + transfers: [ + { + type: TransferType.Send, + from: address, + to: address, + assetId: solAssetId, + totalValue: '1', + components: [{ value: '1' }], + }, + { + type: TransferType.Receive, + from: address, + to: address, + assetId: solAssetId, + totalValue: '1', + components: [{ value: '1' }], + }, + ], + } + + const actual = await txParser.parse(tx, address) + + expect(actual).toEqual(expected) + }) + }) +}) diff --git a/packages/unchained-client/src/solana/parser/index.ts b/packages/unchained-client/src/solana/parser/index.ts new file mode 100644 index 00000000000..c514f2e5ba9 --- /dev/null +++ b/packages/unchained-client/src/solana/parser/index.ts @@ -0,0 +1,99 @@ +import type { AssetId, ChainId } from '@shapeshiftoss/caip' + +import { TransferType, TxStatus } from '../../types' +import { aggregateTransfer } from '../../utils' +import type { ParsedTx, SubParser, Tx } from './types' + +export * from './types' + +export interface TransactionParserArgs { + chainId: ChainId + assetId: AssetId +} + +export class TransactionParser { + chainId: ChainId + assetId: AssetId + + private parsers: SubParser[] = [] + + constructor(args: TransactionParserArgs) { + this.chainId = args.chainId + this.assetId = args.assetId + } + + /** + * Register custom transaction sub parser to parse custom op return data + * + * _parsers should be registered from most generic first to most specific last_ + */ + registerParser(parser: SubParser): void { + this.parsers.unshift(parser) + } + + protected registerParsers(parsers: SubParser[]): void { + parsers.forEach(parser => this.registerParser(parser)) + } + + async parse(tx: T, address: string): Promise { + const parserResult = await (async () => { + for (const parser of this.parsers) { + const result = await parser.parse(tx, address) + if (result) return result + } + })() + + const parsedTx: ParsedTx = { + address, + blockHeight: tx.blockHeight, + blockTime: tx.timestamp, + chainId: this.chainId, + // all transactions from unchained are finalized with at least 1 confirmation (unused throughout web) + confirmations: 1, + status: this.getStatus(tx), + trade: parserResult?.trade, + transfers: parserResult?.transfers ?? [], + txid: tx.txid, + } + + // network fee + if (tx.feePayer === address && tx.fee) { + parsedTx.fee = { assetId: this.assetId, value: BigInt(tx.fee).toString() } + } + + tx.nativeTransfers?.forEach(nativeTransfer => { + const { amount, fromUserAccount, toUserAccount } = nativeTransfer + + // send amount + if (nativeTransfer.fromUserAccount === address) { + parsedTx.transfers = aggregateTransfer({ + assetId: this.assetId, + from: fromUserAccount ?? '', + to: toUserAccount ?? '', + transfers: parsedTx.transfers, + type: TransferType.Send, + value: BigInt(amount).toString(), + }) + } + + // receive amount + if (nativeTransfer.toUserAccount === address) { + parsedTx.transfers = aggregateTransfer({ + assetId: this.assetId, + from: fromUserAccount ?? '', + to: toUserAccount ?? '', + transfers: parsedTx.transfers, + type: TransferType.Receive, + value: BigInt(amount).toString(), + }) + } + }) + + return parsedTx + } + + private getStatus(tx: T): TxStatus { + if (tx.transactionError) return TxStatus.Failed + return TxStatus.Confirmed + } +} diff --git a/packages/unchained-client/src/solana/parser/types.ts b/packages/unchained-client/src/solana/parser/types.ts new file mode 100644 index 00000000000..de2b3774dc0 --- /dev/null +++ b/packages/unchained-client/src/solana/parser/types.ts @@ -0,0 +1,14 @@ +import type * as solana from '../../generated/solana' +import type { StandardTx } from '../../types' + +export * from '../../generated/solana' + +export type Tx = solana.Tx + +export interface ParsedTx extends StandardTx {} + +export type TxSpecific = Partial> + +export interface SubParser { + parse(tx: T, address: string): Promise +} diff --git a/public/manifest.json b/public/manifest.json index 5c7fd77e01e..4012564f93f 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -7,6 +7,7 @@ "name": "ShapeShift", "short_name": "ShapeShift", "description": "Your Web3 & DeFi Portal", + "iconPath": "/icon-512x512.png", "icons": [ { "src": "/icon-192x192.png", diff --git a/react-app-rewired/headers/csps/defi/safe.ts b/react-app-rewired/headers/csps/defi/safe.ts index a06a0226d76..5659e31f236 100644 --- a/react-app-rewired/headers/csps/defi/safe.ts +++ b/react-app-rewired/headers/csps/defi/safe.ts @@ -2,6 +2,7 @@ import type { Csp } from '../../types' export const csp: Csp = { 'connect-src': [ + 'https://app.safe.global', 'https://safe-transaction-mainnet.safe.global', 'https://safe-transaction-avalanche.safe.global', 'https://safe-transaction-optimism.safe.global', diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 453573f5fc3..574f468e964 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -987,6 +987,9 @@ "slippage": "Slippage: %{slippageFormatted}" } }, + "limitOrder": { + "heading": "Limit Order" + }, "modals": { "assetSearch": { "myAssets": "My Assets", @@ -1515,20 +1518,21 @@ "connectWarning": "Before connecting a chain, make sure you have the app open on your device.", "signWarning": "Before signing, make sure you have the %{chain} app open on your device." }, - "metaMask": { - "errors": { - "unknown": "An unexpected error occurred communicating with MetaMask", - "connectFailure": "Unable to connect MetaMask wallet", - "multipleWallets": "Detected Ethereum provider is not MetaMask. Do you have multiple wallets installed?" - }, + "mipd": { "connect": { - "header": "Pair MetaMask", - "body": "Click Pair and login to MetaMask from the popup window", + "header": "Pair %{name}", + "body": "Click Pair and login to %{name} from the popup window", "button": "Pair" }, - "failure": { - "body": "Unable to connect MetaMask wallet" + "errors": { + "unknown": "An unexpected error occurred communicating with %{name}", + "connectFailure": "Unable to connect %{name}" }, + "failure": { + "body": "Unable to connect %{name}" + } + }, + "metaMask": { "redirect": { "header": "Open in MetaMask App", "body": "Click to open ShapeShift dashboard in MetaMask", @@ -1617,26 +1621,6 @@ "body": "Unable to connect WalletConnect wallet" } }, - "xdefi": { - "errors": { - "unknown": "An unexpected error occurred communicating with XDEFI", - "connectFailure": "Unable to connect XDEFI wallet", - "multipleWallets": "Detected Ethereum provider is not XDEFI. Do you have multiple wallets installed and switched on? Prioritize XDEFI in settings and press pair again." - }, - "connect": { - "header": "Pair XDEFI", - "body": "Click Pair and login to XDEFI from the popup window", - "button": "Pair" - }, - "failure": { - "body": "Unable to connect XDEFI wallet" - }, - "redirect": { - "header": "Open in XDEFI App", - "body": "Click to open ShapeShift dashboard in XDEFI", - "button": "Open" - } - }, "shapeShift": { "load": { "error": { diff --git a/src/components/Acknowledgement/Acknowledgement.tsx b/src/components/Acknowledgement/Acknowledgement.tsx index 6a27d22fb3e..b039c7b54c2 100644 --- a/src/components/Acknowledgement/Acknowledgement.tsx +++ b/src/components/Acknowledgement/Acknowledgement.tsx @@ -1,87 +1,25 @@ -import type { ComponentWithAs, IconProps, ResponsiveValue, ThemeTypings } from '@chakra-ui/react' -import { Box, Button, Checkbox, Link, useColorModeValue } from '@chakra-ui/react' -import type * as CSS from 'csstype' -import type { AnimationDefinition, MotionStyle } from 'framer-motion' -import { AnimatePresence, motion } from 'framer-motion' +import type { ComponentWithAs, IconProps, ThemeTypings } from '@chakra-ui/react' +import { + Box, + Button, + Checkbox, + Link, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalOverlay, + useColorModeValue, +} from '@chakra-ui/react' import type { InterpolationOptions } from 'node-polyglot' -import type { PropsWithChildren } from 'react' -import React, { useCallback, useEffect, useMemo, useState } from 'react' +import React, { useCallback, useMemo, useState } from 'react' import { FiAlertTriangle } from 'react-icons/fi' import { useTranslate } from 'react-polyglot' import { StreamIcon } from 'components/Icons/Stream' import { RawText, Text } from 'components/Text' import { formatSecondsToDuration } from 'lib/utils/time' -const initialProps = { opacity: 0 } -const animateProps = { opacity: 1 } -const exitProps = { opacity: 0, transition: { duration: 0.5 } } -const transitionProps = { delay: 0.2, duration: 0.1 } -const motionStyle: MotionStyle = { - backgroundColor: 'var(--chakra-colors-blanket)', - position: 'absolute', - top: 0, - bottom: 0, - left: 0, - right: 0, - zIndex: 4, -} - -const AcknowledgementOverlay: React.FC = ({ children }) => { - return ( - - {children} - - ) -} - -const popoverVariants = { - initial: { - y: '100%', - }, - animate: { - y: 0, - transition: { - type: 'spring', - bounce: 0.2, - duration: 0.55, - }, - }, - exit: { - y: '100%', - opacity: 0, - transition: { - duration: 0.2, - }, - }, -} - -const popoverStyle: MotionStyle = { - backgroundColor: 'var(--chakra-colors-background-surface-overlay-base)', - position: 'absolute', - borderTopLeftRadius: 'var(--chakra-radii-2xl)', - borderTopRightRadius: 'var(--chakra-radii-2xl)', - bottom: 0, - left: 0, - right: 0, - zIndex: 5, - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - paddingLeft: '2rem', - paddingRight: '2rem', - paddingBottom: '2rem', - paddingTop: '4rem', -} - type AcknowledgementProps = { - children: React.ReactNode content?: JSX.Element message: string | JSX.Element onAcknowledge: (() => void) | undefined @@ -92,7 +30,6 @@ type AcknowledgementProps = { buttonTranslation?: string | [string, InterpolationOptions] icon?: ComponentWithAs<'svg', IconProps> disableButton?: boolean - position?: ResponsiveValue } type StreamingAcknowledgementProps = Omit & { @@ -101,10 +38,8 @@ type StreamingAcknowledgementProps = Omit & { type ArbitrumAcknowledgementProps = Omit const cancelHoverProps = { bg: 'rgba(255, 255, 255, 0.2)' } -const boxBorderRadius = { base: 'none', md: 'xl' } export const Acknowledgement = ({ - children, content, message, onAcknowledge, @@ -115,10 +50,8 @@ export const Acknowledgement = ({ buttonTranslation, disableButton, icon: CustomIcon, - position = 'relative', }: AcknowledgementProps) => { const translate = useTranslate() - const [isShowing, setIsShowing] = useState(false) const understandHoverProps = useMemo( () => ({ bg: `${buttonColorScheme}.600` }), @@ -136,87 +69,51 @@ export const Acknowledgement = ({ setShouldShowAcknowledgement(false) }, [setShouldShowAcknowledgement]) - const handleAnimationComplete = useCallback((def: AnimationDefinition) => { - if (def === 'exit') { - setIsShowing(false) - } - }, []) - - useEffect(() => { - // enters with overflow: hidden - // exit after animation complete return to overflow: visible - if (shouldShowAcknowledgement) { - setIsShowing(true) - } - }, [shouldShowAcknowledgement]) - return ( - - - {shouldShowAcknowledgement && ( - - - {CustomIcon ? ( - - ) : ( - - )} - - - {message} - {content} - - - - - - )} - - - {children} - + + + + + {CustomIcon ? ( + + ) : ( + + )} + + + {message} + {content} + + + + + + + + + ) } diff --git a/src/components/Icons/XDEFIIcon.tsx b/src/components/Icons/XDEFIIcon.tsx deleted file mode 100644 index 526b433c08f..00000000000 --- a/src/components/Icons/XDEFIIcon.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { createIcon } from '@chakra-ui/react' - -export const XDEFIIcon = createIcon({ - displayName: 'XDeFiIcon', - path: ( - - - - - - - ), - viewBox: '0 0 318.6 318.6', -}) diff --git a/src/components/Layout/Header/Header.tsx b/src/components/Layout/Header/Header.tsx index 13ce4da98d9..234c667cc67 100644 --- a/src/components/Layout/Header/Header.tsx +++ b/src/components/Layout/Header/Header.tsx @@ -1,7 +1,8 @@ import { InfoIcon } from '@chakra-ui/icons' import { Box, Flex, HStack, useMediaQuery, usePrevious, useToast } from '@chakra-ui/react' -import { btcAssetId } from '@shapeshiftoss/caip' -import { MetaMaskShapeShiftMultiChainHDWallet } from '@shapeshiftoss/hdwallet-shapeshift-multichain' +import { btcAssetId, fromAccountId } from '@shapeshiftoss/caip' +import { isEvmChainId } from '@shapeshiftoss/chain-adapters' +import { MetaMaskMultiChainHDWallet } from '@shapeshiftoss/hdwallet-metamask-multichain' import { useScroll } from 'framer-motion' import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslate } from 'react-polyglot' @@ -13,7 +14,8 @@ import { useFeatureFlag } from 'hooks/useFeatureFlag/useFeatureFlag' import { useIsSnapInstalled } from 'hooks/useIsSnapInstalled/useIsSnapInstalled' import { useModal } from 'hooks/useModal/useModal' import { useWallet } from 'hooks/useWallet/useWallet' -import { isUtxoAccountId } from 'lib/utils/utxo' +import { METAMASK_RDNS } from 'lib/mipd' +import { selectWalletRdns } from 'state/slices/localWalletSlice/selectors' import { portfolio } from 'state/slices/portfolioSlice/portfolioSlice' import { selectEnabledWalletAccountIds, @@ -21,7 +23,7 @@ import { selectShowSnapsModal, selectWalletId, } from 'state/slices/selectors' -import { useAppDispatch } from 'state/store' +import { useAppDispatch, useAppSelector } from 'state/store' import { breakpoints } from 'theme/theme' import { AppLoadingIcon } from './AppLoadingIcon' @@ -97,21 +99,23 @@ export const Header = memo(() => { [dispatch], ) - const currentWalletId = useSelector(selectWalletId) - const walletAccountIds = useSelector(selectEnabledWalletAccountIds) - const hasUtxoAccountIds = useMemo( - () => walletAccountIds.some(accountId => isUtxoAccountId(accountId)), + const connectedRdns = useAppSelector(selectWalletRdns) + const previousConnectedRdns = usePrevious(connectedRdns) + const currentWalletId = useAppSelector(selectWalletId) + const walletAccountIds = useAppSelector(selectEnabledWalletAccountIds) + const hasNonEvmAccountIds = useMemo( + () => walletAccountIds.some(accountId => !isEvmChainId(fromAccountId(accountId).chainId)), [walletAccountIds], ) useEffect(() => { - const isMetaMaskMultichainWallet = wallet instanceof MetaMaskShapeShiftMultiChainHDWallet + const isMetaMaskMultichainWallet = wallet instanceof MetaMaskMultiChainHDWallet if (!(currentWalletId && isMetaMaskMultichainWallet && isSnapInstalled === false)) return // We have just detected that the user doesn't have the snap installed currently // We need to check whether or not the user had previous non-EVM AccountIds and clear those - if (hasUtxoAccountIds) appDispatch(portfolio.actions.clearWalletMetadata(currentWalletId)) - }, [appDispatch, currentWalletId, hasUtxoAccountIds, isSnapInstalled, wallet, walletAccountIds]) + if (hasNonEvmAccountIds) appDispatch(portfolio.actions.clearWalletMetadata(currentWalletId)) + }, [appDispatch, currentWalletId, hasNonEvmAccountIds, isSnapInstalled, wallet, walletAccountIds]) useEffect(() => { if (!isCorrectVersion && isSnapInstalled) return @@ -122,18 +126,27 @@ export const Header = memo(() => { isSnapInstalled === false && previousIsCorrectVersion === true ) { - // they uninstalled the snap - toast({ - status: 'success', - title: translate('walletProvider.metaMaskSnap.snapUninstalledToast'), - position: 'bottom', - }) + if (previousConnectedRdns === METAMASK_RDNS && connectedRdns === METAMASK_RDNS) { + // they uninstalled the snap + toast({ + status: 'success', + title: translate('walletProvider.metaMaskSnap.snapUninstalledToast'), + position: 'bottom', + }) + } const walletId = currentWalletId if (!walletId) return appDispatch(portfolio.actions.clearWalletMetadata(walletId)) - return snapModal.open({ isRemoved: true }) + if (previousConnectedRdns === METAMASK_RDNS && connectedRdns === METAMASK_RDNS) { + return snapModal.open({ isRemoved: true }) + } } - if (previousSnapInstall === false && isSnapInstalled === true) { + if ( + previousSnapInstall === false && + isSnapInstalled === true && + previousConnectedRdns === METAMASK_RDNS && + connectedRdns === METAMASK_RDNS + ) { history.push(`/assets/${btcAssetId}`) // they installed the snap @@ -146,11 +159,13 @@ export const Header = memo(() => { } }, [ appDispatch, + connectedRdns, currentWalletId, dispatch, history, isCorrectVersion, isSnapInstalled, + previousConnectedRdns, previousIsCorrectVersion, previousSnapInstall, showSnapModal, diff --git a/src/components/Layout/Header/NavBar/KeepKey/ChangePassphrase.tsx b/src/components/Layout/Header/NavBar/KeepKey/ChangePassphrase.tsx index f2d440a05a7..790b317382d 100644 --- a/src/components/Layout/Header/NavBar/KeepKey/ChangePassphrase.tsx +++ b/src/components/Layout/Header/NavBar/KeepKey/ChangePassphrase.tsx @@ -72,7 +72,7 @@ export const ChangePassphrase = () => { // Trigger a refresh of the wallet metadata only once the settings have been applied // and the previous wallet meta is gone from the store dispatch({ type: WalletActions.SET_WALLET_MODAL, payload: true }) - connect(KeyManager.KeepKey) + connect(KeyManager.KeepKey, false) }, [ appDispatch, connect, diff --git a/src/components/Layout/Header/NavBar/UserMenu.tsx b/src/components/Layout/Header/NavBar/UserMenu.tsx index 158a0f12055..89966538096 100644 --- a/src/components/Layout/Header/NavBar/UserMenu.tsx +++ b/src/components/Layout/Header/NavBar/UserMenu.tsx @@ -1,4 +1,5 @@ import { ChevronDownIcon, WarningTwoIcon } from '@chakra-ui/icons' +import type { ComponentWithAs, IconProps } from '@chakra-ui/react' import { Box, Button, @@ -27,6 +28,9 @@ import { RawText, Text } from 'components/Text' import { WalletActions } from 'context/WalletProvider/actions' import type { InitialState } from 'context/WalletProvider/WalletProvider' import { useWallet } from 'hooks/useWallet/useWallet' +import { useMipdProviders } from 'lib/mipd' +import { selectWalletRdns } from 'state/slices/localWalletSlice/selectors' +import { useAppSelector } from 'state/store' export const entries = [WalletConnectedRoutes.Connected] @@ -50,7 +54,11 @@ export type WalletConnectedProps = { onDisconnect: () => void onSwitchProvider: () => void onClose?: () => void -} & Pick + walletInfo: { + icon: ComponentWithAs<'svg', IconProps> | string + name: string + } | null +} & Pick export const WalletConnected = (props: WalletConnectedProps) => { return ( @@ -86,6 +94,14 @@ const WalletButton: FC = ({ const bgColor = useColorModeValue('gray.200', 'gray.800') const [ensName, setEnsName] = useState('') + const maybeRdns = useAppSelector(selectWalletRdns) + + const mipdProviders = useMipdProviders() + const maybeMipdProvider = useMemo( + () => mipdProviders.find(provider => provider.info.rdns === maybeRdns), + [mipdProviders, maybeRdns], + ) + useEffect(() => { if (!walletInfo?.meta?.address) return viemEthMainnetClient @@ -118,10 +134,10 @@ const WalletButton: FC = ({ () => ( {!(isConnected || isDemoWallet) && } - + ), - [isConnected, isDemoWallet, walletInfo], + [isConnected, isDemoWallet, maybeMipdProvider, walletInfo], ) const connectIcon = useMemo(() => , []) @@ -162,6 +178,11 @@ export const UserMenu: React.FC<{ onClick?: () => void }> = memo(({ onClick }) = const { isConnected, isDemoWallet, walletInfo, connectedType, isLocked, isLoadingLocalWallet } = state + const maybeRdns = useAppSelector(selectWalletRdns) + + const mipdProviders = useMipdProviders() + const maybeMipdProvider = mipdProviders.find(provider => provider.info.rdns === maybeRdns) + const hasWallet = Boolean(walletInfo?.deviceId) const handleConnect = useCallback(() => { onClick && onClick() @@ -189,7 +210,7 @@ export const UserMenu: React.FC<{ onClick?: () => void }> = memo(({ onClick }) = {hasWallet || isLoadingLocalWallet ? ( +type WalletImageProps = { + walletInfo: { + icon: ComponentWithAs<'svg', IconProps> | string + } | null +} export const WalletImage = ({ walletInfo }: WalletImageProps) => { - const Icon = walletInfo?.icon - if (Icon) { - return - } - return null + if (!walletInfo) return null + if (typeof walletInfo.icon === 'string') + return Wallet Icon + const Icon = walletInfo.icon + return } diff --git a/src/components/ManageAccountsDrawer/components/ImportAccounts.tsx b/src/components/ManageAccountsDrawer/components/ImportAccounts.tsx index 10e063e03d3..8e683a3961c 100644 --- a/src/components/ManageAccountsDrawer/components/ImportAccounts.tsx +++ b/src/components/ManageAccountsDrawer/components/ImportAccounts.tsx @@ -13,7 +13,7 @@ import { import type { ChainId } from '@shapeshiftoss/caip' import { type AccountId, fromAccountId } from '@shapeshiftoss/caip' import { isLedger } from '@shapeshiftoss/hdwallet-ledger' -import { MetaMaskShapeShiftMultiChainHDWallet } from '@shapeshiftoss/hdwallet-shapeshift-multichain' +import { MetaMaskMultiChainHDWallet } from '@shapeshiftoss/hdwallet-metamask-multichain' import type { Asset } from '@shapeshiftoss/types' import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query' import { useCallback, useEffect, useMemo, useState } from 'react' @@ -172,10 +172,10 @@ export const ImportAccounts = ({ chainId, onClose }: ImportAccountsProps) => { dispatch: walletDispatch, } = useWallet() const asset = useAppSelector(state => selectFeeAssetByChainId(state, chainId)) - const isSnapInstalled = useIsSnapInstalled() + const { isSnapInstalled } = useIsSnapInstalled() const isLedgerWallet = useMemo(() => wallet && isLedger(wallet), [wallet]) const isMetaMaskMultichainWallet = useMemo( - () => wallet instanceof MetaMaskShapeShiftMultiChainHDWallet, + () => wallet instanceof MetaMaskMultiChainHDWallet, [wallet], ) const chainNamespaceDisplayName = asset?.networkName ?? '' diff --git a/src/components/Modals/Send/utils.ts b/src/components/Modals/Send/utils.ts index cd0c812d2f6..56c1a7e78f3 100644 --- a/src/components/Modals/Send/utils.ts +++ b/src/components/Modals/Send/utils.ts @@ -7,7 +7,6 @@ import { supportsETH } from '@shapeshiftoss/hdwallet-core' import type { CosmosSdkChainId, EvmChainId, KnownChainIds, UtxoChainId } from '@shapeshiftoss/types' import { checkIsMetaMaskDesktop, - checkIsMetaMaskImpersonator, checkIsSnapInstalled, } from 'hooks/useIsSnapInstalled/useIsSnapInstalled' import { bn, bnOrZero } from 'lib/bignumber/bignumber' @@ -104,15 +103,12 @@ export const handleSend = async ({ const acccountMetadataFilter = { accountId: sendInput.accountId } const accountMetadata = selectPortfolioAccountMetadataByAccountId(state, acccountMetadataFilter) - const isMetaMaskDesktop = await checkIsMetaMaskDesktop(wallet) - const isMetaMaskImpersonator = await checkIsMetaMaskImpersonator(wallet) + const isMetaMaskDesktop = checkIsMetaMaskDesktop(wallet) if ( fromChainId(asset.chainId).chainNamespace === CHAIN_NAMESPACE.CosmosSdk && !wallet.supportsOfflineSigning() && - // MM impersonators don't support Cosmos SDK chains - (!isMetaMaskDesktop || - isMetaMaskImpersonator || - (isMetaMaskDesktop && !(await checkIsSnapInstalled()))) + // MM only supports snap things... if the snap is installed + (!isMetaMaskDesktop || (isMetaMaskDesktop && !(await checkIsSnapInstalled()))) ) { throw new Error(`unsupported wallet: ${await wallet.getModel()}`) } diff --git a/src/components/Modals/Snaps/Snaps.tsx b/src/components/Modals/Snaps/Snaps.tsx index 6b456db35b8..bcaef7d1b5f 100644 --- a/src/components/Modals/Snaps/Snaps.tsx +++ b/src/components/Modals/Snaps/Snaps.tsx @@ -1,6 +1,5 @@ import { Modal, ModalCloseButton, ModalContent, ModalOverlay } from '@chakra-ui/react' import { useCallback, useEffect } from 'react' -import { useFeatureFlag } from 'hooks/useFeatureFlag/useFeatureFlag' import { useIsSnapInstalled } from 'hooks/useIsSnapInstalled/useIsSnapInstalled' import { useModal } from 'hooks/useModal/useModal' @@ -12,7 +11,6 @@ export type SnapsModalProps = { export const Snaps: React.FC = ({ isRemoved }) => { const { close, isOpen } = useModal('snaps') - const isSnapsEnabled = useFeatureFlag('Snaps') const { isSnapInstalled, isCorrectVersion } = useIsSnapInstalled() useEffect(() => { @@ -25,7 +23,6 @@ export const Snaps: React.FC = ({ isRemoved }) => { close() }, [close]) - if (!isSnapsEnabled) return null if (isSnapInstalled === null) return null if (isCorrectVersion === null) return null diff --git a/src/components/MultiHopTrade/MultiHopTrade.tsx b/src/components/MultiHopTrade/MultiHopTrade.tsx index 5d84586edd9..68016a67d1f 100644 --- a/src/components/MultiHopTrade/MultiHopTrade.tsx +++ b/src/components/MultiHopTrade/MultiHopTrade.tsx @@ -1,20 +1,22 @@ import type { AssetId } from '@shapeshiftoss/caip' +import { assertUnreachable } from '@shapeshiftoss/utils' import { AnimatePresence } from 'framer-motion' -import { memo, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { FormProvider, useForm } from 'react-hook-form' -import { MemoryRouter, Route, Switch, useLocation, useParams } from 'react-router-dom' +import { MemoryRouter, Route, Switch, useHistory, useLocation, useParams } from 'react-router-dom' import { selectAssetById } from 'state/slices/assetsSlice/selectors' import { tradeInput } from 'state/slices/tradeInputSlice/tradeInputSlice' import { tradeQuoteSlice } from 'state/slices/tradeQuoteSlice/tradeQuoteSlice' import { useAppDispatch, useAppSelector } from 'state/store' +import { LimitOrder } from './components/LimitOrder/LimitOrder' import { MultiHopTradeConfirm } from './components/MultiHopTradeConfirm/MultiHopTradeConfirm' import { QuoteListRoute } from './components/QuoteList/QuoteListRoute' import { Claim } from './components/TradeInput/components/Claim/Claim' import { TradeInput } from './components/TradeInput/TradeInput' import { VerifyAddresses } from './components/VerifyAddresses/VerifyAddresses' import { useGetTradeQuotes } from './hooks/useGetTradeQuotes/useGetTradeQuotes' -import { TradeRoutePaths } from './types' +import { TradeInputTab, TradeRoutePaths } from './types' const TradeRouteEntries = [ TradeRoutePaths.Input, @@ -22,6 +24,7 @@ const TradeRouteEntries = [ TradeRoutePaths.VerifyAddresses, TradeRoutePaths.QuoteList, TradeRoutePaths.Claim, + TradeRoutePaths.LimitOrder, ] export type TradeCardProps = { @@ -80,6 +83,7 @@ type TradeRoutesProps = { } const TradeRoutes = memo(({ isCompact }: TradeRoutesProps) => { + const history = useHistory() const location = useLocation() const dispatch = useAppDispatch() @@ -102,12 +106,35 @@ const TradeRoutes = memo(({ isCompact }: TradeRoutesProps) => { ) }, [location.pathname]) + const handleChangeTab = useCallback( + (newTab: TradeInputTab) => { + switch (newTab) { + case TradeInputTab.Trade: + history.push(TradeRoutePaths.Input) + break + case TradeInputTab.LimitOrder: + history.push(TradeRoutePaths.LimitOrder) + break + case TradeInputTab.Claim: + history.push(TradeRoutePaths.Claim) + break + default: + assertUnreachable(newTab) + } + }, + [history], + ) + return ( <> - + @@ -122,7 +149,14 @@ const TradeRoutes = memo(({ isCompact }: TradeRoutesProps) => { /> - + + + + diff --git a/src/components/MultiHopTrade/components/LimitOrder/LimitOrder.tsx b/src/components/MultiHopTrade/components/LimitOrder/LimitOrder.tsx new file mode 100644 index 00000000000..8db0a52def1 --- /dev/null +++ b/src/components/MultiHopTrade/components/LimitOrder/LimitOrder.tsx @@ -0,0 +1,190 @@ +import { isLedger } from '@shapeshiftoss/hdwallet-ledger' +import type { Asset } from '@shapeshiftoss/types' +import type { FormEvent } from 'react' +import { useCallback, useMemo, useState } from 'react' +import { useFormContext } from 'react-hook-form' +import { ethereum, fox } from 'test/mocks/assets' +import { WarningAcknowledgement } from 'components/Acknowledgement/Acknowledgement' +import { useReceiveAddress } from 'components/MultiHopTrade/hooks/useReceiveAddress' +import { TradeInputTab } from 'components/MultiHopTrade/types' +import { WalletActions } from 'context/WalletProvider/actions' +import { useErrorHandler } from 'hooks/useErrorToast/useErrorToast' +import { useWallet } from 'hooks/useWallet/useWallet' +import type { ParameterModel } from 'lib/fees/parameters/types' +import { selectIsSnapshotApiQueriesPending, selectVotingPower } from 'state/apis/snapshot/selectors' +import { + selectHasUserEnteredAmount, + selectIsAnyAccountMetadataLoadedForChainId, +} from 'state/slices/selectors' +import { + selectActiveQuote, + selectBuyAmountAfterFeesCryptoPrecision, + selectBuyAmountAfterFeesUserCurrency, + selectIsTradeQuoteRequestAborted, + selectShouldShowTradeQuoteOrAwaitInput, +} from 'state/slices/tradeQuoteSlice/selectors' +import { useAppSelector } from 'state/store' + +import { useAccountIds } from '../../hooks/useAccountIds' +import { SharedTradeInput } from '../SharedTradeInput/SharedTradeInput' + +const votingPowerParams: { feeModel: ParameterModel } = { feeModel: 'SWAPPER' } + +type LimitOrderProps = { + tradeInputRef: React.MutableRefObject + isCompact?: boolean + onChangeTab: (newTab: TradeInputTab) => void +} + +// TODO: Implement me +const CollapsibleLimitOrderList = () => <> + +export const LimitOrder = ({ isCompact, tradeInputRef, onChangeTab }: LimitOrderProps) => { + const { + dispatch: walletDispatch, + state: { isConnected, isDemoWallet, wallet }, + } = useWallet() + + const { handleSubmit } = useFormContext() + const { showErrorToast } = useErrorHandler() + const { manualReceiveAddress, walletReceiveAddress } = useReceiveAddress({ + fetchUnchainedAddress: Boolean(wallet && isLedger(wallet)), + }) + const { sellAssetAccountId, buyAssetAccountId, setSellAssetAccountId, setBuyAssetAccountId } = + useAccountIds() + + const [isConfirmationLoading, setIsConfirmationLoading] = useState(false) + const [shouldShowWarningAcknowledgement, setShouldShowWarningAcknowledgement] = useState(false) + + const buyAmountAfterFeesCryptoPrecision = useAppSelector(selectBuyAmountAfterFeesCryptoPrecision) + const buyAmountAfterFeesUserCurrency = useAppSelector(selectBuyAmountAfterFeesUserCurrency) + const shouldShowTradeQuoteOrAwaitInput = useAppSelector(selectShouldShowTradeQuoteOrAwaitInput) + const isSnapshotApiQueriesPending = useAppSelector(selectIsSnapshotApiQueriesPending) + const isTradeQuoteRequestAborted = useAppSelector(selectIsTradeQuoteRequestAborted) + const hasUserEnteredAmount = useAppSelector(selectHasUserEnteredAmount) + const votingPower = useAppSelector(state => selectVotingPower(state, votingPowerParams)) + const sellAsset = ethereum // TODO: Implement me + const buyAsset = fox // TODO: Implement me + const activeQuote = useAppSelector(selectActiveQuote) + const isAnyAccountMetadataLoadedForChainIdFilter = useMemo( + () => ({ chainId: sellAsset.chainId }), + [sellAsset.chainId], + ) + const isAnyAccountMetadataLoadedForChainId = useAppSelector(state => + selectIsAnyAccountMetadataLoadedForChainId(state, isAnyAccountMetadataLoadedForChainIdFilter), + ) + + const isVotingPowerLoading = useMemo( + () => isSnapshotApiQueriesPending && votingPower === undefined, + [isSnapshotApiQueriesPending, votingPower], + ) + + const isLoading = useMemo( + () => + // No account meta loaded for that chain + !isAnyAccountMetadataLoadedForChainId || + (!shouldShowTradeQuoteOrAwaitInput && !isTradeQuoteRequestAborted) || + isConfirmationLoading || + // Only consider snapshot API queries as pending if we don't have voting power yet + // if we do, it means we have persisted or cached (both stale) data, which is enough to let the user continue + // as we are optimistic and don't want to be waiting for a potentially very long time for the snapshot API to respond + isVotingPowerLoading, + [ + isAnyAccountMetadataLoadedForChainId, + shouldShowTradeQuoteOrAwaitInput, + isTradeQuoteRequestAborted, + isConfirmationLoading, + isVotingPowerLoading, + ], + ) + + const warningAcknowledgementMessage = useMemo(() => { + // TODO: Implement me + return '' + }, []) + + const headerRightContent = useMemo(() => { + // TODO: Implement me + return <> + }, []) + + const setBuyAsset = useCallback((_asset: Asset) => { + // TODO: Implement me + }, []) + const setSellAsset = useCallback((_asset: Asset) => { + // TODO: Implement me + }, []) + const handleSwitchAssets = useCallback(() => { + // TODO: Implement me + }, []) + + const handleConnect = useCallback(() => { + walletDispatch({ type: WalletActions.SET_WALLET_MODAL, payload: true }) + }, [walletDispatch]) + + const onSubmit = useCallback(() => { + // No preview happening if wallet isn't connected i.e is using the demo wallet + if (!isConnected || isDemoWallet) { + return handleConnect() + } + + setIsConfirmationLoading(true) + try { + // TODO: Implement me + } catch (e) { + showErrorToast(e) + } + + setIsConfirmationLoading(false) + }, [handleConnect, isConnected, isDemoWallet, showErrorToast]) + + const handleFormSubmit = useMemo(() => handleSubmit(onSubmit), [handleSubmit, onSubmit]) + + const handleWarningAcknowledgementSubmit = useCallback(() => { + handleFormSubmit() + }, [handleFormSubmit]) + + const handleTradeQuoteConfirm = useCallback( + (e: FormEvent) => { + e.preventDefault() + handleFormSubmit() + }, + [handleFormSubmit], + ) + + return ( + <> + + + + ) +} diff --git a/src/components/MultiHopTrade/components/MultiHopTradeConfirm/MultiHopTradeConfirm.tsx b/src/components/MultiHopTrade/components/MultiHopTradeConfirm/MultiHopTradeConfirm.tsx index d6e7f6a573f..24795f80c49 100644 --- a/src/components/MultiHopTrade/components/MultiHopTradeConfirm/MultiHopTradeConfirm.tsx +++ b/src/components/MultiHopTrade/components/MultiHopTradeConfirm/MultiHopTradeConfirm.tsx @@ -111,40 +111,39 @@ export const MultiHopTradeConfirm = memo(() => { onAcknowledge={handleTradeConfirm} shouldShowAcknowledgement={shouldShowWarningAcknowledgement} setShouldShowAcknowledgement={setShouldShowWarningAcknowledgement} - > - - - - - - - - {isTradeComplete ? ( - - - - ) : ( - <> - - - -