From 0cc763cb76f78249e1cba7ca0d5e279280402b42 Mon Sep 17 00:00:00 2001 From: Muzaffar Ahmad Bhat Date: Mon, 4 May 2026 18:56:02 +0530 Subject: [PATCH 1/7] fix: send max issue in sia, send max now takes updated amount from updated txn --- .../operations/prepareTransaction/index.ts | 3 +++ .../src/dialogs/Send/context/index.tsx | 27 ++++++++++++------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/coin-support-sia/src/operations/prepareTransaction/index.ts b/packages/coin-support-sia/src/operations/prepareTransaction/index.ts index 73bae5c27..9148985df 100644 --- a/packages/coin-support-sia/src/operations/prepareTransaction/index.ts +++ b/packages/coin-support-sia/src/operations/prepareTransaction/index.ts @@ -225,6 +225,9 @@ export const prepareTransaction = async ( currentOutput = sendAllResult.output; currentTxn = sendAllResult.txn; feeHastings = BigInt(scToHastings(feeSC)); + + // update userInput so that the max amount is editable & not reset to 0 + txn.userInputs.outputs[0].amount = sendAmountSC; } const validationResult = validateBalanceAndFees( diff --git a/packages/cysync-core/src/dialogs/Send/context/index.tsx b/packages/cysync-core/src/dialogs/Send/context/index.tsx index 9f3d33d71..222adb357 100644 --- a/packages/cysync-core/src/dialogs/Send/context/index.tsx +++ b/packages/cysync-core/src/dialogs/Send/context/index.tsx @@ -126,7 +126,9 @@ export interface SendDialogContextInterface { transactionRef: React.MutableRefObject; setTransaction: (txn: IPreparedTransaction) => void; initialize: () => Promise; - prepare: (txn: IPreparedTransaction) => Promise; + prepare: ( + txn: IPreparedTransaction, + ) => Promise; isDeviceRequired: boolean; deviceEvents: Record; startFlow: () => Promise; @@ -534,15 +536,15 @@ export const SendDialogProvider: FC = ({ const prepare = async (txn: IPreparedTransaction) => { logger.info('Preparing send transaction'); + let preparedTransaction: IPreparedTransaction | undefined; try { setPreparingTxn(true); - const preparedTransaction = - await getCurrentCoinSupport().prepareTransaction({ - accountId: selectedAccount?.__id ?? '', - db: getDB(), - txn, - keyDB: getKeyDB(), - }); + preparedTransaction = await getCurrentCoinSupport().prepareTransaction({ + accountId: selectedAccount?.__id ?? '', + db: getDB(), + txn, + keyDB: getKeyDB(), + }); setTransaction(structuredClone(preparedTransaction)); } catch (e: any) { @@ -550,6 +552,8 @@ export const SendDialogProvider: FC = ({ } finally { setPreparingTxn(false); } + + return preparedTransaction; }; const getFlowObserver = ( @@ -685,9 +689,12 @@ export const SendDialogProvider: FC = ({ const prepareSendMax = async (state: boolean) => { const txn = transactionRef.current; if (!selectedAccount || !txn) return ''; + txn.userInputs.isSendAll = state; - await prepare(txn); - const outputAmount = txn.userInputs.outputs[0].amount; + const preparedTransaction = await prepare(txn); + + const outputAmount = + preparedTransaction?.userInputs.outputs[0].amount ?? '0'; const convertedAmount = convertToUnit({ amount: outputAmount, coinId: selectedAccount.parentAssetId, From cc77888ca2b68978731706f4b1286c5f1df4fe08 Mon Sep 17 00:00:00 2001 From: Keyur Vaitha Date: Mon, 11 May 2026 02:16:55 +0530 Subject: [PATCH 2/7] fix: show syncing and failed status icons for token accounts in wallet page --- .../src/pages/MainApp/hooks/useWalletPage.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/cysync-core/src/pages/MainApp/hooks/useWalletPage.tsx b/packages/cysync-core/src/pages/MainApp/hooks/useWalletPage.tsx index c28d609b9..f515ccc86 100644 --- a/packages/cysync-core/src/pages/MainApp/hooks/useWalletPage.tsx +++ b/packages/cysync-core/src/pages/MainApp/hooks/useWalletPage.tsx @@ -54,6 +54,7 @@ export interface AccountTokenType { displayAmount: string; amountTooltip?: string; displayValue: string; + statusImage?: React.ReactNode; } export interface AccountRowData { @@ -129,6 +130,7 @@ const mapTokenAccounts = ( priceInfos: IPriceInfo[], isDiscreetMode: boolean, currency: string, + accountSyncMap: Record, ): AccountTokenType => { const { amount, unit } = getParsedAmount({ coinId: a.parentAssetId, @@ -175,6 +177,9 @@ const mapTokenAccounts = ( name = asset.name; } + const tokenSyncState = + accountSyncMap[a.__id ?? '']?.syncState ?? AccountSyncStateMap.synced; + return { id: a.__id ?? '', leftImage: ( @@ -190,6 +195,10 @@ const mapTokenAccounts = ( displayValue: isDiscreetMode ? '****' : displayValue, amount: parseFloat(amount), value: parseFloat(value), + statusImage: + tokenSyncState === AccountSyncStateMap.synced + ? undefined + : accountSyncIconMap[tokenSyncState], }; }; @@ -324,6 +333,7 @@ export const useWalletPage = () => { priceInfos, isDiscreetMode, currentCurrency, + accountSyncMap, ), ); From d4ef1fb4ea904ec6d69dd39632d166ba43b1e3f7 Mon Sep 17 00:00:00 2001 From: Keyur Vaitha Date: Mon, 11 May 2026 02:27:08 +0530 Subject: [PATCH 3/7] chore: add changeset --- .changeset/chilled-turtles-work.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/chilled-turtles-work.md diff --git a/.changeset/chilled-turtles-work.md b/.changeset/chilled-turtles-work.md new file mode 100644 index 000000000..06ca64b1e --- /dev/null +++ b/.changeset/chilled-turtles-work.md @@ -0,0 +1,5 @@ +--- +'@cypherock/cysync-core': patch +--- + +show syncing and failed status icons for token accounts in wallet page From dfc14389fe52c732e6d4ce3b4fe5379e00a3d979 Mon Sep 17 00:00:00 2001 From: Keyur Vaitha Date: Tue, 2 Jun 2026 04:35:37 +0530 Subject: [PATCH 4/7] feat: add EVM/Base staking operations, service layer and context --- packages/coin-support-evm/src/index.ts | 1 + .../coin-support-evm/src/operations/index.ts | 1 + .../src/operations/staking/index.ts | 73 ++ .../src/operations/staking/types.ts | 38 ++ .../src/operations/staking/utils.ts | 39 ++ packages/cysync-core/src/constants/hysp.ts | 26 + .../cysync-core/src/context/hysp/index.tsx | 627 ++++++++++++++++++ .../cysync-core/src/services/hyspService.ts | 88 +++ 8 files changed, 893 insertions(+) create mode 100644 packages/coin-support-evm/src/operations/staking/index.ts create mode 100644 packages/coin-support-evm/src/operations/staking/types.ts create mode 100644 packages/coin-support-evm/src/operations/staking/utils.ts create mode 100644 packages/cysync-core/src/constants/hysp.ts create mode 100644 packages/cysync-core/src/context/hysp/index.tsx create mode 100644 packages/cysync-core/src/services/hyspService.ts diff --git a/packages/coin-support-evm/src/index.ts b/packages/coin-support-evm/src/index.ts index eecbb26ef..d9293a3cf 100644 --- a/packages/coin-support-evm/src/index.ts +++ b/packages/coin-support-evm/src/index.ts @@ -37,6 +37,7 @@ import { getAppletId, setCoinSupportEthersLib } from './utils'; import { setCoinSupportWeb3Lib } from './utils/web3'; export * from './operations/types'; +export * from './operations/staking'; export * from './services'; export { updateLogger } from './utils/logger'; diff --git a/packages/coin-support-evm/src/operations/index.ts b/packages/coin-support-evm/src/operations/index.ts index 9cb7172f0..7a61b1224 100644 --- a/packages/coin-support-evm/src/operations/index.ts +++ b/packages/coin-support-evm/src/operations/index.ts @@ -13,3 +13,4 @@ export * from './signTransaction'; export * from './broadcastTransaction'; export * from './syncAccount'; export * from './formatAddress'; +export * from './staking'; diff --git a/packages/coin-support-evm/src/operations/staking/index.ts b/packages/coin-support-evm/src/operations/staking/index.ts new file mode 100644 index 000000000..dbcc9e185 --- /dev/null +++ b/packages/coin-support-evm/src/operations/staking/index.ts @@ -0,0 +1,73 @@ +import { IPreparedEvmTransaction } from '../transaction'; +import { + IHyspEvmPrepareParams, + IHyspEvmApproveRedeemParams, + IHyspEvmRedeemPrepareParams, +} from './types'; +import { callHyspBuildEndpoint, buildPreparedTxn } from './utils'; + +export * from './types'; + +export const prepareApproveDeposit = async ( + params: IHyspEvmPrepareParams, +): Promise => { + const { txn, chain, walletAddress, tokenAddress, amount } = params; + const serverTx = await callHyspBuildEndpoint('build/approve-deposit', { + chain, + walletAddress, + tokenAddress, + amount, + }); + return buildPreparedTxn(serverTx, txn); +}; + +export const prepareDeposit = async ( + params: IHyspEvmPrepareParams, +): Promise => { + const { txn, chain, walletAddress, tokenAddress, amount } = params; + const serverTx = await callHyspBuildEndpoint('build/deposit', { + chain, + walletAddress, + tokenAddress, + amount, + }); + return buildPreparedTxn(serverTx, txn); +}; + +export const prepareApproveRedeem = async ( + params: IHyspEvmApproveRedeemParams, +): Promise => { + const { txn, chain, walletAddress, amount } = params; + const serverTx = await callHyspBuildEndpoint('build/approve-redeem', { + chain, + walletAddress, + amount, + }); + return buildPreparedTxn(serverTx, txn); +}; + +export const prepareRedeemInstant = async ( + params: IHyspEvmRedeemPrepareParams, +): Promise => { + const { txn, chain, walletAddress, tokenOut, amount } = params; + const serverTx = await callHyspBuildEndpoint('build/redeem-instant', { + chain, + walletAddress, + tokenAddress: tokenOut, + amount, + }); + return buildPreparedTxn(serverTx, txn); +}; + +export const prepareRedeemQueue = async ( + params: IHyspEvmRedeemPrepareParams, +): Promise => { + const { txn, chain, walletAddress, tokenOut, amount } = params; + const serverTx = await callHyspBuildEndpoint('build/redeem-queue', { + chain, + walletAddress, + tokenAddress: tokenOut, + amount, + }); + return buildPreparedTxn(serverTx, txn); +}; diff --git a/packages/coin-support-evm/src/operations/staking/types.ts b/packages/coin-support-evm/src/operations/staking/types.ts new file mode 100644 index 000000000..df6ed3179 --- /dev/null +++ b/packages/coin-support-evm/src/operations/staking/types.ts @@ -0,0 +1,38 @@ +import { IDatabase } from '@cypherock/db-interfaces'; + +import { IPreparedEvmTransaction } from '../transaction'; + +export type HyspChain = 'eth_mainnet' | 'base'; + +export interface IHyspEvmPrepareParams { + accountId: string; + db: IDatabase; + txn: IPreparedEvmTransaction; + chain: HyspChain; + walletAddress: string; + tokenAddress: string; + amount: number; +} + +// no tokenOut needed, always approves mevUSD +export interface IHyspEvmApproveRedeemParams { + accountId: string; + db: IDatabase; + txn: IPreparedEvmTransaction; + chain: HyspChain; + walletAddress: string; + amount: number; +} + +// Used for redeemInstant and redeemQueue (tokenOut required) +export interface IHyspEvmRedeemPrepareParams + extends IHyspEvmApproveRedeemParams { + tokenOut: string; +} + +export interface IHyspServerTxParams { + to: string; + data: string; + value: string; + gasLimit: string; +} diff --git a/packages/coin-support-evm/src/operations/staking/utils.ts b/packages/coin-support-evm/src/operations/staking/utils.ts new file mode 100644 index 000000000..a92039aad --- /dev/null +++ b/packages/coin-support-evm/src/operations/staking/utils.ts @@ -0,0 +1,39 @@ +import { makePostRequest, BigNumber } from '@cypherock/cysync-utils'; + +import { IPreparedEvmTransaction } from '../transaction'; +import { IHyspServerTxParams } from './types'; + +const BASE_URL = 'http://localhost:5001/hysp'; + +export const callHyspBuildEndpoint = async ( + path: string, + body: Record, +): Promise => { + const response = await makePostRequest(`${BASE_URL}/${path}`, body); + return response.data.data as IHyspServerTxParams; +}; + +export const buildPreparedTxn = ( + serverTx: IHyspServerTxParams, + txn: IPreparedEvmTransaction, +): IPreparedEvmTransaction => { + const gasPrice = txn.userInputs.gasPrice ?? txn.staticData.averageGasPrice; + const fee = new BigNumber(serverTx.gasLimit).multipliedBy(gasPrice); + + return { + ...txn, + userInputs: { + ...txn.userInputs, + outputs: [{ address: serverTx.to, amount: serverTx.value, remarks: '' }], + }, + computedData: { + output: { address: serverTx.to, amount: serverTx.value }, + data: serverTx.data, + fee: fee.toString(10), + gasLimit: serverTx.gasLimit, + gasLimitEstimate: serverTx.gasLimit, + l1Fee: '0', + gasPrice, + }, + }; +}; diff --git a/packages/cysync-core/src/constants/hysp.ts b/packages/cysync-core/src/constants/hysp.ts new file mode 100644 index 000000000..1de7abff6 --- /dev/null +++ b/packages/cysync-core/src/constants/hysp.ts @@ -0,0 +1,26 @@ +import { HyspChain } from '@cypherock/coin-support-evm'; +import { EvmIdMap } from '@cypherock/coins'; + +export const MEV_USD_ADDRESS: Record = { + eth_mainnet: '0x548857309BEfb6Fb6F20a9C5A56c9023D892785B', + base: '0xccbad2823328BCcAEa6476Df3Aa529316aB7474A', +}; + +export const TOKEN_ADDRESSES: Record< + HyspChain, + { usdc: string; usdt: string | null } +> = { + eth_mainnet: { + usdc: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + usdt: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + }, + base: { + usdc: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + usdt: null, + }, +}; + +export const COIN_ID_TO_CHAIN: Partial> = { + [EvmIdMap.ethereum]: 'eth_mainnet', + [EvmIdMap.base]: 'base', +}; diff --git a/packages/cysync-core/src/context/hysp/index.tsx b/packages/cysync-core/src/context/hysp/index.tsx new file mode 100644 index 000000000..2d4c0b69b --- /dev/null +++ b/packages/cysync-core/src/context/hysp/index.tsx @@ -0,0 +1,627 @@ +import { getCoinSupport } from '@cypherock/coin-support'; +import { + IPreparedEvmTransaction, + prepareApproveDeposit, + prepareDeposit, + prepareApproveRedeem, + prepareRedeemInstant, + prepareRedeemQueue, + HyspChain, +} from '@cypherock/coin-support-evm'; +import { ISignTransactionEvent } from '@cypherock/coin-support-interfaces'; +import { evmCoinList, EvmIdMap } from '@cypherock/coins'; +import { IAccount, IWallet } from '@cypherock/db-interfaces'; +import lodash from 'lodash'; +import React, { + Context, + FC, + ReactNode, + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { Subscription } from 'rxjs'; + +import { deviceLock, useDevice } from '~/context'; +import { useWalletDropdown, useAccountDropdown } from '~/hooks'; +import { getDB } from '~/utils'; +import logger from '~/utils/logger'; + +import { + MEV_USD_ADDRESS, + TOKEN_ADDRESSES, + COIN_ID_TO_CHAIN, +} from '../../constants/hysp'; +import * as hyspService from '../../services/hyspService'; + +// Types + +export type HyspMode = 'deposit' | 'redeem'; +export type RedeemPath = 'instant' | 'queue'; + +type HyspStep = + | 'input' + | 'approveFee' + | 'approving' + | 'polling' + | 'depositFee' + | 'depositing' + | 'done' + | 'redeem-input' + | 'redeem-approve-fee' + | 'redeem-approving' + | 'redeem-polling' + | 'redeem-fee' + | 'redeeming' + | 'redeem-done' + | 'error'; + +interface IHyspContext { + mode: HyspMode; + setMode: (m: HyspMode) => void; + step: HyspStep; + selectedWallet: IWallet | undefined; + selectedAccount: IAccount | undefined; + walletDropdownList: any[]; + accountDropdownList: any[]; + handleWalletChange: (id?: string) => void; + handleAccountChange: (id?: string) => void; + // deposit + selectedToken: 'usdc' | 'usdt'; + setSelectedToken: (t: 'usdc' | 'usdt') => void; + amount: string; + setAmount: (a: string) => void; + approveTxn: IPreparedEvmTransaction | undefined; + depositTxn: IPreparedEvmTransaction | undefined; + // redeem + redeemAmount: string; + setRedeemAmount: (a: string) => void; + redeemTokenOut: 'usdc' | 'usdt'; + setRedeemTokenOut: (t: 'usdc' | 'usdt') => void; + userRedeemChoice: RedeemPath; + setUserRedeemChoice: (p: RedeemPath) => void; + instantAvailable: boolean; + redeemPath: RedeemPath | undefined; + redeemApproveTxn: IPreparedEvmTransaction | undefined; + redeemTxn: IPreparedEvmTransaction | undefined; + // shared + deviceEvents: Record; + error: string | undefined; + vaultInfo: hyspService.IHyspVaultInfo | undefined; + position: hyspService.IHyspPosition | undefined; + vaultInfoLoading: boolean; + onProceed: () => Promise; + onClose: () => void; +} + +const HyspContext: Context = createContext( + {} as IHyspContext, +); + +// Provider + +export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ + children, + onClose, +}) => { + const { connection } = useDevice(); + + const [mode, setModeState] = useState('deposit'); + const [step, setStep] = useState('input'); + + // deposit + const [selectedToken, setSelectedToken] = useState<'usdc' | 'usdt'>('usdc'); + const [amount, setAmount] = useState(''); + const [approveTxn, setApproveTxn] = useState(); + const [depositTxn, setDepositTxn] = useState(); + + // redeem + const [redeemAmount, setRedeemAmount] = useState(''); + const [redeemTokenOut, setRedeemTokenOut] = useState<'usdc' | 'usdt'>('usdc'); + const [userRedeemChoice, setUserRedeemChoice] = + useState('instant'); + const [redeemPath, setRedeemPath] = useState(); + const [redeemApproveTxn, setRedeemApproveTxn] = + useState(); + const [redeemTxn, setRedeemTxn] = useState(); + + // shared + const [deviceEvents, setDeviceEvents] = useState< + Record + >({}); + const [error, setError] = useState(); + const [vaultInfo, setVaultInfo] = useState(); + const [position, setPosition] = useState(); + const [vaultInfoLoading, setVaultInfoLoading] = useState(false); + + // whether current redeemAmount fits within instant liquidity + const instantAvailable = React.useMemo(() => { + const num = parseFloat(redeemAmount); + const liquidity = parseFloat(vaultInfo?.instantLiquidity ?? '0'); + return !!num && !!liquidity && num <= liquidity; + }, [redeemAmount, vaultInfo?.instantLiquidity]); + + const flowSubscription = useRef(); + const signedApprove = useRef(); + const signedAction = useRef(); + + const { selectedWallet, handleWalletChange, walletDropdownList } = + useWalletDropdown(); + + const { selectedAccount, handleAccountChange, accountDropdownList } = + useAccountDropdown({ + selectedWallet, + assetFilter: [EvmIdMap.ethereum, EvmIdMap.base], + }); + + // Vault info + + useEffect(() => { + if (!selectedAccount) return; + const chain = COIN_ID_TO_CHAIN[selectedAccount.assetId]; + if (!chain) return; + setVaultInfoLoading(true); + Promise.all([ + hyspService.getVaultInfo(chain), + hyspService.getUserPosition(chain, selectedAccount.xpubOrAddress), + ]) + .then(([info, pos]) => { + setVaultInfo(info); + setPosition(pos); + }) + .catch(e => logger.error('HYSP vault info fetch failed', e as object)) + .finally(() => setVaultInfoLoading(false)); + }, [selectedAccount?.assetId, selectedAccount?.xpubOrAddress]); + + // Mode toggle + + const setMode = (m: HyspMode) => { + setModeState(m); + setStep(m === 'deposit' ? 'input' : 'redeem-input'); + setError(undefined); + setDeviceEvents({}); + if (m === 'redeem') { + setRedeemAmount(''); + setUserRedeemChoice('instant'); + } + flowSubscription.current?.unsubscribe(); + }; + + // Helpers + + const getChain = (): HyspChain => { + const chain = COIN_ID_TO_CHAIN[selectedAccount?.assetId ?? '']; + if (!chain) throw new Error('Unsupported chain for HYSP'); + return chain; + }; + + const getDepositTokenAddress = (): string => { + const chain = getChain(); + const addr = + selectedToken === 'usdt' + ? TOKEN_ADDRESSES[chain].usdt + : TOKEN_ADDRESSES[chain].usdc; + if (!addr) throw new Error('Token not supported on this chain'); + return addr; + }; + + const getRedeemTokenOutAddress = (): string => { + const chain = getChain(); + const addr = + redeemTokenOut === 'usdt' + ? TOKEN_ADDRESSES[chain].usdt + : TOKEN_ADDRESSES[chain].usdc; + if (!addr) throw new Error('Token not supported on this chain'); + return addr; + }; + + const coinSupport = useCallback(() => { + if (!selectedAccount) throw new Error('No account selected'); + return getCoinSupport(selectedAccount.familyId); + }, [selectedAccount]); + + const getInitTxn = async (): Promise => + (await coinSupport().initializeTransaction({ + db: getDB(), + accountId: selectedAccount!.__id ?? '', + })) as IPreparedEvmTransaction; + + const pollConfirmation = async (txHash: string): Promise => { + if (!txHash) + throw new Error('Broadcast failed — no transaction hash returned'); + + const { network } = evmCoinList[selectedAccount!.assetId]; + const deadline = Date.now() + 5 * 60 * 1000; + while (Date.now() < deadline) { + await new Promise(r => { + setTimeout(r, 4000); + }); + try { + const status = await hyspService.getEthTransactionStatus( + txHash, + network, + ); + if (status === '1') return true; + if (status === '0') return false; + } catch (e) { + logger.warn('HYSP polling error', e as object); + } + } + return false; + }; + + const startSign = async ( + txn: IPreparedEvmTransaction, + onSigned: (sig: string) => void, + onDone: () => Promise, + ) => { + if (!connection?.connection) throw new Error('Device not connected'); + const taskId = lodash.uniqueId('hysp-'); + await deviceLock.acquire(connection.device, taskId); + const release = () => deviceLock.release(connection.device, taskId); + setDeviceEvents({}); + flowSubscription.current = coinSupport() + .signTransaction({ + connection: connection.connection, + db: getDB(), + transaction: txn, + }) + .subscribe({ + next: (payload: ISignTransactionEvent) => { + if (payload.device) setDeviceEvents({ ...payload.device.events }); + if (payload.transaction) onSigned(payload.transaction as string); + }, + error: (err: any) => { + release(); + setError(err?.message ?? 'Signing failed'); + setStep('error'); + }, + complete: () => { + release(); + onDone().catch((err: any) => { + const msg = + err?.response?.data?.cysyncError ?? + err?.response?.data?.message ?? + err?.response?.data?.error ?? + err?.message ?? + 'Broadcast failed'; + logger.error('HYSP post-sign error', err as object); + setError(msg); + setStep('error'); + }); + }, + }); + }; + + const broadcast = async ( + signed: string, + txn: IPreparedEvmTransaction, + ): Promise => { + // No try/catch — let RPC failures propagate to onProceed's error handler + const result = await coinSupport().broadcastTransaction({ + db: getDB(), + signedTransaction: signed, + transaction: txn, + }); + return (result as any)?.hash ?? ''; + }; + + // Deposit flow + + const handleDepositFlow = async () => { + const chain = getChain(); + const tokenAddress = getDepositTokenAddress(); + const walletAddress = selectedAccount!.xpubOrAddress; + const numAmount = parseFloat(amount); + + if (step === 'input') { + const allowance = await hyspService.checkAllowance({ + chain, + walletAddress, + tokenAddress, + vaultType: 'deposit', + amount: numAmount, + }); + const initTxn = await getInitTxn(); + if (!allowance.sufficient) { + const prepared = await prepareApproveDeposit({ + accountId: selectedAccount!.__id ?? '', + db: getDB(), + txn: initTxn, + chain, + walletAddress, + tokenAddress, + amount: numAmount, + }); + setApproveTxn(prepared); + setStep('approveFee'); + } else { + const prepared = await prepareDeposit({ + accountId: selectedAccount!.__id ?? '', + db: getDB(), + txn: initTxn, + chain, + walletAddress, + tokenAddress, + amount: numAmount, + }); + setDepositTxn(prepared); + setStep('depositFee'); + } + return; + } + + if (step === 'approveFee') { + setStep('approving'); + await startSign( + approveTxn!, + sig => { + signedApprove.current = sig; + }, + async () => { + const txHash = await broadcast(signedApprove.current!, approveTxn!); + setStep('polling'); + const confirmed = await pollConfirmation(txHash); + if (!confirmed) { + setError('Approval transaction failed or timed out'); + setStep('error'); + return; + } + const initTxn2 = await getInitTxn(); + const prepared = await prepareDeposit({ + accountId: selectedAccount!.__id ?? '', + db: getDB(), + txn: initTxn2, + chain, + walletAddress, + tokenAddress, + amount: numAmount, + }); + setDepositTxn(prepared); + setStep('depositFee'); + }, + ); + return; + } + + if (step === 'depositFee') { + setStep('depositing'); + await startSign( + depositTxn!, + sig => { + signedAction.current = sig; + }, + async () => { + await broadcast(signedAction.current!, depositTxn!); + setStep('done'); + }, + ); + } + }; + + // Redeem flow + + const handleRedeemFlow = async () => { + const chain = getChain(); + const walletAddress = selectedAccount!.xpubOrAddress; + const numAmount = parseFloat(redeemAmount); + const mevUsdAddress = MEV_USD_ADDRESS[chain]; + const tokenOutAddress = getRedeemTokenOutAddress(); + + const resolveRedeemPath = (): RedeemPath => { + const liquidity = parseFloat(vaultInfo?.instantLiquidity ?? '0'); + const canInstant = numAmount <= liquidity; + if (!canInstant) return 'queue'; // force queue, insufficient instant liquidity + return userRedeemChoice; // user's choice when both are available + }; + + if (step === 'redeem-input') { + const path = resolveRedeemPath(); + setRedeemPath(path); + + const allowance = await hyspService.checkAllowance({ + chain, + walletAddress, + tokenAddress: mevUsdAddress, + vaultType: 'redeem', + amount: numAmount, + }); + const initTxn = await getInitTxn(); + + if (!allowance.sufficient) { + const prepared = await prepareApproveRedeem({ + accountId: selectedAccount!.__id ?? '', + db: getDB(), + txn: initTxn, + chain, + walletAddress, + amount: numAmount, + }); + setRedeemApproveTxn(prepared); + setStep('redeem-approve-fee'); + } else { + const prepared = + path === 'instant' + ? await prepareRedeemInstant({ + accountId: selectedAccount!.__id ?? '', + db: getDB(), + txn: initTxn, + chain, + walletAddress, + tokenOut: tokenOutAddress, + amount: numAmount, + }) + : await prepareRedeemQueue({ + accountId: selectedAccount!.__id ?? '', + db: getDB(), + txn: initTxn, + chain, + walletAddress, + tokenOut: tokenOutAddress, + amount: numAmount, + }); + setRedeemTxn(prepared); + setStep('redeem-fee'); + } + return; + } + + if (step === 'redeem-approve-fee') { + setStep('redeem-approving'); + await startSign( + redeemApproveTxn!, + sig => { + signedApprove.current = sig; + }, + async () => { + const txHash = await broadcast( + signedApprove.current!, + redeemApproveTxn!, + ); + setStep('redeem-polling'); + const confirmed = await pollConfirmation(txHash); + if (!confirmed) { + setError('Approval transaction failed or timed out'); + setStep('error'); + return; + } + const path = redeemPath!; + const initTxn2 = await getInitTxn(); + const prepared = + path === 'instant' + ? await prepareRedeemInstant({ + accountId: selectedAccount!.__id ?? '', + db: getDB(), + txn: initTxn2, + chain, + walletAddress, + tokenOut: tokenOutAddress, + amount: numAmount, + }) + : await prepareRedeemQueue({ + accountId: selectedAccount!.__id ?? '', + db: getDB(), + txn: initTxn2, + chain, + walletAddress, + tokenOut: tokenOutAddress, + amount: numAmount, + }); + setRedeemTxn(prepared); + setStep('redeem-fee'); + }, + ); + return; + } + + if (step === 'redeem-fee') { + setStep('redeeming'); + await startSign( + redeemTxn!, + sig => { + signedAction.current = sig; + }, + async () => { + await broadcast(signedAction.current!, redeemTxn!); + setStep('redeem-done'); + }, + ); + } + }; + + // Main entry + + const onProceed = async () => { + try { + if (mode === 'deposit') { + await handleDepositFlow(); + } else { + await handleRedeemFlow(); + } + } catch (e: any) { + logger.error('HYSP flow error', e as object); + setError(e?.message ?? 'An error occurred'); + setStep('error'); + } + }; + + const contextValue = React.useMemo( + () => ({ + mode, + setMode, + step, + selectedWallet, + selectedAccount, + walletDropdownList, + accountDropdownList, + handleWalletChange, + handleAccountChange, + selectedToken, + setSelectedToken, + amount, + setAmount, + approveTxn, + depositTxn, + redeemAmount, + setRedeemAmount, + redeemTokenOut, + setRedeemTokenOut, + userRedeemChoice, + setUserRedeemChoice, + instantAvailable, + redeemPath, + redeemApproveTxn, + redeemTxn, + deviceEvents, + error, + vaultInfo, + position, + vaultInfoLoading, + onProceed, + onClose, + }), + [ + mode, + setMode, + step, + selectedWallet, + selectedAccount, + walletDropdownList, + accountDropdownList, + handleWalletChange, + handleAccountChange, + selectedToken, + setSelectedToken, + amount, + setAmount, + approveTxn, + depositTxn, + redeemAmount, + setRedeemAmount, + redeemTokenOut, + setRedeemTokenOut, + userRedeemChoice, + setUserRedeemChoice, + instantAvailable, + redeemPath, + redeemApproveTxn, + redeemTxn, + deviceEvents, + error, + vaultInfo, + position, + vaultInfoLoading, + onProceed, + onClose, + ], + ); + + return ( + {children} + ); +}; + +export const useHysp = () => useContext(HyspContext); diff --git a/packages/cysync-core/src/services/hyspService.ts b/packages/cysync-core/src/services/hyspService.ts new file mode 100644 index 000000000..32ca785a6 --- /dev/null +++ b/packages/cysync-core/src/services/hyspService.ts @@ -0,0 +1,88 @@ +import axios from 'axios'; +import { HyspChain } from '@cypherock/coin-support-evm'; +import { makePostRequest } from '@cypherock/cysync-utils'; + +const HYSP_API_BASE = 'http://localhost:5001'; +const BASE = `${HYSP_API_BASE}/hysp`; + +export interface IHyspVaultInfo { + apy: number; + price: string; + instantLiquidity: string; + depositFee: number; + redeemFee: number; +} + +export interface IHyspPosition { + usdcBalance: string; + usdtBalance: string | null; + mevUsdBalance: string; + mevUsdValueUsdc: string; +} + +export interface IHyspAllowance { + allowance: string; + sufficient: boolean; +} + +export interface IHyspQueueRequest { + requestId: string; + amountMToken: string; + mevUsdValueUsdc: string; + status: 'Pending' | 'Processed' | 'Canceled'; + tokenOut: string; + feeAmount: string; + mTokenRate: string; + tokenOutRate: string; +} + +export const getVaultInfo = async ( + chain: HyspChain, +): Promise => { + const res = await axios.get(`${BASE}/vault-info`, { params: { chain } }); + return res.data.data; +}; + +export const getUserPosition = async ( + chain: HyspChain, + walletAddress: string, +): Promise => { + const res = await axios.get(`${BASE}/position`, { + params: { chain, walletAddress }, + }); + return res.data.data; +}; + +export const getQueueStatus = async ( + chain: HyspChain, + walletAddress: string, + status?: 'Pending' | 'Processed' | 'Canceled', +): Promise => { + const res = await axios.get(`${BASE}/queue-status`, { + params: { chain, walletAddress, ...(status ? { status } : {}) }, + }); + return res.data.data; +}; + +export const checkAllowance = async (params: { + chain: HyspChain; + walletAddress: string; + tokenAddress: string; + vaultType: 'deposit' | 'redeem'; + amount: number; +}): Promise => { + const res = await makePostRequest(`${BASE}/check-allowance`, params); + return res.data.data; +}; + +export const getEthTransactionStatus = async ( + txHash: string, + network: string, +): Promise => { + const res = await makePostRequest( + `${HYSP_API_BASE}/eth/transaction/transaction-status`, + { txHash, network }, + ); + // Returns receipt with status 1 (success) | 0 (failed) | null (pending) + return res.data?.result?.status ?? null; +}; From 523f77a949a47742a4aae1fc9442578d4b678378 Mon Sep 17 00:00:00 2001 From: Keyur Vaitha Date: Tue, 2 Jun 2026 19:47:11 +0530 Subject: [PATCH 5/7] feat: add geo-blocking for midas vault --- .../src/operations/staking/index.ts | 17 ++++++++---- .../src/operations/staking/types.ts | 2 ++ .../src/bgTask/countryTask/index.tsx | 24 +++++++++++++++++ packages/cysync-core/src/bgTask/index.tsx | 2 ++ .../cysync-core/src/context/hysp/index.tsx | 22 ++++++++++++--- .../src/services/countryService.ts | 11 ++++++++ .../cysync-core/src/services/hyspService.ts | 17 +++++++++--- .../cysync-core/src/store/country/index.ts | 27 +++++++++++++++++++ packages/cysync-core/src/store/index.ts | 1 + packages/cysync-core/src/store/store.ts | 3 +++ 10 files changed, 115 insertions(+), 11 deletions(-) create mode 100644 packages/cysync-core/src/bgTask/countryTask/index.tsx create mode 100644 packages/cysync-core/src/services/countryService.ts create mode 100644 packages/cysync-core/src/store/country/index.ts diff --git a/packages/coin-support-evm/src/operations/staking/index.ts b/packages/coin-support-evm/src/operations/staking/index.ts index dbcc9e185..b9b8d4d58 100644 --- a/packages/coin-support-evm/src/operations/staking/index.ts +++ b/packages/coin-support-evm/src/operations/staking/index.ts @@ -11,12 +11,14 @@ export * from './types'; export const prepareApproveDeposit = async ( params: IHyspEvmPrepareParams, ): Promise => { - const { txn, chain, walletAddress, tokenAddress, amount } = params; + const { txn, chain, walletAddress, tokenAddress, amount, countryCode } = + params; const serverTx = await callHyspBuildEndpoint('build/approve-deposit', { chain, walletAddress, tokenAddress, amount, + ...(countryCode ? { countryCode } : {}), }); return buildPreparedTxn(serverTx, txn); }; @@ -24,12 +26,14 @@ export const prepareApproveDeposit = async ( export const prepareDeposit = async ( params: IHyspEvmPrepareParams, ): Promise => { - const { txn, chain, walletAddress, tokenAddress, amount } = params; + const { txn, chain, walletAddress, tokenAddress, amount, countryCode } = + params; const serverTx = await callHyspBuildEndpoint('build/deposit', { chain, walletAddress, tokenAddress, amount, + ...(countryCode ? { countryCode } : {}), }); return buildPreparedTxn(serverTx, txn); }; @@ -37,11 +41,12 @@ export const prepareDeposit = async ( export const prepareApproveRedeem = async ( params: IHyspEvmApproveRedeemParams, ): Promise => { - const { txn, chain, walletAddress, amount } = params; + const { txn, chain, walletAddress, amount, countryCode } = params; const serverTx = await callHyspBuildEndpoint('build/approve-redeem', { chain, walletAddress, amount, + ...(countryCode ? { countryCode } : {}), }); return buildPreparedTxn(serverTx, txn); }; @@ -49,12 +54,13 @@ export const prepareApproveRedeem = async ( export const prepareRedeemInstant = async ( params: IHyspEvmRedeemPrepareParams, ): Promise => { - const { txn, chain, walletAddress, tokenOut, amount } = params; + const { txn, chain, walletAddress, tokenOut, amount, countryCode } = params; const serverTx = await callHyspBuildEndpoint('build/redeem-instant', { chain, walletAddress, tokenAddress: tokenOut, amount, + ...(countryCode ? { countryCode } : {}), }); return buildPreparedTxn(serverTx, txn); }; @@ -62,12 +68,13 @@ export const prepareRedeemInstant = async ( export const prepareRedeemQueue = async ( params: IHyspEvmRedeemPrepareParams, ): Promise => { - const { txn, chain, walletAddress, tokenOut, amount } = params; + const { txn, chain, walletAddress, tokenOut, amount, countryCode } = params; const serverTx = await callHyspBuildEndpoint('build/redeem-queue', { chain, walletAddress, tokenAddress: tokenOut, amount, + ...(countryCode ? { countryCode } : {}), }); return buildPreparedTxn(serverTx, txn); }; diff --git a/packages/coin-support-evm/src/operations/staking/types.ts b/packages/coin-support-evm/src/operations/staking/types.ts index df6ed3179..c28851fd6 100644 --- a/packages/coin-support-evm/src/operations/staking/types.ts +++ b/packages/coin-support-evm/src/operations/staking/types.ts @@ -12,6 +12,7 @@ export interface IHyspEvmPrepareParams { walletAddress: string; tokenAddress: string; amount: number; + countryCode?: string; } // no tokenOut needed, always approves mevUSD @@ -22,6 +23,7 @@ export interface IHyspEvmApproveRedeemParams { chain: HyspChain; walletAddress: string; amount: number; + countryCode?: string; } // Used for redeemInstant and redeemQueue (tokenOut required) diff --git a/packages/cysync-core/src/bgTask/countryTask/index.tsx b/packages/cysync-core/src/bgTask/countryTask/index.tsx new file mode 100644 index 000000000..a24abeb72 --- /dev/null +++ b/packages/cysync-core/src/bgTask/countryTask/index.tsx @@ -0,0 +1,24 @@ +import React, { useEffect } from 'react'; + +import { getUserCountry } from '~/services/countryService'; +import logger from '~/utils/logger'; + +import { setCountryCode, useAppDispatch } from '../..'; + +export const CountryTask: React.FC = () => { + const dispatch = useAppDispatch(); + + useEffect(() => { + getUserCountry() + .then(code => { + dispatch(setCountryCode(code)); + logger.info('Country detected', { countryCode: code }); + }) + .catch(e => { + logger.warn('Country detection failed', e as object); + dispatch(setCountryCode(undefined)); + }); + }, []); + + return null; +}; diff --git a/packages/cysync-core/src/bgTask/index.tsx b/packages/cysync-core/src/bgTask/index.tsx index 564c57908..a0891d0dc 100644 --- a/packages/cysync-core/src/bgTask/index.tsx +++ b/packages/cysync-core/src/bgTask/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { AccountSyncTask } from './accountsSync'; +import { CountryTask } from './countryTask'; import { DatabaseListener } from './dbListener'; import { DeviceHandlingTask } from './deviceHandlingTask'; import { NetworkPingTask } from './networkTask'; @@ -19,5 +20,6 @@ export const BackgroundTasks = () => ( + ); diff --git a/packages/cysync-core/src/context/hysp/index.tsx b/packages/cysync-core/src/context/hysp/index.tsx index 2d4c0b69b..ae7d3c785 100644 --- a/packages/cysync-core/src/context/hysp/index.tsx +++ b/packages/cysync-core/src/context/hysp/index.tsx @@ -36,6 +36,7 @@ import { COIN_ID_TO_CHAIN, } from '../../constants/hysp'; import * as hyspService from '../../services/hyspService'; +import { selectCountry, useAppSelector } from '../../store'; // Types @@ -108,6 +109,7 @@ export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ onClose, }) => { const { connection } = useDevice(); + const { countryCode } = useAppSelector(selectCountry); const [mode, setModeState] = useState('deposit'); const [step, setStep] = useState('input'); @@ -165,8 +167,12 @@ export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ if (!chain) return; setVaultInfoLoading(true); Promise.all([ - hyspService.getVaultInfo(chain), - hyspService.getUserPosition(chain, selectedAccount.xpubOrAddress), + hyspService.getVaultInfo(chain, countryCode), + hyspService.getUserPosition( + chain, + selectedAccount.xpubOrAddress, + countryCode, + ), ]) .then(([info, pos]) => { setVaultInfo(info); @@ -174,7 +180,7 @@ export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ }) .catch(e => logger.error('HYSP vault info fetch failed', e as object)) .finally(() => setVaultInfoLoading(false)); - }, [selectedAccount?.assetId, selectedAccount?.xpubOrAddress]); + }, [selectedAccount?.assetId, selectedAccount?.xpubOrAddress, countryCode]); // Mode toggle @@ -324,6 +330,7 @@ export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ tokenAddress, vaultType: 'deposit', amount: numAmount, + countryCode, }); const initTxn = await getInitTxn(); if (!allowance.sufficient) { @@ -335,6 +342,7 @@ export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ walletAddress, tokenAddress, amount: numAmount, + countryCode, }); setApproveTxn(prepared); setStep('approveFee'); @@ -347,6 +355,7 @@ export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ walletAddress, tokenAddress, amount: numAmount, + countryCode, }); setDepositTxn(prepared); setStep('depositFee'); @@ -379,6 +388,7 @@ export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ walletAddress, tokenAddress, amount: numAmount, + countryCode, }); setDepositTxn(prepared); setStep('depositFee'); @@ -428,6 +438,7 @@ export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ tokenAddress: mevUsdAddress, vaultType: 'redeem', amount: numAmount, + countryCode, }); const initTxn = await getInitTxn(); @@ -439,6 +450,7 @@ export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ chain, walletAddress, amount: numAmount, + countryCode, }); setRedeemApproveTxn(prepared); setStep('redeem-approve-fee'); @@ -453,6 +465,7 @@ export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ walletAddress, tokenOut: tokenOutAddress, amount: numAmount, + countryCode, }) : await prepareRedeemQueue({ accountId: selectedAccount!.__id ?? '', @@ -462,6 +475,7 @@ export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ walletAddress, tokenOut: tokenOutAddress, amount: numAmount, + countryCode, }); setRedeemTxn(prepared); setStep('redeem-fee'); @@ -500,6 +514,7 @@ export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ walletAddress, tokenOut: tokenOutAddress, amount: numAmount, + countryCode, }) : await prepareRedeemQueue({ accountId: selectedAccount!.__id ?? '', @@ -509,6 +524,7 @@ export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ walletAddress, tokenOut: tokenOutAddress, amount: numAmount, + countryCode, }); setRedeemTxn(prepared); setStep('redeem-fee'); diff --git a/packages/cysync-core/src/services/countryService.ts b/packages/cysync-core/src/services/countryService.ts new file mode 100644 index 000000000..cf4430334 --- /dev/null +++ b/packages/cysync-core/src/services/countryService.ts @@ -0,0 +1,11 @@ +const COUNTRY_API_URL = 'https://api.country.is/'; + +export const getUserCountry = async (): Promise => { + try { + const res = await fetch(COUNTRY_API_URL); + const data = await res.json(); + return (data.country as string | undefined) ?? undefined; + } catch { + return undefined; + } +}; diff --git a/packages/cysync-core/src/services/hyspService.ts b/packages/cysync-core/src/services/hyspService.ts index 32ca785a6..5cef9b3a2 100644 --- a/packages/cysync-core/src/services/hyspService.ts +++ b/packages/cysync-core/src/services/hyspService.ts @@ -38,17 +38,21 @@ export interface IHyspQueueRequest { export const getVaultInfo = async ( chain: HyspChain, + countryCode?: string, ): Promise => { - const res = await axios.get(`${BASE}/vault-info`, { params: { chain } }); + const res = await axios.get(`${BASE}/vault-info`, { + params: { chain, ...(countryCode ? { countryCode } : {}) }, + }); return res.data.data; }; export const getUserPosition = async ( chain: HyspChain, walletAddress: string, + countryCode?: string, ): Promise => { const res = await axios.get(`${BASE}/position`, { - params: { chain, walletAddress }, + params: { chain, walletAddress, ...(countryCode ? { countryCode } : {}) }, }); return res.data.data; }; @@ -57,9 +61,15 @@ export const getQueueStatus = async ( chain: HyspChain, walletAddress: string, status?: 'Pending' | 'Processed' | 'Canceled', + countryCode?: string, ): Promise => { const res = await axios.get(`${BASE}/queue-status`, { - params: { chain, walletAddress, ...(status ? { status } : {}) }, + params: { + chain, + walletAddress, + ...(status ? { status } : {}), + ...(countryCode ? { countryCode } : {}), + }, }); return res.data.data; }; @@ -70,6 +80,7 @@ export const checkAllowance = async (params: { tokenAddress: string; vaultType: 'deposit' | 'redeem'; amount: number; + countryCode?: string; }): Promise => { const res = await makePostRequest(`${BASE}/check-allowance`, params); return res.data.data; diff --git a/packages/cysync-core/src/store/country/index.ts b/packages/cysync-core/src/store/country/index.ts new file mode 100644 index 000000000..799cd9b26 --- /dev/null +++ b/packages/cysync-core/src/store/country/index.ts @@ -0,0 +1,27 @@ +import { PayloadAction, createSlice } from '@reduxjs/toolkit'; + +import { RootState } from '../store'; + +export interface ICountryState { + countryCode: string | undefined; +} + +const initialState: ICountryState = { + countryCode: undefined, +}; + +export const countrySlice = createSlice({ + name: 'country', + initialState, + reducers: { + setCountryCode: (state, action: PayloadAction) => { + state.countryCode = action.payload; + }, + }, +}); + +export const { setCountryCode } = countrySlice.actions; + +export const selectCountry = (state: RootState) => state.country; + +export default countrySlice.reducer; diff --git a/packages/cysync-core/src/store/index.ts b/packages/cysync-core/src/store/index.ts index 390aa6bc6..4e7ed44a6 100644 --- a/packages/cysync-core/src/store/index.ts +++ b/packages/cysync-core/src/store/index.ts @@ -16,3 +16,4 @@ export * from './inheritance'; export * from './lastConnectedFirmware'; export * from './canton'; export * from './buySell'; +export * from './country'; diff --git a/packages/cysync-core/src/store/store.ts b/packages/cysync-core/src/store/store.ts index 4b4dcbe2b..603807836 100644 --- a/packages/cysync-core/src/store/store.ts +++ b/packages/cysync-core/src/store/store.ts @@ -14,6 +14,7 @@ import { import accountReducer, { IAccountState } from './account'; import accountSyncReducer, { IAccountSyncState } from './accountSync'; import buySellOrderReducer, { IBuySellOrderState } from './buySell'; +import countryReducer, { ICountryState } from './country'; import cantonReducer, { ICantonState } from './canton'; import deviceReducer, { IDeviceState } from './device'; import dialogReducer, { IDialogState } from './dialog'; @@ -49,6 +50,7 @@ export interface RootState { lastConnectedFirmware: ILastConnectedFirmwareState; canton: ICantonState; buySellOrder: IBuySellOrderState; + country: ICountryState; } export const store = configureStore({ @@ -70,6 +72,7 @@ export const store = configureStore({ lastConnectedFirmware: lastConnectedFirmwareReducer, canton: cantonReducer, buySellOrder: buySellOrderReducer, + country: countryReducer, }, }); From 58bb14321d9c09735de92ddd36c39d9b271746c2 Mon Sep 17 00:00:00 2001 From: Keyur Vaitha Date: Wed, 3 Jun 2026 23:37:39 +0530 Subject: [PATCH 6/7] feat: add mvp ui for evm and base hysp --- .../cysync-core/src/actions/dialog/index.ts | 3 + .../cysync-core/src/components/SideBar.tsx | 14 ++ packages/cysync-core/src/dialogs/index.tsx | 2 + .../pages/MainApp/Hysp/Pages/HyspDeposit.tsx | 129 +++++++++++++++ .../pages/MainApp/Hysp/Pages/HyspRedeem.tsx | 149 ++++++++++++++++++ .../pages/MainApp/Hysp/Pages/HyspStatus.tsx | 148 +++++++++++++++++ .../MainApp/Hysp/Pages/HyspVaultInfo.tsx | 67 ++++++++ .../src/pages/MainApp/Hysp/index.tsx | 92 +++++++++++ .../cysync-core/src/store/dialog/index.ts | 3 + .../cysync-core/src/store/dialog/types.ts | 5 + 10 files changed, 612 insertions(+) create mode 100644 packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspDeposit.tsx create mode 100644 packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspRedeem.tsx create mode 100644 packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspStatus.tsx create mode 100644 packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspVaultInfo.tsx create mode 100644 packages/cysync-core/src/pages/MainApp/Hysp/index.tsx diff --git a/packages/cysync-core/src/actions/dialog/index.ts b/packages/cysync-core/src/actions/dialog/index.ts index 4371f7706..bc5a37a3b 100644 --- a/packages/cysync-core/src/actions/dialog/index.ts +++ b/packages/cysync-core/src/actions/dialog/index.ts @@ -53,6 +53,9 @@ export const openAddTokenDialog = (props?: AddTokenDialogProps) => export const openReceiveDialog = (data?: ReceiveDialogProps) => openDialog({ name: 'receive', data }); +export const openHyspDialog = () => + openDialog({ name: 'hyspDialog', data: undefined }); + export const openSendDialog = (data?: SendDialogProps) => openDialog({ name: 'sendDialog', data }); diff --git a/packages/cysync-core/src/components/SideBar.tsx b/packages/cysync-core/src/components/SideBar.tsx index 49672123d..284fece69 100644 --- a/packages/cysync-core/src/components/SideBar.tsx +++ b/packages/cysync-core/src/components/SideBar.tsx @@ -31,6 +31,7 @@ import { IWallet } from '@cypherock/db-interfaces'; import React, { FC } from 'react'; import { + openHyspDialog, openReceiveDialog, openSendDialog, openWalletConnectDialog, @@ -211,6 +212,19 @@ const SideBarComponent: FC = () => { } /> )} + dispatch(openHyspDialog())} + extraRight={ + + + {strings.new} + + + } + /> {window.cysyncFeatureFlags.COVER && ( = { walletSyncError: WalletSyncError, @@ -65,6 +66,7 @@ export const dialogs: Record = { guidedFlow: GuidedFlow, addAccount: AddAccountDialog, addToken: AddTokenDialog, + hyspDialog: HyspPage, sendDialog: SendDialog, deployAccountDialog: DeployAccountDialog, historyDialog: HistoryDialog, diff --git a/packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspDeposit.tsx b/packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspDeposit.tsx new file mode 100644 index 000000000..bd56bfb7e --- /dev/null +++ b/packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspDeposit.tsx @@ -0,0 +1,129 @@ +import { getParsedAmount, getDefaultUnit } from '@cypherock/coin-support-utils'; +import { + Button, + Container, + Dropdown, + Flex, + Input, + Typography, +} from '@cypherock/cysync-ui'; +import React from 'react'; + +import { useHysp } from '~/context/hysp'; + +const formatFee = (fee: string | undefined, coinId: string): string => { + if (!fee || fee === '0') return ''; + try { + const { amount, unit } = getParsedAmount({ + coinId, + unitAbbr: getDefaultUnit(coinId).abbr, + amount: fee, + }); + return `${amount} ${unit.abbr}`; + } catch { + return ''; + } +}; + +export const HyspDeposit: React.FC = () => { + const { + selectedWallet, + selectedAccount, + walletDropdownList, + accountDropdownList, + handleWalletChange, + handleAccountChange, + selectedToken, + setSelectedToken, + amount, + setAmount, + onProceed, + depositTxn, + approveTxn, + step, + } = useHysp(); + + const isBase = selectedAccount?.assetId === 'base'; + const coinId = + selectedAccount?.parentAssetId ?? selectedAccount?.assetId ?? ''; + + const tokenOptions = [ + { id: 'usdc', text: 'USDC', checkType: 'radio' as const }, + ...(!isBase + ? [{ id: 'usdt', text: 'USDT', checkType: 'radio' as const }] + : []), + ]; + + let feeLabel = ''; + if (step === 'approveFee') + feeLabel = `Approve gas: ${formatFee( + approveTxn?.computedData.fee, + coinId, + )}`; + else if (step === 'depositFee') + feeLabel = `Deposit gas: ${formatFee( + depositTxn?.computedData.fee, + coinId, + )}`; + + const canProceed = !!selectedAccount && !!amount && parseFloat(amount) > 0; + + let buttonLabel = 'Continue'; + if (step === 'approveFee') buttonLabel = 'Approve & Continue'; + else if (step === 'depositFee') buttonLabel = 'Confirm Deposit'; + + return ( + + Deposit USDC / USDT + + + + + + + + setSelectedToken((id ?? 'usdc') as 'usdc' | 'usdt') + } + placeholderText="Select Token" + searchText="" + disabled={!selectedAccount} + /> + + setAmount(val)} + placeholder="Amount" + disabled={!selectedAccount} + /> + + {feeLabel && ( + + {feeLabel} + + )} + + + + + ); +}; diff --git a/packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspRedeem.tsx b/packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspRedeem.tsx new file mode 100644 index 000000000..dac5b9763 --- /dev/null +++ b/packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspRedeem.tsx @@ -0,0 +1,149 @@ +import { + Button, + Container, + Dropdown, + Flex, + Input, + MessageBox, + Typography, +} from '@cypherock/cysync-ui'; +import React, { useMemo } from 'react'; + +import { useHysp } from '~/context/hysp'; + +export const HyspRedeem: React.FC = () => { + const { + selectedAccount, + redeemAmount, + setRedeemAmount, + redeemTokenOut, + setRedeemTokenOut, + userRedeemChoice, + setUserRedeemChoice, + instantAvailable, + vaultInfo, + position, + onProceed, + } = useHysp(); + + const isBase = selectedAccount?.assetId === 'base'; + + // mevUSD equivalent in USDC for display + const usdcEquivalent = useMemo(() => { + const num = parseFloat(redeemAmount); + const price = parseFloat(vaultInfo?.price ?? '0'); + if (!num || !price) return ''; + return (num * price).toFixed(4); + }, [redeemAmount, vaultInfo?.price]); + + const tokenOutOptions = [ + { id: 'usdc', text: 'USDC', checkType: 'radio' as const }, + ...(!isBase + ? [{ id: 'usdt', text: 'USDT', checkType: 'radio' as const }] + : []), + ]; + + const maxMevUsd = position?.mevUsdBalance ?? '0'; + const canProceed = + !!selectedAccount && + !!redeemAmount && + parseFloat(redeemAmount) > 0 && + parseFloat(redeemAmount) <= parseFloat(maxMevUsd); + + // Effective path: user's choice when instant is available, forced queue otherwise + const effectivePath = instantAvailable ? userRedeemChoice : 'queue'; + const redeemFeePercent = vaultInfo?.redeemFee ?? 0; + const hasAmount = !!redeemAmount && parseFloat(redeemAmount) > 0; + + return ( + + Redeem mevUSD + + + setRedeemAmount(val)} + placeholder={`mevUSD amount (max: ${parseFloat(maxMevUsd).toFixed( + 4, + )})`} + disabled={!selectedAccount} + /> + + {usdcEquivalent && ( + + ≈ {usdcEquivalent} USDC + + )} + + {!isBase && ( + + setRedeemTokenOut((id ?? 'usdc') as 'usdc' | 'usdt') + } + placeholderText="Receive as" + searchText="" + disabled={!selectedAccount} + /> + )} + + {/* Redemption type toggle — always shown when instant is available */} + {hasAmount && instantAvailable && ( + + Redemption type + + + + + + )} + + {/* Routing info */} + {hasAmount && ( + <> + {effectivePath === 'instant' && ( + 0 ? ` (${redeemFeePercent}% fee)` : '' + }`} + /> + )} + {effectivePath === 'queue' && instantAvailable && ( + + )} + {effectivePath === 'queue' && !instantAvailable && ( + + )} + + )} + + + + + ); +}; diff --git a/packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspStatus.tsx b/packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspStatus.tsx new file mode 100644 index 000000000..d21692bd6 --- /dev/null +++ b/packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspStatus.tsx @@ -0,0 +1,148 @@ +import { SignTransactionDeviceEvent } from '@cypherock/coin-support-interfaces'; +import { + ArrowRightIcon, + Button, + Check, + Container, + Flex, + LeanBox, + LeanBoxContainer, + LeanBoxProps, + Throbber, + Typography, + VerifyAmountDeviceGraphics, +} from '@cypherock/cysync-ui'; +import React, { useMemo } from 'react'; + +import { useHysp } from '~/context/hysp'; + +const checkIcon = ; +const throbberIcon = ; +const arrowIcon = ; + +export const HyspStatus: React.FC = () => { + const { step, error, deviceEvents, redeemPath, onClose } = useHysp(); + + const isDeviceSigning = [ + 'approving', + 'depositing', + 'redeem-approving', + 'redeeming', + ].includes(step); + + const isPolling = step === 'polling' || step === 'redeem-polling'; + const isDone = step === 'done'; + const isRedeemDone = step === 'redeem-done'; + const isError = step === 'error'; + + const getEventIcon = ( + loadingEvent: SignTransactionDeviceEvent, + completedEvent: SignTransactionDeviceEvent, + ) => { + if (deviceEvents[completedEvent]) return checkIcon; + if (deviceEvents[loadingEvent]) return throbberIcon; + return undefined; + }; + + const deviceSteps = useMemo( + () => [ + { + id: '1', + text: 'Confirm on X1 Vault', + leftImage: arrowIcon, + rightImage: getEventIcon( + SignTransactionDeviceEvent.INIT, + SignTransactionDeviceEvent.CONFIRMED, + ), + }, + { + id: '2', + text: 'Verify transaction details', + leftImage: arrowIcon, + rightImage: getEventIcon( + SignTransactionDeviceEvent.CONFIRMED, + SignTransactionDeviceEvent.VERIFIED, + ), + }, + { + id: '3', + text: 'Tap any card', + leftImage: arrowIcon, + rightImage: getEventIcon( + SignTransactionDeviceEvent.VERIFIED, + SignTransactionDeviceEvent.CARD_TAPPED, + ), + }, + ], + [deviceEvents], + ); + + const getTitle = () => { + if (step === 'approving' || step === 'redeem-approving') + return 'Approve on Device'; + if (step === 'depositing') return 'Confirm Deposit on Device'; + if (step === 'redeeming') return 'Confirm Redeem on Device'; + if (isPolling) return 'Waiting for Approval...'; + if (isDone) return 'Deposit Successful!'; + if (isRedeemDone) + return redeemPath === 'queue' + ? 'Redeem Request Submitted' + : 'Redeem Successful!'; + if (isError) return 'Something Went Wrong'; + return ''; + }; + + const getDescription = () => { + if (step === 'approving' || step === 'redeem-approving') + return 'Review and confirm the approval on your Cypherock X1.'; + if (step === 'depositing') + return 'Review and confirm the deposit on your Cypherock X1.'; + if (step === 'redeeming') + return 'Review and confirm the redemption on your Cypherock X1.'; + if (isPolling) + return 'Your approval is being confirmed on-chain. This may take a few seconds.'; + if (isDone) + return 'Your USDC has been deposited into the HYSP vault successfully.'; + if (isRedeemDone && redeemPath === 'queue') + return 'Your redeem request has been submitted. Funds will be available within 3 business days.'; + if (isRedeemDone) return 'Your mevUSD has been redeemed successfully.'; + if (isError) return error ?? 'An unknown error occurred.'; + return ''; + }; + + return ( + + {isDeviceSigning && ( + <> + + + {deviceSteps.map(s => ( + + ))} + + + )} + + + + {getTitle()} + + + {getDescription()} + + + + {(isDone || isRedeemDone || isError) && ( + + )} + + ); +}; diff --git a/packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspVaultInfo.tsx b/packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspVaultInfo.tsx new file mode 100644 index 000000000..050d8c51f --- /dev/null +++ b/packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspVaultInfo.tsx @@ -0,0 +1,67 @@ +import { Container, Flex, Throbber, Typography } from '@cypherock/cysync-ui'; +import React from 'react'; + +import { useHysp } from '~/context/hysp'; + +export const HyspVaultInfo: React.FC = () => { + const { selectedAccount, vaultInfo, position, vaultInfoLoading } = useHysp(); + + if (vaultInfoLoading) { + return ( + + + + ); + } + + return ( + + HYSP Vault + + {vaultInfo && ( + + APY: {vaultInfo.apy}% + + mevUSD Price: {parseFloat(vaultInfo.price).toFixed(4)} + + + Instant Liquidity:{' '} + {parseFloat(vaultInfo.instantLiquidity).toFixed(2)} USDC + + + Deposit Fee: {vaultInfo.depositFee}% + + + Redeem Fee: {vaultInfo.redeemFee}% + + + )} + + {position && ( + + Your Position + + USDC: {parseFloat(position.usdcBalance).toFixed(4)} + + {position.usdtBalance !== null && ( + + USDT: {parseFloat(position.usdtBalance).toFixed(4)} + + )} + + mevUSD: {parseFloat(position.mevUsdBalance).toFixed(4)} + + + Value: {parseFloat(position.mevUsdValueUsdc).toFixed(4)} USDC + + + )} + + {!selectedAccount && ( + + Select a wallet and account to view your position. + + )} + + ); +}; diff --git a/packages/cysync-core/src/pages/MainApp/Hysp/index.tsx b/packages/cysync-core/src/pages/MainApp/Hysp/index.tsx new file mode 100644 index 000000000..acbd396d5 --- /dev/null +++ b/packages/cysync-core/src/pages/MainApp/Hysp/index.tsx @@ -0,0 +1,92 @@ +import { + BlurOverlay, + Button, + CloseButton, + Container, + DialogBox, + DialogBoxBody, + Flex, +} from '@cypherock/cysync-ui'; +import React from 'react'; + +import { HyspProvider, useHysp } from '~/context/hysp'; +import { closeDialog, useAppDispatch } from '~/store'; + +import { HyspDeposit } from './Pages/HyspDeposit'; +import { HyspRedeem } from './Pages/HyspRedeem'; +import { HyspStatus } from './Pages/HyspStatus'; +import { HyspVaultInfo } from './Pages/HyspVaultInfo'; + +const STATUS_STEPS = new Set([ + 'approving', + 'polling', + 'depositing', + 'done', + 'redeem-approving', + 'redeem-polling', + 'redeeming', + 'redeem-done', + 'error', +]); + +const HyspContent: React.FC = () => { + const { step, mode, setMode, onClose } = useHysp(); + + const isStatusStep = STATUS_STEPS.has(step); + + const showRightPanel = () => { + if (isStatusStep) return ; + if (mode === 'redeem') return ; + return ; + }; + + return ( + + + + + + {/* Left panel — vault info */} + + + + + {/* Right panel — deposit / redeem / status */} + + {/* Mode toggle — only shown on input steps */} + {!isStatusStep && ( + + + + + )} + + {showRightPanel()} + + + + + + ); +}; + +export const HyspPage: React.FC = () => { + const dispatch = useAppDispatch(); + const handleClose = () => dispatch(closeDialog('hyspDialog')); + + return ( + + + + ); +}; diff --git a/packages/cysync-core/src/store/dialog/index.ts b/packages/cysync-core/src/store/dialog/index.ts index ab46c799e..1a75ec4a0 100644 --- a/packages/cysync-core/src/store/dialog/index.ts +++ b/packages/cysync-core/src/store/dialog/index.ts @@ -44,6 +44,9 @@ const initialState: IDialogState = { deployAccountDialog: { isOpen: false, }, + hyspDialog: { + isOpen: false, + }, sendDialog: { isOpen: false, }, diff --git a/packages/cysync-core/src/store/dialog/types.ts b/packages/cysync-core/src/store/dialog/types.ts index 2c63a0ed7..7a84452b1 100644 --- a/packages/cysync-core/src/store/dialog/types.ts +++ b/packages/cysync-core/src/store/dialog/types.ts @@ -61,6 +61,11 @@ export interface IDialogState { data?: undefined; }; + hyspDialog: { + isOpen: boolean; + data?: undefined; + }; + sendDialog: { isOpen: boolean; data?: SendDialogProps; From 095afcce894041f07e76a0c30bb8e0e3e8a7afc2 Mon Sep 17 00:00:00 2001 From: Keyur Vaitha Date: Thu, 18 Jun 2026 06:31:53 +0530 Subject: [PATCH 7/7] feat: Add hysp dashboard and deposit screens --- .../cysync-core/src/actions/dialog/index.ts | 7 +- .../cysync-core/src/components/SideBar.tsx | 6 +- packages/cysync-core/src/constants/hysp.ts | 18 + .../cysync-core/src/constants/routes/index.ts | 4 + .../cysync-core/src/context/hysp/index.tsx | 150 +++++-- packages/cysync-core/src/context/sidebar.tsx | 3 +- .../src/pages/MainApp/Hysp/EarnDashboard.tsx | 423 ++++++++++++++++++ .../pages/MainApp/Hysp/Pages/HyspConsent.tsx | 117 +++++ .../pages/MainApp/Hysp/Pages/HyspDeposit.tsx | 423 +++++++++++++++--- .../pages/MainApp/Hysp/Pages/HyspStatus.tsx | 306 +++++++++++-- .../src/pages/MainApp/Hysp/index.tsx | 147 +++--- .../cysync-core/src/pages/MainApp/index.ts | 1 + .../cysync-core/src/store/dialog/types.ts | 6 +- packages/desktop-ui/src/Router.tsx | 2 + packages/ui/icons/everstake-logo.svg | 12 + 15 files changed, 1411 insertions(+), 214 deletions(-) create mode 100644 packages/cysync-core/src/pages/MainApp/Hysp/EarnDashboard.tsx create mode 100644 packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspConsent.tsx create mode 100644 packages/ui/icons/everstake-logo.svg diff --git a/packages/cysync-core/src/actions/dialog/index.ts b/packages/cysync-core/src/actions/dialog/index.ts index bc5a37a3b..4a778780c 100644 --- a/packages/cysync-core/src/actions/dialog/index.ts +++ b/packages/cysync-core/src/actions/dialog/index.ts @@ -53,8 +53,11 @@ export const openAddTokenDialog = (props?: AddTokenDialogProps) => export const openReceiveDialog = (data?: ReceiveDialogProps) => openDialog({ name: 'receive', data }); -export const openHyspDialog = () => - openDialog({ name: 'hyspDialog', data: undefined }); +export const openHyspDialog = (initialAccountId?: string, initialToken?: 'usdc' | 'usdt', initialWalletId?: string) => + openDialog({ + name: 'hyspDialog', + data: initialAccountId ? { initialAccountId, initialToken, initialWalletId } : undefined, + }); export const openSendDialog = (data?: SendDialogProps) => openDialog({ name: 'sendDialog', data }); diff --git a/packages/cysync-core/src/components/SideBar.tsx b/packages/cysync-core/src/components/SideBar.tsx index 284fece69..56c5383d1 100644 --- a/packages/cysync-core/src/components/SideBar.tsx +++ b/packages/cysync-core/src/components/SideBar.tsx @@ -213,10 +213,10 @@ const SideBarComponent: FC = () => { /> )} dispatch(openHyspDialog())} + state={getState('earn')} + onClick={() => navigate('earn')} extraRight={ diff --git a/packages/cysync-core/src/constants/hysp.ts b/packages/cysync-core/src/constants/hysp.ts index 1de7abff6..c1294f7e9 100644 --- a/packages/cysync-core/src/constants/hysp.ts +++ b/packages/cysync-core/src/constants/hysp.ts @@ -24,3 +24,21 @@ export const COIN_ID_TO_CHAIN: Partial> = { [EvmIdMap.ethereum]: 'eth_mainnet', [EvmIdMap.base]: 'base', }; + +export type EarnProtocol = 'midas' | 'kamino'; + +export interface IStakeableAsset { + parentAssetId: string; // chain: 'ethereum', 'base', 'solana' + assetId: string; // token: 'usdc', 'usdt' + protocol: EarnProtocol; + geoblocked: boolean; // true = hide for blocked countries +} + +export const STAKEABLE_ASSETS: IStakeableAsset[] = [ + { parentAssetId: 'ethereum', assetId: 'ethereum:usd-coin', protocol: 'midas', geoblocked: true }, + { parentAssetId: 'ethereum', assetId: 'ethereum:tether', protocol: 'midas', geoblocked: true }, + { parentAssetId: 'base', assetId: 'base:usd-coin', protocol: 'midas', geoblocked: true }, + // { parentAssetId: 'solana', assetId: 'solana:usd-coin', protocol: 'kamino', geoblocked: false }, +]; + +export const MIDAS_BLOCKED_COUNTRIES: string[] = []; diff --git a/packages/cysync-core/src/constants/routes/index.ts b/packages/cysync-core/src/constants/routes/index.ts index 368ee997b..1505d1829 100644 --- a/packages/cysync-core/src/constants/routes/index.ts +++ b/packages/cysync-core/src/constants/routes/index.ts @@ -42,6 +42,10 @@ const rootRoutes = { name: 'refer-and-earn', path: '/refer-and-earn', }, + earn: { + name: 'earn', + path: '/earn', + }, } as const; export const routes = { diff --git a/packages/cysync-core/src/context/hysp/index.tsx b/packages/cysync-core/src/context/hysp/index.tsx index ae7d3c785..b6ccd359c 100644 --- a/packages/cysync-core/src/context/hysp/index.tsx +++ b/packages/cysync-core/src/context/hysp/index.tsx @@ -11,6 +11,7 @@ import { import { ISignTransactionEvent } from '@cypherock/coin-support-interfaces'; import { evmCoinList, EvmIdMap } from '@cypherock/coins'; import { IAccount, IWallet } from '@cypherock/db-interfaces'; +import { BigNumber } from '@cypherock/cysync-utils'; import lodash from 'lodash'; import React, { Context, @@ -44,6 +45,7 @@ export type HyspMode = 'deposit' | 'redeem'; export type RedeemPath = 'instant' | 'queue'; type HyspStep = + | 'consent' | 'input' | 'approveFee' | 'approving' @@ -77,6 +79,10 @@ interface IHyspContext { setAmount: (a: string) => void; approveTxn: IPreparedEvmTransaction | undefined; depositTxn: IPreparedEvmTransaction | undefined; + depositTxHash: string | undefined; + isFeeLoading: boolean; + customGasPrice: number | undefined; + setCustomGasPrice: (v: number) => void; // redeem redeemAmount: string; setRedeemAmount: (a: string) => void; @@ -90,7 +96,7 @@ interface IHyspContext { redeemTxn: IPreparedEvmTransaction | undefined; // shared deviceEvents: Record; - error: string | undefined; + error: Error | undefined; vaultInfo: hyspService.IHyspVaultInfo | undefined; position: hyspService.IHyspPosition | undefined; vaultInfoLoading: boolean; @@ -104,21 +110,27 @@ const HyspContext: Context = createContext( // Provider -export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ +export const HyspProvider: FC<{ children: ReactNode; onClose: () => void; initialAccountId?: string; initialToken?: 'usdc' | 'usdt'; initialWalletId?: string }> = ({ children, onClose, + initialAccountId, + initialToken, + initialWalletId, }) => { const { connection } = useDevice(); const { countryCode } = useAppSelector(selectCountry); const [mode, setModeState] = useState('deposit'); - const [step, setStep] = useState('input'); + const [step, setStep] = useState('consent'); // deposit - const [selectedToken, setSelectedToken] = useState<'usdc' | 'usdt'>('usdc'); + const [selectedToken, setSelectedToken] = useState<'usdc' | 'usdt'>(initialToken ?? 'usdc'); const [amount, setAmount] = useState(''); const [approveTxn, setApproveTxn] = useState(); const [depositTxn, setDepositTxn] = useState(); + const [depositTxHash, setDepositTxHash] = useState(); + const [isFeeLoading, setIsFeeLoading] = useState(false); + const [customGasPrice, setCustomGasPrice] = useState(); // redeem const [redeemAmount, setRedeemAmount] = useState(''); @@ -134,7 +146,7 @@ export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ const [deviceEvents, setDeviceEvents] = useState< Record >({}); - const [error, setError] = useState(); + const [error, setError] = useState(); const [vaultInfo, setVaultInfo] = useState(); const [position, setPosition] = useState(); const [vaultInfoLoading, setVaultInfoLoading] = useState(false); @@ -151,12 +163,13 @@ export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ const signedAction = useRef(); const { selectedWallet, handleWalletChange, walletDropdownList } = - useWalletDropdown(); + useWalletDropdown(initialWalletId ? { walletId: initialWalletId } : undefined); const { selectedAccount, handleAccountChange, accountDropdownList } = useAccountDropdown({ selectedWallet, assetFilter: [EvmIdMap.ethereum, EvmIdMap.base], + defaultAccountId: initialAccountId, }); // Vault info @@ -186,7 +199,7 @@ export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ const setMode = (m: HyspMode) => { setModeState(m); - setStep(m === 'deposit' ? 'input' : 'redeem-input'); + setStep(m === 'deposit' ? 'consent' : 'redeem-input'); setError(undefined); setDeviceEvents({}); if (m === 'redeem') { @@ -235,6 +248,23 @@ export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ accountId: selectedAccount!.__id ?? '', })) as IPreparedEvmTransaction; + // Recompute computedData with a new gas price — no network calls needed. + // Server data (to, value, data, gasLimit) is already in computedData and doesn't change. + const applyCustomGas = ( + txn: IPreparedEvmTransaction, + gweiOverride: number, + ): IPreparedEvmTransaction => { + const gasPriceWei = String(Math.round(gweiOverride * 1e9)); + const fee = new BigNumber(txn.computedData.gasLimit) + .multipliedBy(gasPriceWei) + .toString(10); + return { + ...txn, + userInputs: { ...txn.userInputs, gasPrice: gasPriceWei }, + computedData: { ...txn.computedData, gasPrice: gasPriceWei, fee }, + }; + }; + const pollConfirmation = async (txHash: string): Promise => { if (!txHash) throw new Error('Broadcast failed — no transaction hash returned'); @@ -282,7 +312,7 @@ export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ }, error: (err: any) => { release(); - setError(err?.message ?? 'Signing failed'); + setError(err instanceof Error ? err : new Error(err?.message ?? 'Signing failed')); setStep('error'); }, complete: () => { @@ -295,7 +325,7 @@ export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ err?.message ?? 'Broadcast failed'; logger.error('HYSP post-sign error', err as object); - setError(msg); + setError(err instanceof Error ? err : new Error(msg)); setStep('error'); }); }, @@ -318,64 +348,81 @@ export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ // Deposit flow const handleDepositFlow = async () => { + // Consent step — just advance to input + if (step === 'consent') { + setStep('input'); + return; + } + const chain = getChain(); const tokenAddress = getDepositTokenAddress(); const walletAddress = selectedAccount!.xpubOrAddress; const numAmount = parseFloat(amount); if (step === 'input') { - const allowance = await hyspService.checkAllowance({ - chain, - walletAddress, - tokenAddress, - vaultType: 'deposit', - amount: numAmount, - countryCode, - }); - const initTxn = await getInitTxn(); - if (!allowance.sufficient) { - const prepared = await prepareApproveDeposit({ - accountId: selectedAccount!.__id ?? '', - db: getDB(), - txn: initTxn, - chain, - walletAddress, - tokenAddress, - amount: numAmount, - countryCode, - }); - setApproveTxn(prepared); - setStep('approveFee'); - } else { - const prepared = await prepareDeposit({ - accountId: selectedAccount!.__id ?? '', - db: getDB(), - txn: initTxn, + // Transition immediately — fee prep happens on the fee screen with a spinner + setStep('approveFee'); + setIsFeeLoading(true); + setCustomGasPrice(undefined); + try { + const allowance = await hyspService.checkAllowance({ chain, walletAddress, tokenAddress, + vaultType: 'deposit', amount: numAmount, countryCode, }); - setDepositTxn(prepared); - setStep('depositFee'); + const initTxn = await getInitTxn(); + if (!allowance.sufficient) { + const prepared = await prepareApproveDeposit({ + accountId: selectedAccount!.__id ?? '', + db: getDB(), + txn: initTxn, + chain, + walletAddress, + tokenAddress, + amount: numAmount, + countryCode, + }); + setApproveTxn(prepared); + } else { + const prepared = await prepareDeposit({ + accountId: selectedAccount!.__id ?? '', + db: getDB(), + txn: initTxn, + chain, + walletAddress, + tokenAddress, + amount: numAmount, + countryCode, + }); + setDepositTxn(prepared); + setStep('depositFee'); + } + } finally { + setIsFeeLoading(false); } return; } if (step === 'approveFee') { setStep('approving'); + // Apply custom gas locally — no API calls, server data in computedData is unchanged + const finalApproveTxn = customGasPrice + ? applyCustomGas(approveTxn!, customGasPrice) + : approveTxn!; await startSign( - approveTxn!, + finalApproveTxn, sig => { signedApprove.current = sig; }, async () => { - const txHash = await broadcast(signedApprove.current!, approveTxn!); + const txHash = await broadcast(signedApprove.current!, finalApproveTxn); setStep('polling'); const confirmed = await pollConfirmation(txHash); if (!confirmed) { - setError('Approval transaction failed or timed out'); + setError(new Error('Approval transaction failed or timed out')); setStep('error'); return; } @@ -391,6 +438,7 @@ export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ countryCode, }); setDepositTxn(prepared); + setCustomGasPrice(undefined); setStep('depositFee'); }, ); @@ -399,13 +447,18 @@ export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ if (step === 'depositFee') { setStep('depositing'); + // Apply custom gas locally — no API calls, server data in computedData is unchanged + const finalDepositTxn = customGasPrice + ? applyCustomGas(depositTxn!, customGasPrice) + : depositTxn!; await startSign( - depositTxn!, + finalDepositTxn, sig => { signedAction.current = sig; }, async () => { - await broadcast(signedAction.current!, depositTxn!); + const txHash = await broadcast(signedAction.current!, finalDepositTxn); + setDepositTxHash(txHash); setStep('done'); }, ); @@ -498,7 +551,7 @@ export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ setStep('redeem-polling'); const confirmed = await pollConfirmation(txHash); if (!confirmed) { - setError('Approval transaction failed or timed out'); + setError(new Error('Approval transaction failed or timed out')); setStep('error'); return; } @@ -559,7 +612,7 @@ export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ } } catch (e: any) { logger.error('HYSP flow error', e as object); - setError(e?.message ?? 'An error occurred'); + setError(e instanceof Error ? e : new Error(e?.message ?? 'An error occurred')); setStep('error'); } }; @@ -581,6 +634,10 @@ export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ setAmount, approveTxn, depositTxn, + depositTxHash, + isFeeLoading, + customGasPrice, + setCustomGasPrice, redeemAmount, setRedeemAmount, redeemTokenOut, @@ -615,6 +672,9 @@ export const HyspProvider: FC<{ children: ReactNode; onClose: () => void }> = ({ setAmount, approveTxn, depositTxn, + depositTxHash, + isFeeLoading, + customGasPrice, redeemAmount, setRedeemAmount, redeemTokenOut, diff --git a/packages/cysync-core/src/context/sidebar.tsx b/packages/cysync-core/src/context/sidebar.tsx index aa5af6586..6321dbcca 100644 --- a/packages/cysync-core/src/context/sidebar.tsx +++ b/packages/cysync-core/src/context/sidebar.tsx @@ -39,7 +39,8 @@ export type Page = | 'help' | 'referAndEarn' | 'swap' - | 'buysell2'; + | 'buysell2' + | 'earn'; export interface SidebarContextInterface { lang: ILangState; diff --git a/packages/cysync-core/src/pages/MainApp/Hysp/EarnDashboard.tsx b/packages/cysync-core/src/pages/MainApp/Hysp/EarnDashboard.tsx new file mode 100644 index 000000000..6429d8dd2 --- /dev/null +++ b/packages/cysync-core/src/pages/MainApp/Hysp/EarnDashboard.tsx @@ -0,0 +1,423 @@ +import { getParsedAmount, getDefaultUnit } from '@cypherock/coin-support-utils'; +import { + Button, + Flex, + Throbber, + Typography, +} from '@cypherock/cysync-ui'; +import { AccountTypeMap } from '@cypherock/db-interfaces'; +import React, { useEffect, useMemo, useState } from 'react'; + +import { openHyspDialog } from '~/actions'; +import { CoinIcon } from '~/components'; +import { useAccounts } from '~/hooks'; +import { MIDAS_BLOCKED_COUNTRIES, STAKEABLE_ASSETS } from '~/constants/hysp'; +import * as hyspService from '~/services/hyspService'; +import { + selectCountry, + selectCurrentCurrencyPriceInfos, + selectWallets, + useAppDispatch, + useAppSelector, +} from '~/store'; +import { useCurrency } from '~/context'; + +import { MainAppLayout } from '../Layout'; + +const COL = { + account: 1, + wallet: 1, + balance: 1, + apy: 1, + vault: 1, + actions: 1, +}; + +const HEADER_COLS = [ + { label: 'Account', flex: COL.account }, + { label: 'Wallet', flex: COL.wallet }, + { label: 'Balance', flex: COL.balance }, + { label: 'APY', flex: COL.apy }, + { label: 'Vault', flex: COL.vault }, + { label: 'Actions', flex: COL.actions }, +]; + +const Cell: React.FC<{ + flex: number; + children: React.ReactNode; + align?: 'flex-start' | 'center' | 'flex-end'; +}> = ({ flex, children, align = 'flex-start' }) => ( +
+ {children} +
+); + +// ─── Number style for summary cards ────────────────────────────────────────── +const bigNumber = (color = '#FFFFFF'): React.CSSProperties => ({ + fontFamily: 'Poppins', + fontWeight: 700, + fontSize: 38, + lineHeight: 1, + color, + letterSpacing: 0, +}); + +export const EarnDashboard: React.FC = () => { + const dispatch = useAppDispatch(); + const accounts = useAccounts(); + const { countryCode } = useAppSelector(selectCountry); + const { currentCurrency } = useCurrency(); + const priceInfos = useAppSelector(state => + selectCurrentCurrencyPriceInfos(state, currentCurrency), + ); + const { wallets } = useAppSelector(selectWallets); + + const [midasApy, setMidasApy] = useState(); + const [apyLoading, setApyLoading] = useState(true); + + useEffect(() => { + setApyLoading(true); + hyspService + .getVaultInfo('eth_mainnet', countryCode) + .then(info => { + setMidasApy(info.apy); + setApyLoading(false); + }) + .catch(() => { + setMidasApy(undefined); + setApyLoading(false); + }); + }, [countryCode]); + + const isMidasBlocked = + !!countryCode && + MIDAS_BLOCKED_COUNTRIES.includes(countryCode.toUpperCase()); + + const stakeableRows = useMemo( + () => + accounts + .filter(a => a.type === AccountTypeMap.subAccount) + .flatMap(account => { + const cfg = STAKEABLE_ASSETS.find( + s => + s.assetId === account.assetId && + s.parentAssetId === account.parentAssetId, + ); + if (!cfg) return []; + if (cfg.geoblocked && isMidasBlocked) return []; + const wallet = wallets.find(w => w.__id === account.walletId); + return [{ account, cfg, walletName: wallet?.name ?? '' }]; + }), + [accounts, isMidasBlocked, wallets], + ); + + const formatTokenBalance = ( + account: (typeof stakeableRows)[0]['account'], + ): { token: string; usd: string } => { + try { + const { amount, unit } = getParsedAmount({ + coinId: account.parentAssetId, + assetId: account.assetId, + unitAbbr: getDefaultUnit(account.parentAssetId, account.assetId).abbr, + amount: account.balance, + }); + const priceInfo = priceInfos.find(p => p.assetId === account.assetId); + const usdVal = priceInfo + ? (parseFloat(amount) * parseFloat(priceInfo.latestPrice)).toFixed(2) + : undefined; + return { + token: `${amount} ${unit.abbr}`, + usd: usdVal !== undefined ? `$${usdVal}` : '', + }; + } catch { + return { token: account.balance, usd: '' }; + } + }; + + const totalUsdValue = useMemo(() => { + let total = 0; + for (const { account } of stakeableRows) { + const priceInfo = priceInfos.find(p => p.assetId === account.assetId); + if (!priceInfo) continue; + try { + const { amount } = getParsedAmount({ + coinId: account.parentAssetId, + assetId: account.assetId, + unitAbbr: getDefaultUnit(account.parentAssetId, account.assetId).abbr, + amount: account.balance, + }); + total += parseFloat(amount) * parseFloat(priceInfo.latestPrice); + } catch { + // skip + } + } + return total; + }, [stakeableRows, priceInfos]); + + const estimatedYearlyRewards = useMemo(() => { + if (!midasApy) return 0; + return totalUsdValue * (midasApy / 100); + }, [totalUsdValue, midasApy]); + + return ( + +
+ {/* ── Summary cards ───────────────────────────────────────────── */} + + {/* Amount available */} +
+ + Amount available to earn + + ${totalUsdValue.toFixed(2)} + + All of your stakeable assets from your portfolio + +
+ + {/* Yearly rewards */} +
+ + Rewards you could earn + + {apyLoading ? ( + + ) : ( + + ${estimatedYearlyRewards.toFixed(2)} + + )} + {!apyLoading && midasApy !== undefined && ( + + Per year · {midasApy.toFixed(2)}% APY + + )} +
+
+ + {/* ── Assets table ────────────────────────────────────────────── */} + {stakeableRows.length === 0 ? ( + + {isMidasBlocked + ? 'Earn is not available in your region.' + : 'No supported assets found. Add a USDC or USDT account on ETH or Base to get started.'} + + ) : ( +
+ Available assets + + {/* Table card */} +
+ {/* Header row */} +
+ {HEADER_COLS.map(col => ( +
+ + {col.label} + +
+ ))} +
+ + {/* Data rows */} + {stakeableRows.map(({ account, cfg, walletName }, index) => { + const { token, usd } = formatTokenBalance(account); + const apy = + midasApy !== undefined ? `${midasApy.toFixed(2)}%` : '—'; + const vaultLabel = + cfg.protocol === 'midas' ? 'Midas Vault' : 'Kamino Vault'; + const tokenSymbol = account.assetId.includes('tether') + ? 'usdt' + : 'usdc'; + const chainLabel = + account.parentAssetId.charAt(0).toUpperCase() + + account.parentAssetId.slice(1); + const isEven = index % 2 === 0; + const isLast = index === stakeableRows.length - 1; + + return ( +
+ {/* Account */} + + + + + + {account.name} + + + {chainLabel} + + + + + + {/* Wallet */} + + + {walletName} + + + + {/* Balance */} + + + {token} + + {usd ? ( + + {usd} + + ) : null} + + + {/* APY */} + + {apyLoading ? ( + + ) : ( + <> + + {apy} + + + APY + + + )} + + + {/* Vault */} + + + {vaultLabel} + + + + {/* Actions */} + + + +
+ ); + })} +
+
+ )} +
+
+ ); +}; diff --git a/packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspConsent.tsx b/packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspConsent.tsx new file mode 100644 index 000000000..f3059f73a --- /dev/null +++ b/packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspConsent.tsx @@ -0,0 +1,117 @@ +import EverstakeLogo from '@cypherock/cysync-ui/dist/esm/assets/icons/generated/EverstakeLogo'; +import AccountIcon from '@cypherock/cysync-ui/dist/esm/assets/icons/generated/AccountIcon'; +import GoldExternalLink from '@cypherock/cysync-ui/dist/esm/assets/icons/generated/GoldExternalLink'; +import { + Button, + CheckBox, + Flex, +} from '@cypherock/cysync-ui'; +import React, { useState } from 'react'; + +import { useHysp } from '~/context/hysp'; + +const CARD: React.CSSProperties = { + width: 500, + borderRadius: 16, + border: '1px solid #2C2520', + background: 'linear-gradient(180deg, #211C18 0%, #211A16 50%, #252219 100%)', + boxShadow: '4px 4px 32px 4px #0F0D0B', + padding: 32, + display: 'flex', + flexDirection: 'column', + gap: 24, + alignItems: 'center', +}; + +const INFO_BOX: React.CSSProperties = { + width: '100%', + borderRadius: 8, + border: '1px solid #3C3C3C', + background: '#27221D', + padding: 16, + display: 'flex', + flexDirection: 'row', + gap: 12, + alignItems: 'center', + justifyContent: 'center', +}; + + +export const HyspConsent: React.FC = () => { + const { onProceed } = useHysp(); + const [acknowledged, setAcknowledged] = useState(false); + + return ( +
+ {/* Title */} +
+ + Your staked funds are
maintained by Everstake +
+
+ + Powered by + + +
+
+ + {/* Info boxes */} +
+
+ + + Everstake maintains and protects your staked asset with their smart + contracts, infrastructure, and technology. + +
+
+ + + When staking, the responsibility for your funds' security + transitions from your Cypherock X1 Vault to Everstake. + +
+
+ + {/* Learn how it works */} + + + + Learn how it works + + + + {/* Acknowledgment */} +
setAcknowledged(v => !v)} + > +
+ setAcknowledged(v => !v)} + id="hysp-consent" + /> +
+ + I acknowledge and consent to
staking with Everstake +
+
+ + {/* Confirm */} + +
+ ); +}; diff --git a/packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspDeposit.tsx b/packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspDeposit.tsx index bd56bfb7e..76807e006 100644 --- a/packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspDeposit.tsx +++ b/packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspDeposit.tsx @@ -1,15 +1,68 @@ import { getParsedAmount, getDefaultUnit } from '@cypherock/coin-support-utils'; +import { BigNumber } from '@cypherock/cysync-utils'; import { + BlockchainIcon, Button, - Container, + CustomInputSend, Dropdown, + FeesSlider, Flex, Input, + Throbber, + Toggle, Typography, } from '@cypherock/cysync-ui'; -import React from 'react'; +import React, { useState } from 'react'; +import { STAKEABLE_ASSETS } from '~/constants/hysp'; import { useHysp } from '~/context/hysp'; +import { selectUnHiddenAccounts, useAppSelector } from '~/store'; + +const CARD: React.CSSProperties = { + width: 500, + borderRadius: 16, + border: '1px solid #2C2520', + background: 'linear-gradient(180deg, #211C18 0%, #211A16 50%, #252219 100%)', + boxShadow: '4px 4px 32px 4px #0F0D0B', + padding: 32, + display: 'flex', + flexDirection: 'column', + gap: 24, + maxHeight: '80vh', + overflowY: 'auto', +}; + +const FIELD: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 8, + width: '100%', +}; + +const APY_CARD: React.CSSProperties = { + width: '100%', + borderRadius: 8, + border: '1px solid #3C3C3C', + background: '#27221D', + display: 'flex', + flexDirection: 'row', +}; + +const APY_CELL: React.CSSProperties = { + flex: 1, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 4, + padding: '12px 16px', +}; + +const ROW: React.CSSProperties = { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + width: '100%', +}; const formatFee = (fee: string | undefined, coinId: string): string => { if (!fee || fee === '0') return ''; @@ -41,11 +94,77 @@ export const HyspDeposit: React.FC = () => { depositTxn, approveTxn, step, + isFeeLoading, + customGasPrice, + setCustomGasPrice, + onClose, + vaultInfo, + vaultInfoLoading, } = useHysp(); - const isBase = selectedAccount?.assetId === 'base'; + const [stakeMax, setStakeMax] = useState(false); + + const { accounts } = useAppSelector(selectUnHiddenAccounts); + + const isBase = selectedAccount?.assetId?.startsWith('base'); const coinId = selectedAccount?.parentAssetId ?? selectedAccount?.assetId ?? ''; + const tokenName = selectedToken.toUpperCase(); + + const isFeeStep = step === 'approveFee' || step === 'depositFee'; + + // Find the USDC/USDT sub-account for balance + const tokenAssetId = STAKEABLE_ASSETS.find( + a => + a.parentAssetId === selectedAccount?.assetId && + a.assetId.includes(selectedToken === 'usdc' ? 'usd-coin' : 'tether'), + )?.assetId; + + const tokenAccount = selectedAccount + ? accounts.find( + a => + a.parentAccountId === selectedAccount.__id && + a.assetId === tokenAssetId, + ) + : undefined; + + // Balance helpers + const parseTokenBalance = (): { display: string; raw: string } => { + if (!tokenAccount) return { display: '', raw: '' }; + try { + const { amount: bal, unit } = getParsedAmount({ + coinId: tokenAccount.parentAssetId, + assetId: tokenAccount.assetId, + unitAbbr: getDefaultUnit( + tokenAccount.parentAssetId, + tokenAccount.assetId, + ).abbr, + amount: tokenAccount.balance, + }); + return { display: `Available: ${bal} ${unit.abbr}`, raw: bal }; + } catch { + return { display: '', raw: '' }; + } + }; + + const { display: balanceDisplay, raw: balanceRaw } = parseTokenBalance(); + + const amountExceedsBalance = + !!amount && + !!balanceRaw && + new BigNumber(amount).isGreaterThan(new BigNumber(balanceRaw)); + + const handleToggleMax = (checked: boolean) => { + setStakeMax(checked); + if (checked) setAmount(balanceRaw); + else setAmount(''); + }; + + const canProceed = + !!selectedAccount && + !!amount && + parseFloat(amount) > 0 && + !amountExceedsBalance; const tokenOptions = [ { id: 'usdc', text: 'USDC', checkType: 'radio' as const }, @@ -54,76 +173,246 @@ export const HyspDeposit: React.FC = () => { : []), ]; - let feeLabel = ''; - if (step === 'approveFee') - feeLabel = `Approve gas: ${formatFee( - approveTxn?.computedData.fee, - coinId, - )}`; - else if (step === 'depositFee') - feeLabel = `Deposit gas: ${formatFee( - depositTxn?.computedData.fee, - coinId, - )}`; + // Fee step helpers + const activeTxn = step === 'approveFee' ? approveTxn : depositTxn; + const averageGwei = activeTxn + ? Number((activeTxn as any).staticData?.averageGasPrice) / 1e9 + : 1; + const sliderValue = customGasPrice ?? averageGwei; - const canProceed = !!selectedAccount && !!amount && parseFloat(amount) > 0; + // Scale fee proportionally to slider value so the display updates live. + // fee = gasPrice × gasLimit, gasLimit is fixed, so fee scales linearly. + const getDisplayFee = (): string => { + const baseFeeWei = activeTxn?.computedData?.fee; + if (!baseFeeWei || averageGwei === 0) return baseFeeWei ?? ''; + const ratio = sliderValue / averageGwei; + return new BigNumber(baseFeeWei).multipliedBy(ratio).toFixed(0); + }; - let buttonLabel = 'Continue'; - if (step === 'approveFee') buttonLabel = 'Approve & Continue'; - else if (step === 'depositFee') buttonLabel = 'Confirm Deposit'; + const feeLabel = formatFee(getDisplayFee(), coinId); + + const feeStepTitle = + step === 'approveFee' + ? `Approving ${tokenName} spend` + : `Depositing ${amount} ${tokenName} into Midas Vault`; return ( - - Deposit USDC / USDT - - - - - - - - setSelectedToken((id ?? 'usdc') as 'usdc' | 'usdt') - } - placeholderText="Select Token" - searchText="" - disabled={!selectedAccount} - /> - - setAmount(val)} - placeholder="Amount" - disabled={!selectedAccount} - /> - - {feeLabel && ( - - {feeLabel} +
+ {/* Title */} +
+ + + {isFeeStep ? feeStepTitle : 'Start Staking'} + + {!isFeeStep && ( + + Select wallet and asset to continue + + )} +
+ + {/* Fields — dimmed + locked on fee steps */} +
+ {/* Wallet */} +
+ + Select Wallet + + +
+ + {/* Account */} +
+ + Select Ethereum Account + + +
+ + {/* Asset */} +
+ + Select Asset + + setSelectedToken((id ?? 'usdc') as 'usdc' | 'usdt') + } + placeholderText="Select Token" + searchText="" + disabled={!selectedAccount || isFeeStep} + /> +
+ + {/* Amount */} +
+
+ + Enter Amount + + {!isFeeStep && ( + + + Stake Max + + {selectedAccount ? ( + + ) : ( + + )} + + )} +
+ + { + setAmount(val.replace(/[^0-9.]/g, '')); + if (stakeMax) setStakeMax(false); + }} + value={amount} + disabled={!selectedAccount || stakeMax || isFeeStep} + $textColor="white" + $noBorder + /> + + {tokenName} + + + {!isFeeStep && balanceDisplay ? ( + + {balanceDisplay} + + ) : null} + {!isFeeStep && amountExceedsBalance ? ( + + Amount exceeds available balance + + ) : null} +
+
+ + {/* Fee section only on fee steps */} + {isFeeStep && ( + isFeeLoading ? ( +
+ + + Fetching network fees, this may take a moment... + +
+ ) : ( + activeTxn && ( +
+
+ Gas Price + {sliderValue.toFixed(4)} Gwei +
+ + {feeLabel ? ( +
+ Network Fees + {feeLabel} +
+ ) : null} +
+ ) + ) + )} + + {/* APY Card — always rendered; shows spinner while loading */} +
+ {vaultInfoLoading || !vaultInfo ? ( +
+ +
+ ) : ( + <> +
+ Est. APY + + {vaultInfo.apy.toFixed(2)}% + +
+
+
+ Withdrawals + + Flexible + + No lock-up +
+ )} - +
- - + {/* Buttons */} +
+ + +
+
); }; diff --git a/packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspStatus.tsx b/packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspStatus.tsx index d21692bd6..5b3ef152b 100644 --- a/packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspStatus.tsx +++ b/packages/cysync-core/src/pages/MainApp/Hysp/Pages/HyspStatus.tsx @@ -1,27 +1,70 @@ import { SignTransactionDeviceEvent } from '@cypherock/coin-support-interfaces'; +import { evmCoinList } from '@cypherock/coins'; import { ArrowRightIcon, Button, Check, + ConfettiBlast, Container, + CopyContainer, + DialogBox, + DialogBoxBody, + DialogBoxFooter, Flex, + GoldExternalLink, + Image, LeanBox, LeanBoxContainer, LeanBoxProps, Throbber, Typography, VerifyAmountDeviceGraphics, + successIcon, } from '@cypherock/cysync-ui'; import React, { useMemo } from 'react'; +import { config } from '~/config'; import { useHysp } from '~/context/hysp'; +import { truncateMiddle } from '~/utils'; const checkIcon = ; const throbberIcon = ; const arrowIcon = ; +// ─── APY Card styles (same as HyspDeposit) ─────────────────────────────────── + +const APY_CARD: React.CSSProperties = { + width: '100%', + borderRadius: 8, + border: '1px solid #3C3C3C', + background: '#27221D', + display: 'flex', + flexDirection: 'row', +}; + +const APY_CELL: React.CSSProperties = { + flex: 1, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 4, + padding: '12px 16px', +}; + export const HyspStatus: React.FC = () => { - const { step, error, deviceEvents, redeemPath, onClose } = useHysp(); + const { + step, + error, + deviceEvents, + redeemPath, + onClose, + selectedAccount, + depositTxHash, + vaultInfo, + vaultInfoLoading, + amount, + selectedToken, + } = useHysp(); const isDeviceSigning = [ 'approving', @@ -83,7 +126,6 @@ export const HyspStatus: React.FC = () => { if (step === 'depositing') return 'Confirm Deposit on Device'; if (step === 'redeeming') return 'Confirm Redeem on Device'; if (isPolling) return 'Waiting for Approval...'; - if (isDone) return 'Deposit Successful!'; if (isRedeemDone) return redeemPath === 'queue' ? 'Redeem Request Submitted' @@ -101,48 +143,236 @@ export const HyspStatus: React.FC = () => { return 'Review and confirm the redemption on your Cypherock X1.'; if (isPolling) return 'Your approval is being confirmed on-chain. This may take a few seconds.'; - if (isDone) - return 'Your USDC has been deposited into the HYSP vault successfully.'; if (isRedeemDone && redeemPath === 'queue') return 'Your redeem request has been submitted. Funds will be available within 3 business days.'; if (isRedeemDone) return 'Your mevUSD has been redeemed successfully.'; - if (isError) return error ?? 'An unknown error occurred.'; + if (isError) return error?.message ?? 'An unknown error occurred.'; return ''; }; - return ( - - {isDeviceSigning && ( - <> - - - {deviceSteps.map(s => ( - + // Explorer link for the deposit tx + const explorerLink = (() => { + if (!depositTxHash || !selectedAccount) return undefined; + const network = + evmCoinList[selectedAccount.assetId]?.network ?? selectedAccount.assetId; + return `${config.API_CYPHEROCK}/eth/transaction/open-txn?network=${network}&txHash=${depositTxHash}&isConfirmed=true`; + })(); + + // ── Confirmation screen ────────────────────────────────────────────────── + if (isDone) { + const network = selectedAccount + ? evmCoinList[selectedAccount.assetId]?.network ?? selectedAccount.assetId + : ''; + const networkLabel = + network === 'homestead' || network === 'mainnet' + ? 'Ethereum Mainnet' + : network === 'base' + ? 'Base' + : network; + const tokenLabel = selectedToken.toUpperCase(); + + const summaryRows: Array<{ label: string; value: React.ReactNode }> = [ + { + label: 'Staked Amount', + value: `${amount} ${tokenLabel}`, + }, + { label: 'Vault', value: 'Midas (HYSP)' }, + { label: 'Network', value: networkLabel }, + ...(depositTxHash + ? [ + { + label: 'Transaction Hash', + value: ( + + + {explorerLink && ( + + + + )} + + ), + }, + ] + : []), + ]; + + return ( + + + + Success + + + + Staking Successful! + + + Your {tokenLabel} has been deposited into the HYSP vault. + + + + {/* Transaction summary card */} +
+ {summaryRows.map((row, i) => ( +
+
+ + {row.label} + +
+ {typeof row.value === 'string' ? ( +
+ + {row.value} + +
+ ) : ( + row.value + )} +
))} - - - )} - - - - {getTitle()} - - - {getDescription()} - - - - {(isDone || isRedeemDone || isError) && ( - - )} - +
+ + {/* APY Card */} +
+ {vaultInfoLoading || !vaultInfo ? ( +
+ +
+ ) : ( + <> +
+ + Est. APY + + + {vaultInfo.apy.toFixed(2)}% + +
+
+
+ + Withdrawals + + + Flexible + + + No lock-up + +
+ + )} +
+ + + + + + + ); + } + + // ── Signing / Polling / Error screens ──────────────────────────────────── + return ( + + + {isDeviceSigning && ( + <> + + + {deviceSteps.map(s => ( + + ))} + + + )} + + {isPolling && ( + + + + )} + + + + {getTitle()} + + + {getDescription()} + + + + {(isRedeemDone || isError) && ( + + )} + + ); }; diff --git a/packages/cysync-core/src/pages/MainApp/Hysp/index.tsx b/packages/cysync-core/src/pages/MainApp/Hysp/index.tsx index acbd396d5..bbd3b8d29 100644 --- a/packages/cysync-core/src/pages/MainApp/Hysp/index.tsx +++ b/packages/cysync-core/src/pages/MainApp/Hysp/index.tsx @@ -1,80 +1,109 @@ import { BlurOverlay, - Button, CloseButton, - Container, DialogBox, + DialogBoxBackgroundBar, DialogBoxBody, - Flex, + MilestoneAside, + WalletDialogMainContainer, } from '@cypherock/cysync-ui'; -import React from 'react'; +import React, { useRef } from 'react'; +import { ErrorHandlerDialog, WithConnectedDevice } from '~/components'; import { HyspProvider, useHysp } from '~/context/hysp'; -import { closeDialog, useAppDispatch } from '~/store'; +import { closeDialog, selectDialogs, useAppDispatch, useAppSelector } from '~/store'; +import { HyspConsent } from './Pages/HyspConsent'; import { HyspDeposit } from './Pages/HyspDeposit'; import { HyspRedeem } from './Pages/HyspRedeem'; import { HyspStatus } from './Pages/HyspStatus'; -import { HyspVaultInfo } from './Pages/HyspVaultInfo'; -const STATUS_STEPS = new Set([ - 'approving', - 'polling', - 'depositing', - 'done', - 'redeem-approving', - 'redeem-polling', - 'redeeming', - 'redeem-done', - 'error', -]); +// Which milestone index (0-based) each step belongs to +const STEP_TO_MILESTONE: Record = { + consent: 0, + input: 0, + approveFee: 1, + approving: 1, + polling: 1, + depositFee: 2, + depositing: 2, + done: 3, +}; + +const DeviceConnectionWrapper: React.FC<{ + isDeviceRequired: boolean; + children: React.ReactNode; +}> = ({ isDeviceRequired, children }) => { + if (isDeviceRequired) + return {children}; + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>{children}; +}; + +const DEVICE_REQUIRED_STEPS = ['approveFee', 'approving', 'polling', 'depositFee', 'depositing']; const HyspContent: React.FC = () => { - const { step, mode, setMode, onClose } = useHysp(); + const { step, onClose, error, selectedWallet, mode, setMode } = useHysp(); + const isDeviceRequired = DEVICE_REQUIRED_STEPS.includes(step); - const isStatusStep = STATUS_STEPS.has(step); + const milestones = ['Stake', 'Approve', 'Deposit', 'Confirmation']; - const showRightPanel = () => { - if (isStatusStep) return ; - if (mode === 'redeem') return ; - return ; + // Keep the last non-error milestone so sidebar stays correct on error + const lastMilestoneRef = useRef(0); + if (step !== 'error') { + lastMilestoneRef.current = STEP_TO_MILESTONE[step] ?? 0; + } + const activeTab = step === 'error' ? lastMilestoneRef.current : (STEP_TO_MILESTONE[step] ?? 0); + + const renderContent = () => { + switch (step) { + case 'consent': + return ; + case 'input': + case 'approveFee': + case 'depositFee': + return ; + case 'approving': + case 'polling': + case 'depositing': + case 'done': + return ; + case 'error': + // Let ErrorHandlerDialog handle the error display entirely + return null; + // Redeem steps still handled by redeem page + default: + return ; + } }; return ( - - - - - {/* Left panel — vault info */} - - - - - {/* Right panel — deposit / redeem / status */} - - {/* Mode toggle — only shown on input steps */} - {!isStatusStep && ( - - - - - )} - - {showRightPanel()} - - - + + + + + setMode(mode === 'deposit' ? 'deposit' : 'redeem')} + selectedWallet={selectedWallet} + > + + {renderContent()} + + + + } + position="top" + useLightPadding + /> + ); @@ -83,9 +112,13 @@ const HyspContent: React.FC = () => { export const HyspPage: React.FC = () => { const dispatch = useAppDispatch(); const handleClose = () => dispatch(closeDialog('hyspDialog')); + const dialogs = useAppSelector(selectDialogs); + const initialAccountId = dialogs.hyspDialog?.data?.initialAccountId; + const initialToken = dialogs.hyspDialog?.data?.initialToken; + const initialWalletId = dialogs.hyspDialog?.data?.initialWalletId; return ( - + ); diff --git a/packages/cysync-core/src/pages/MainApp/index.ts b/packages/cysync-core/src/pages/MainApp/index.ts index a159f7fcc..a376ddff9 100644 --- a/packages/cysync-core/src/pages/MainApp/index.ts +++ b/packages/cysync-core/src/pages/MainApp/index.ts @@ -9,3 +9,4 @@ export * from './BuySell2'; export * from './Swap'; export * from './Inheritance'; export * from './ReferAndEarn'; +export { EarnDashboard } from './Hysp/EarnDashboard'; diff --git a/packages/cysync-core/src/store/dialog/types.ts b/packages/cysync-core/src/store/dialog/types.ts index 7a84452b1..d86ece0e8 100644 --- a/packages/cysync-core/src/store/dialog/types.ts +++ b/packages/cysync-core/src/store/dialog/types.ts @@ -63,7 +63,11 @@ export interface IDialogState { hyspDialog: { isOpen: boolean; - data?: undefined; + data?: { + initialAccountId?: string; + initialWalletId?: string; + initialToken?: 'usdc' | 'usdt'; + }; }; sendDialog: { diff --git a/packages/desktop-ui/src/Router.tsx b/packages/desktop-ui/src/Router.tsx index bc38df166..eb29c6a3b 100644 --- a/packages/desktop-ui/src/Router.tsx +++ b/packages/desktop-ui/src/Router.tsx @@ -30,6 +30,7 @@ import { Swap, ChooseFirmware, BuySell2, + EarnDashboard, analyticsService, } from '@cypherock/cysync-core'; import React, { memo, ReactNode, useEffect } from 'react'; @@ -68,6 +69,7 @@ const components: Record = { 'inheritance-choose-plan': , 'inheritance-plan-details': , 'refer-and-earn': , + earn: , }; export type InternalRoute = Record; diff --git a/packages/ui/icons/everstake-logo.svg b/packages/ui/icons/everstake-logo.svg new file mode 100644 index 000000000..6fc385014 --- /dev/null +++ b/packages/ui/icons/everstake-logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + +