diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..7fef2ff8 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# NextJS +SKIP_NODE_VERSION_CHECK=1 + +# Turborepo +TURBO_TEAM=phoenix-protocol +TURBO_TOKEN=your-token-here + +# Build Options +NODE_ENV=development diff --git a/.gitignore b/.gitignore index 8ea36866..2b465ab0 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,6 @@ next-env.d.ts .idea/ -.yarn/cache \ No newline at end of file +.yarn/cache + +.turbo \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..48b14e6b --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20.14.0 diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index 322246d8..55cff370 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/README.md b/README.md index 2c734486..80eeb8b0 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Phoenix Frontend -Phoenix Frontend is built with TypeScript and leverages yarn workspaces for package management. You'll find multiple packages in the `/packages` directory, each serving a unique function in the overall application ecosystem. +Phoenix Frontend is built with TypeScript and leverages yarn workspaces for package management. You'll find multiple packages in the `/packages` directory, each serving a unique function in the overall application ecosystem. The project is optimized with Turborepo for efficient monorepo management. ## Packages @@ -22,21 +22,100 @@ Housing a range of utility and helper functions, the `Utils` package offers a on ### [Core](https://github.com/Phoenix-Protocol-Group/phoenix-frontend/tree/main/packages/core) -As the heart of the application, the `Core` package orchestrates the UI, state management, and utility functions. It's built on Next.js and serves as the primary entry point, setting the architectural groundwork and facilitating inter-package interactions. +As the heart of the application, the `Core` package orchestrates the UI, state management, and utility functions. It's built on Next.js 15 and serves as the primary entry point, setting the architectural groundwork and facilitating inter-package interactions. ### [Contracts](https://github.com/Phoenix-Protocol-Group/phoenix-frontend/tree/main/packages/contracts) The `Contracts` package provides generated contract classes and associated types, created through Soroban bindings. For more information, refer to [Soroban's documentation](https://soroban.stellar.org/docs/getting-started/create-an-app#generate-an-npm-package-for-the-hello-world-contract). +### [Types](https://github.com/Phoenix-Protocol-Group/phoenix-frontend/tree/main/packages/types) + +The `Types` package contains TypeScript type definitions used throughout the Phoenix Frontend ecosystem, ensuring type consistency across all packages. + +### [Strategies](https://github.com/Phoenix-Protocol-Group/phoenix-frontend/tree/main/packages/strategies) + +The `Strategies` package serves as a strategy provider registry for the Phoenix DeFi ecosystem, offering modular and extensible strategy implementations. + ## Quick Start -1. Clone the repo locally. -2. Make sure Node.js and yarn are installed. -3. Cd into the project root. -4. Run `yarn install`. -5. Navigate to the package you're interested in under `/packages`. -6. Check the package-specific readme for setup and usage instructions. +### Setup Script + +The easiest way to get started is to use the provided setup script: + +```bash +./setup.sh +``` + +This script will: + +1. Check for the correct Node.js version +2. Create a local environment file +3. Install dependencies +4. Build the required packages + +### Development with Turborepo + +This project uses Turborepo for efficient monorepo management. It optimizes builds, provides incremental builds, and manages dependencies between packages. + +```bash +# Install dependencies +yarn install + +# Start development with watch mode for all packages +yarn dev + +# Run only the core app (Next.js) +yarn dev:core + +# Run experimental features (if available) +yarn dev:experimental + +# Run only the UI package development +yarn dev:ui + +# Build all packages +yarn build + +# Build specific packages +yarn build:core # Build the core Next.js app +yarn build:contracts # Build the contracts package +yarn build:state # Build the state package +yarn build:ui # Build the UI package +yarn build:utils # Build the utils package +yarn build:types # Build the types package +yarn build:strat # Build the strategies package + +# Run Storybook for the UI package +yarn storybook +``` + +#### Understanding the Development Process + +When you run `yarn dev`, Turborepo will: + +1. Build all necessary dependencies in the correct order +2. Start watch mode for all packages, rebuilding them when files change +3. Start the Next.js development server for the core package + +This means you can edit files in any package, and changes will automatically be reflected in the running application without manual rebuilds. + +## Project Structure + +- `/packages/core` - Next.js application (main entry point) +- `/packages/ui` - UI component library based on Material UI +- `/packages/state` - State management with Zustand +- `/packages/utils` - Utility functions +- `/packages/types` - TypeScript type definitions +- `/packages/contracts` - Generated Soroban contract bindings +- `/packages/strategies` - Strategy provider registry for Phoenix DeFi +- `/schemas` - JSON schemas for project configuration + +## Requirements + +- Node.js (version specified in .nvmrc) +- Yarn package manager +- Git ## Reach Out -Questions, feedback, or suggestions? Feel free to connect with our dev team. We value your input! +Questions, feedback, or suggestions? Feel free to connect with our dev team via [GitHub Issues](https://github.com/Phoenix-Protocol-Group/phoenix-frontend/issues). We value your input! diff --git a/package.json b/package.json index 46b1d12a..4142970d 100644 --- a/package.json +++ b/package.json @@ -3,16 +3,20 @@ "description": "The Phoenix Frontend is a TypeScript-based web application utilizing yarn workspaces. It includes a UI kit (MUI-based), state management, utility functions, and a core package for seamless integration. It's written to use it with phoenix-contracts on Stellar's soroban platform.", "version": "0.0.1", "scripts": { - "test": "yarn workspace @phoenix-protocol/utils run test --passWithNoTests", - "build": "yarn workspace @phoenix-protocol/types run build && yarn workspace @phoenix-protocol/utils run build && yarn workspace @phoenix-protocol/contracts run build && yarn workspace @phoenix-protocol/state run build && yarn workspace @phoenix-protocol/ui run build && yarn workspace @phoenix-protocol/core run build", + "test": "turbo run test", + "build": "turbo run build", "storybook": "yarn workspace @phoenix-protocol/ui storybook", - "dev": "yarn workspace @phoenix-protocol/core dev", - "build:core": "yarn workspace @phoenix-protocol/core build", - "build:contracts": "yarn workspace @phoenix-protocol/contracts build", - "build:state": "yarn workspace @phoenix-protocol/state build", - "build:ui": "yarn workspace @phoenix-protocol/ui build", - "build:utils": "yarn workspace @phoenix-protocol/utils build", - "build:types": "yarn workspace @phoenix-protocol/types build" + "dev": "turbo run dev", + "dev:core": "turbo run dev --filter=@phoenix-protocol/core", + "dev:ui": "turbo run dev --filter=@phoenix-protocol/ui", + "dev:experimental": "yarn workspace @phoenix-protocol/core dev:experimental", + "build:core": "turbo run build --filter=@phoenix-protocol/core", + "build:contracts": "turbo run build --filter=@phoenix-protocol/contracts", + "build:state": "turbo run build --filter=@phoenix-protocol/state", + "build:ui": "turbo run build --filter=@phoenix-protocol/ui", + "build:utils": "turbo run build --filter=@phoenix-protocol/utils", + "build:types": "turbo run build --filter=@phoenix-protocol/types", + "build:strat": "turbo run build --filter=@phoenix-protocol/strategies" }, "repository": { "type": "git", @@ -36,14 +40,20 @@ "devDependencies": { "@tsconfig/recommended": "^1.0.2", "@types/jest": "^29.5.2", - "jest": "^29.5.0" + "concurrently": "^9.1.2", + "jest": "^29.5.0", + "turbo": "^2.5.3" }, "packageManager": "yarn@3.6.4", "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", "@stellar/stellar-sdk": "^13.1.0", + "@storybook/react-webpack5": "^8.6.9", "@walletconnect/modal": "^2.6.2", "@walletconnect/sign-client": "^2.13.1", "@walletconnect/types": "^2.13.1", + "framer-motion": "^12.6.0", "next": "15.0.3" } } diff --git a/packages/contracts/package.json b/packages/contracts/package.json index d250ee2c..5e99bd01 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -11,8 +11,9 @@ "buffer": "6.0.3" }, "scripts": { - "test": "tsc && jest", - "build": "tsc" + "test": "echo \"Error: no test specified\" && exit 0", + "build": "tsc", + "dev": "tsc --watch" }, "main": "build/index.js", "types": "build/index.d.ts", diff --git a/packages/contracts/readme.md b/packages/contracts/readme.md index 915e2e93..ad9b09a8 100644 --- a/packages/contracts/readme.md +++ b/packages/contracts/readme.md @@ -1,19 +1,30 @@ # Contracts Package for Phoenix Frontend -The `Contracts` package is part of the larger Phoenix Frontend ecosystem and serves as a repository for generated contract classes and their associated types. This package is generated using [Soroban bindings](https://soroban.stellar.org/docs/getting-started/create-an-app#generate-an-npm-package-for-the-hello-world-contract). +The `Contracts` package is part of the larger Phoenix Frontend ecosystem and serves as a repository for generated contract classes and their associated types. This package is generated using [Soroban bindings](https://soroban.stellar.org/docs/getting-started/create-an-app#generate-an-npm-package-for-the-hello-world-contract) for seamless integration with the Stellar Soroban platform. ## Features -- Generated contract classes for seamless integration with blockchain platforms. -- Types associated with each contract class for type safety. -- Soroban bindings for compatibility and extendibility. +- Generated contract classes for Phoenix Protocol contracts +- TypeScript types for contract interactions +- Soroban bindings for the Stellar blockchain +- Predefined contract interaction methods +- Helper functions for interacting with Phoenix protocol + +## Included Contracts + +- `phoenix-factory` - Factory contract for creating and managing token pairs +- `phoenix-multihop` - Contract for multi-hop swaps between different token pairs +- `phoenix-pair` - Liquidity pair contract for token swaps +- `phoenix-stake` - Staking contract for liquidity provider rewards +- `phoenix-vesting` - Vesting contract for token distribution +- `soroban-token` - Token contract implementation for Soroban ## Installation -Navigate to the `Contracts` directory within the `/packages` directory of the main Phoenix Frontend project: +Navigate to the root directory of the Phoenix Frontend project: ```bash -cd path-to-your-project/packages/Contracts +cd /path/to/phoenix/frontend ``` Run the following command to install all dependencies: @@ -22,16 +33,49 @@ Run the following command to install all dependencies: yarn install ``` +Build the contracts package: + +```bash +yarn build:contracts +``` + ## Usage -After installation, you can import the contract classes and types into your project as needed. +After installation, you can import the contract classes and types into your project as needed: ```typescript -import { ContractClassName } from '@phoenix-frontend/contracts'; +import { + PhoenixPairClient, + PhoenixFactoryClient, + PhoenixMultihopClient, +} from "@phoenix-protocol/contracts"; + +// Initialize a contract client +const pairClient = new PhoenixPairClient({ + contractId: "your_contract_id", + networkPassphrase: "your_network_passphrase", + rpcUrl: "your_rpc_url", +}); + +// Interact with the contract +const poolInfo = await pairClient.getPoolInfo(); ``` -Refer to the main Phoenix Frontend readme for guidance on how this package interacts with the other packages in the ecosystem. +## Fetching PHO Token + +This package also includes a utility for fetching PHO tokens from the faucet: + +```typescript +import { fetchPho } from "@phoenix-protocol/contracts"; + +// Fetch PHO tokens to your wallet +await fetchPho(walletPublicKey); +``` ## Additional Resources -For more information on generating contract classes and types, refer to the [Soroban documentation](https://soroban.stellar.org/docs/getting-started/create-an-app#generate-an-npm-package-for-the-hello-world-contract). +For more information on Soroban contracts and Stellar development: + +- [Soroban Documentation](https://soroban.stellar.org/docs) +- [Stellar Developers Guide](https://developers.stellar.org/docs) +- [Phoenix Protocol Documentation](https://docs.phoenix-protocol.io) diff --git a/packages/contracts/src/phoenix-factory/index.ts b/packages/contracts/src/phoenix-factory/index.ts index 9c4a7758..508bcb92 100644 --- a/packages/contracts/src/phoenix-factory/index.ts +++ b/packages/contracts/src/phoenix-factory/index.ts @@ -24,21 +24,31 @@ if (typeof window !== "undefined") { } export const Errors = { - 1: { message: "AlreadyInitialized" }, + 100: { message: "AlreadyInitialized" }, - 2: { message: "WhiteListeEmpty" }, + 101: { message: "WhiteListeEmpty" }, - 3: { message: "NotAuthorized" }, + 102: { message: "NotAuthorized" }, - 4: { message: "LiquidityPoolNotFound" }, + 103: { message: "LiquidityPoolNotFound" }, - 5: { message: "TokenABiggerThanTokenB" }, + 104: { message: "TokenABiggerThanTokenB" }, - 6: { message: "MinStakeInvalid" }, + 105: { message: "MinStakeInvalid" }, - 7: { message: "MinRewardInvalid" }, + 106: { message: "MinRewardInvalid" }, - 8: { message: "AdminNotSet" }, + 107: { message: "AdminNotSet" }, + + 108: { message: "OverflowingOps" }, + + 109: { message: "SameAdmin" }, + + 110: { message: "NoAdminChangeInPlace" }, + + 111: { message: "AdminChangeExpired" }, + + 112: { message: "TokenDecimalsInvalid" }, }; export interface PairTupleKey { @@ -149,53 +159,22 @@ export interface LiquidityPoolInitInfo { token_init_info: TokenInitInfo; } +export interface AdminChange { + new_admin: string; + time_limit: Option; +} + +export interface AutoUnstakeInfo { + stake_amount: i128; + stake_timestamp: u64; +} + export enum PoolType { Xyk = 0, Stable = 1, } export interface Client { - /** - * Construct and simulate a initialize transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. - */ - initialize: ( - { - admin, - multihop_wasm_hash, - lp_wasm_hash, - stable_wasm_hash, - stake_wasm_hash, - token_wasm_hash, - whitelisted_accounts, - lp_token_decimals, - }: { - admin: string; - multihop_wasm_hash: Buffer; - lp_wasm_hash: Buffer; - stable_wasm_hash: Buffer; - stake_wasm_hash: Buffer; - token_wasm_hash: Buffer; - whitelisted_accounts: Array; - lp_token_decimals: u32; - }, - options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - } - ) => Promise>; - /** * Construct and simulate a create_liquidity_pool transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ @@ -238,44 +217,25 @@ export interface Client { ) => Promise>; /** - * Construct and simulate a update_whitelisted_accounts transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. - */ - update_whitelisted_accounts: ( - { - sender, - to_add, - to_remove, - }: { sender: string; to_add: Array; to_remove: Array }, - options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - } - ) => Promise>; - - /** - * Construct and simulate a update_wasm_hashes transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Construct and simulate a update_config transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ - update_wasm_hashes: ( + update_config: ( { + multihop_address, lp_wasm_hash, stake_wasm_hash, token_wasm_hash, + whitelisted_to_add, + whitelisted_to_remove, + lp_token_decimals, }: { + multihop_address: Option; lp_wasm_hash: Option; stake_wasm_hash: Option; token_wasm_hash: Option; + whitelisted_to_add: Option>; + whitelisted_to_remove: Option>; + lp_token_decimals: Option; }, options?: { /** @@ -293,7 +253,7 @@ export interface Client { */ simulate?: boolean; } - ) => Promise>; + ) => Promise>>; /** * Construct and simulate a query_pools transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. @@ -464,6 +424,69 @@ export interface Client { simulate?: boolean; }) => Promise>>; + /** + * Construct and simulate a propose_admin transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + propose_admin: ( + { new_admin, time_limit }: { new_admin: string; time_limit: Option }, + options?: { + /** + * The fee to pay for the transaction. Default: BASE_FEE + */ + fee?: number; + + /** + * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT + */ + timeoutInSeconds?: number; + + /** + * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true + */ + simulate?: boolean; + } + ) => Promise>>; + + /** + * Construct and simulate a revoke_admin_change transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + revoke_admin_change: (options?: { + /** + * The fee to pay for the transaction. Default: BASE_FEE + */ + fee?: number; + + /** + * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT + */ + timeoutInSeconds?: number; + + /** + * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true + */ + simulate?: boolean; + }) => Promise>>; + + /** + * Construct and simulate a accept_admin transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + accept_admin: (options?: { + /** + * The fee to pay for the transaction. Default: BASE_FEE + */ + fee?: number; + + /** + * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT + */ + timeoutInSeconds?: number; + + /** + * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true + */ + simulate?: boolean; + }) => Promise>>; + /** * Construct and simulate a update transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ @@ -489,9 +512,69 @@ export interface Client { simulate?: boolean; } ) => Promise>; + + /** + * Construct and simulate a query_version transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + query_version: (options?: { + /** + * The fee to pay for the transaction. Default: BASE_FEE + */ + fee?: number; + + /** + * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT + */ + timeoutInSeconds?: number; + + /** + * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true + */ + simulate?: boolean; + }) => Promise>; + + /** + * Construct and simulate a add_new_key_to_storage transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + add_new_key_to_storage: (options?: { + /** + * The fee to pay for the transaction. Default: BASE_FEE + */ + fee?: number; + + /** + * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT + */ + timeoutInSeconds?: number; + + /** + * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true + */ + simulate?: boolean; + }) => Promise>>; } export class Client extends ContractClient { static async deploy( + /** Constructor/Initialization Args for the contract's `__constructor` method */ + { + admin, + multihop_wasm_hash, + lp_wasm_hash, + stable_wasm_hash, + stake_wasm_hash, + token_wasm_hash, + whitelisted_accounts, + lp_token_decimals, + }: { + admin: string; + multihop_wasm_hash: Buffer; + lp_wasm_hash: Buffer; + stable_wasm_hash: Buffer; + stake_wasm_hash: Buffer; + token_wasm_hash: Buffer; + whitelisted_accounts: Array; + lp_token_decimals: u32; + }, /** Options for initalizing a Client as well as for calling a method, with extras specific to deploying. */ options: MethodOptions & Omit & { @@ -503,15 +586,25 @@ export class Client extends ContractClient { format?: "hex" | "base64"; } ): Promise> { - return ContractClient.deploy(null, options); + return ContractClient.deploy( + { + admin, + multihop_wasm_hash, + lp_wasm_hash, + stable_wasm_hash, + stake_wasm_hash, + token_wasm_hash, + whitelisted_accounts, + lp_token_decimals, + }, + options + ); } constructor(public readonly options: ContractClientOptions) { super( new ContractSpec([ - "AAAAAAAAAAAAAAAKaW5pdGlhbGl6ZQAAAAAACAAAAAAAAAAFYWRtaW4AAAAAAAATAAAAAAAAABJtdWx0aWhvcF93YXNtX2hhc2gAAAAAA+4AAAAgAAAAAAAAAAxscF93YXNtX2hhc2gAAAPuAAAAIAAAAAAAAAAQc3RhYmxlX3dhc21faGFzaAAAA+4AAAAgAAAAAAAAAA9zdGFrZV93YXNtX2hhc2gAAAAD7gAAACAAAAAAAAAAD3Rva2VuX3dhc21faGFzaAAAAAPuAAAAIAAAAAAAAAAUd2hpdGVsaXN0ZWRfYWNjb3VudHMAAAPqAAAAEwAAAAAAAAARbHBfdG9rZW5fZGVjaW1hbHMAAAAAAAAEAAAAAA==", "AAAAAAAAAAAAAAAVY3JlYXRlX2xpcXVpZGl0eV9wb29sAAAAAAAACAAAAAAAAAAGc2VuZGVyAAAAAAATAAAAAAAAAAxscF9pbml0X2luZm8AAAfQAAAAFUxpcXVpZGl0eVBvb2xJbml0SW5mbwAAAAAAAAAAAAAQc2hhcmVfdG9rZW5fbmFtZQAAABAAAAAAAAAAEnNoYXJlX3Rva2VuX3N5bWJvbAAAAAAAEAAAAAAAAAAJcG9vbF90eXBlAAAAAAAH0AAAAAhQb29sVHlwZQAAAAAAAAADYW1wAAAAA+gAAAAGAAAAAAAAABRkZWZhdWx0X3NsaXBwYWdlX2JwcwAAAAcAAAAAAAAAE21heF9hbGxvd2VkX2ZlZV9icHMAAAAABwAAAAEAAAAT", - "AAAAAAAAAAAAAAAbdXBkYXRlX3doaXRlbGlzdGVkX2FjY291bnRzAAAAAAMAAAAAAAAABnNlbmRlcgAAAAAAEwAAAAAAAAAGdG9fYWRkAAAAAAPqAAAAEwAAAAAAAAAJdG9fcmVtb3ZlAAAAAAAD6gAAABMAAAAA", - "AAAAAAAAAAAAAAASdXBkYXRlX3dhc21faGFzaGVzAAAAAAADAAAAAAAAAAxscF93YXNtX2hhc2gAAAPoAAAD7gAAACAAAAAAAAAAD3N0YWtlX3dhc21faGFzaAAAAAPoAAAD7gAAACAAAAAAAAAAD3Rva2VuX3dhc21faGFzaAAAAAPoAAAD7gAAACAAAAAA", + "AAAAAAAAAAAAAAANdXBkYXRlX2NvbmZpZwAAAAAAAAcAAAAAAAAAEG11bHRpaG9wX2FkZHJlc3MAAAPoAAAAEwAAAAAAAAAMbHBfd2FzbV9oYXNoAAAD6AAAA+4AAAAgAAAAAAAAAA9zdGFrZV93YXNtX2hhc2gAAAAD6AAAA+4AAAAgAAAAAAAAAA90b2tlbl93YXNtX2hhc2gAAAAD6AAAA+4AAAAgAAAAAAAAABJ3aGl0ZWxpc3RlZF90b19hZGQAAAAAA+gAAAPqAAAAEwAAAAAAAAAVd2hpdGVsaXN0ZWRfdG9fcmVtb3ZlAAAAAAAD6AAAA+oAAAATAAAAAAAAABFscF90b2tlbl9kZWNpbWFscwAAAAAAA+gAAAAEAAAAAQAAA+kAAAfQAAAABkNvbmZpZwAAAAAH0AAAAA1Db250cmFjdEVycm9yAAAA", "AAAAAAAAAAAAAAALcXVlcnlfcG9vbHMAAAAAAAAAAAEAAAPqAAAAEw==", "AAAAAAAAAAAAAAAScXVlcnlfcG9vbF9kZXRhaWxzAAAAAAABAAAAAAAAAAxwb29sX2FkZHJlc3MAAAATAAAAAQAAB9AAAAARTGlxdWlkaXR5UG9vbEluZm8AAAA=", "AAAAAAAAAAAAAAAXcXVlcnlfYWxsX3Bvb2xzX2RldGFpbHMAAAAAAAAAAAEAAAPqAAAH0AAAABFMaXF1aWRpdHlQb29sSW5mbwAAAA==", @@ -520,8 +613,14 @@ export class Client extends ContractClient { "AAAAAAAAAAAAAAAKZ2V0X2NvbmZpZwAAAAAAAAAAAAEAAAfQAAAABkNvbmZpZwAA", "AAAAAAAAAAAAAAAUcXVlcnlfdXNlcl9wb3J0Zm9saW8AAAACAAAAAAAAAAZzZW5kZXIAAAAAABMAAAAAAAAAB3N0YWtpbmcAAAAAAQAAAAEAAAfQAAAADVVzZXJQb3J0Zm9saW8AAAA=", "AAAAAAAAAAAAAAARbWlncmF0ZV9hZG1pbl9rZXkAAAAAAAAAAAAAAQAAA+kAAAPtAAAAAAAAB9AAAAANQ29udHJhY3RFcnJvcgAAAA==", + "AAAAAAAAAAAAAAANcHJvcG9zZV9hZG1pbgAAAAAAAAIAAAAAAAAACW5ld19hZG1pbgAAAAAAABMAAAAAAAAACnRpbWVfbGltaXQAAAAAA+gAAAAGAAAAAQAAA+kAAAATAAAH0AAAAA1Db250cmFjdEVycm9yAAAA", + "AAAAAAAAAAAAAAATcmV2b2tlX2FkbWluX2NoYW5nZQAAAAAAAAAAAQAAA+kAAAPtAAAAAAAAB9AAAAANQ29udHJhY3RFcnJvcgAAAA==", + "AAAAAAAAAAAAAAAMYWNjZXB0X2FkbWluAAAAAAAAAAEAAAPpAAAAEwAAB9AAAAANQ29udHJhY3RFcnJvcgAAAA==", + "AAAAAAAAAAAAAAANX19jb25zdHJ1Y3RvcgAAAAAAAAgAAAAAAAAABWFkbWluAAAAAAAAEwAAAAAAAAASbXVsdGlob3Bfd2FzbV9oYXNoAAAAAAPuAAAAIAAAAAAAAAAMbHBfd2FzbV9oYXNoAAAD7gAAACAAAAAAAAAAEHN0YWJsZV93YXNtX2hhc2gAAAPuAAAAIAAAAAAAAAAPc3Rha2Vfd2FzbV9oYXNoAAAAA+4AAAAgAAAAAAAAAA90b2tlbl93YXNtX2hhc2gAAAAD7gAAACAAAAAAAAAAFHdoaXRlbGlzdGVkX2FjY291bnRzAAAD6gAAABMAAAAAAAAAEWxwX3Rva2VuX2RlY2ltYWxzAAAAAAAABAAAAAA=", "AAAAAAAAAAAAAAAGdXBkYXRlAAAAAAACAAAAAAAAAA1uZXdfd2FzbV9oYXNoAAAAAAAD7gAAACAAAAAAAAAAFG5ld19zdGFibGVfcG9vbF9oYXNoAAAD7gAAACAAAAAA", - "AAAABAAAAAAAAAAAAAAADUNvbnRyYWN0RXJyb3IAAAAAAAAIAAAAAAAAABJBbHJlYWR5SW5pdGlhbGl6ZWQAAAAAAAEAAAAAAAAAD1doaXRlTGlzdGVFbXB0eQAAAAACAAAAAAAAAA1Ob3RBdXRob3JpemVkAAAAAAAAAwAAAAAAAAAVTGlxdWlkaXR5UG9vbE5vdEZvdW5kAAAAAAAABAAAAAAAAAAWVG9rZW5BQmlnZ2VyVGhhblRva2VuQgAAAAAABQAAAAAAAAAPTWluU3Rha2VJbnZhbGlkAAAAAAYAAAAAAAAAEE1pblJld2FyZEludmFsaWQAAAAHAAAAAAAAAAtBZG1pbk5vdFNldAAAAAAI", + "AAAAAAAAAAAAAAANcXVlcnlfdmVyc2lvbgAAAAAAAAAAAAABAAAAEA==", + "AAAAAAAAAAAAAAAWYWRkX25ld19rZXlfdG9fc3RvcmFnZQAAAAAAAAAAAAEAAAPpAAAD7QAAAAAAAAfQAAAADUNvbnRyYWN0RXJyb3IAAAA=", + "AAAABAAAAAAAAAAAAAAADUNvbnRyYWN0RXJyb3IAAAAAAAANAAAAAAAAABJBbHJlYWR5SW5pdGlhbGl6ZWQAAAAAAGQAAAAAAAAAD1doaXRlTGlzdGVFbXB0eQAAAABlAAAAAAAAAA1Ob3RBdXRob3JpemVkAAAAAAAAZgAAAAAAAAAVTGlxdWlkaXR5UG9vbE5vdEZvdW5kAAAAAAAAZwAAAAAAAAAWVG9rZW5BQmlnZ2VyVGhhblRva2VuQgAAAAAAaAAAAAAAAAAPTWluU3Rha2VJbnZhbGlkAAAAAGkAAAAAAAAAEE1pblJld2FyZEludmFsaWQAAABqAAAAAAAAAAtBZG1pbk5vdFNldAAAAABrAAAAAAAAAA5PdmVyZmxvd2luZ09wcwAAAAAAbAAAAAAAAAAJU2FtZUFkbWluAAAAAAAAbQAAAAAAAAAUTm9BZG1pbkNoYW5nZUluUGxhY2UAAABuAAAAAAAAABJBZG1pbkNoYW5nZUV4cGlyZWQAAAAAAG8AAAAAAAAAFFRva2VuRGVjaW1hbHNJbnZhbGlkAAAAcA==", "AAAAAQAAAAAAAAAAAAAADFBhaXJUdXBsZUtleQAAAAIAAAAAAAAAB3Rva2VuX2EAAAAAEwAAAAAAAAAHdG9rZW5fYgAAAAAT", "AAAAAQAAAAAAAAAAAAAABkNvbmZpZwAAAAAABwAAAAAAAAAFYWRtaW4AAAAAAAATAAAAAAAAABFscF90b2tlbl9kZWNpbWFscwAAAAAAAAQAAAAAAAAADGxwX3dhc21faGFzaAAAA+4AAAAgAAAAAAAAABBtdWx0aWhvcF9hZGRyZXNzAAAAEwAAAAAAAAAPc3Rha2Vfd2FzbV9oYXNoAAAAA+4AAAAgAAAAAAAAAA90b2tlbl93YXNtX2hhc2gAAAAD7gAAACAAAAAAAAAAFHdoaXRlbGlzdGVkX2FjY291bnRzAAAD6gAAABM=", "AAAAAQAAAAAAAAAAAAAADVVzZXJQb3J0Zm9saW8AAAAAAAACAAAAAAAAAAxscF9wb3J0Zm9saW8AAAPqAAAH0AAAAAtMcFBvcnRmb2xpbwAAAAAAAAAAD3N0YWtlX3BvcnRmb2xpbwAAAAPqAAAH0AAAAA5TdGFrZVBvcnRmb2xpbwAA", @@ -535,16 +634,16 @@ export class Client extends ContractClient { "AAAAAQAAAAAAAAAAAAAADVRva2VuSW5pdEluZm8AAAAAAAACAAAAAAAAAAd0b2tlbl9hAAAAABMAAAAAAAAAB3Rva2VuX2IAAAAAEw==", "AAAAAQAAAAAAAAAAAAAADVN0YWtlSW5pdEluZm8AAAAAAAAEAAAAAAAAAAdtYW5hZ2VyAAAAABMAAAAAAAAADm1heF9jb21wbGV4aXR5AAAAAAAEAAAAAAAAAAhtaW5fYm9uZAAAAAsAAAAAAAAACm1pbl9yZXdhcmQAAAAAAAs=", "AAAAAQAAAAAAAAAAAAAAFUxpcXVpZGl0eVBvb2xJbml0SW5mbwAAAAAAAAkAAAAAAAAABWFkbWluAAAAAAAAEwAAAAAAAAAUZGVmYXVsdF9zbGlwcGFnZV9icHMAAAAHAAAAAAAAAA1mZWVfcmVjaXBpZW50AAAAAAAAEwAAAAAAAAAYbWF4X2FsbG93ZWRfc2xpcHBhZ2VfYnBzAAAABwAAAAAAAAAWbWF4X2FsbG93ZWRfc3ByZWFkX2JwcwAAAAAABwAAAAAAAAAQbWF4X3JlZmVycmFsX2JwcwAAAAcAAAAAAAAAD3N0YWtlX2luaXRfaW5mbwAAAAfQAAAADVN0YWtlSW5pdEluZm8AAAAAAAAAAAAADHN3YXBfZmVlX2JwcwAAAAcAAAAAAAAAD3Rva2VuX2luaXRfaW5mbwAAAAfQAAAADVRva2VuSW5pdEluZm8AAAA=", + "AAAAAQAAAAAAAAAAAAAAC0FkbWluQ2hhbmdlAAAAAAIAAAAAAAAACW5ld19hZG1pbgAAAAAAABMAAAAAAAAACnRpbWVfbGltaXQAAAAAA+gAAAAG", + "AAAAAQAAAAAAAAAAAAAAD0F1dG9VbnN0YWtlSW5mbwAAAAACAAAAAAAAAAxzdGFrZV9hbW91bnQAAAALAAAAAAAAAA9zdGFrZV90aW1lc3RhbXAAAAAABg==", "AAAAAwAAAAAAAAAAAAAACFBvb2xUeXBlAAAAAgAAAAAAAAADWHlrAAAAAAAAAAAAAAAABlN0YWJsZQAAAAAAAQ==", ]), options ); } public readonly fromJSON = { - initialize: this.txFromJSON, create_liquidity_pool: this.txFromJSON, - update_whitelisted_accounts: this.txFromJSON, - update_wasm_hashes: this.txFromJSON, + update_config: this.txFromJSON>, query_pools: this.txFromJSON>, query_pool_details: this.txFromJSON, query_all_pools_details: this.txFromJSON>, @@ -553,6 +652,11 @@ export class Client extends ContractClient { get_config: this.txFromJSON, query_user_portfolio: this.txFromJSON, migrate_admin_key: this.txFromJSON>, + propose_admin: this.txFromJSON>, + revoke_admin_change: this.txFromJSON>, + accept_admin: this.txFromJSON>, update: this.txFromJSON, + query_version: this.txFromJSON, + add_new_key_to_storage: this.txFromJSON>, }; } diff --git a/packages/contracts/src/phoenix-multihop/index.ts b/packages/contracts/src/phoenix-multihop/index.ts index 1f649860..ed286a64 100644 --- a/packages/contracts/src/phoenix-multihop/index.ts +++ b/packages/contracts/src/phoenix-multihop/index.ts @@ -1,5 +1,4 @@ import { Buffer } from "buffer"; -import { Address } from "@stellar/stellar-sdk"; import { AssembledTransaction, Client as ContractClient, @@ -10,16 +9,10 @@ import { } from "@stellar/stellar-sdk/contract"; import type { u32, - i32, u64, i64, - u128, i128, - u256, - i256, Option, - Typepoint, - Duration, } from "@stellar/stellar-sdk/contract"; export * from "@stellar/stellar-sdk"; export * as contract from "@stellar/stellar-sdk/contract"; @@ -31,13 +24,19 @@ if (typeof window !== "undefined") { } export const Errors = { - 1: { message: "AlreadyInitialized" }, + 200: { message: "AlreadyInitialized" }, - 2: { message: "OperationsEmpty" }, + 201: { message: "OperationsEmpty" }, - 3: { message: "IncorrectAssetSwap" }, + 202: { message: "IncorrectAssetSwap" }, - 4: { message: "AdminNotSet" }, + 203: { message: "AdminNotSet" }, + + 204: { message: "SameAdmin" }, + + 205: { message: "NoAdminChangeInPlace" }, + + 206: { message: "AdminChangeExpired" }, }; export interface Swap { @@ -128,35 +127,22 @@ export interface LiquidityPoolInitInfo { token_init_info: TokenInitInfo; } +export interface AdminChange { + new_admin: string; + time_limit: Option; +} + +export interface AutoUnstakeInfo { + stake_amount: i128; + stake_timestamp: u64; +} + export enum PoolType { Xyk = 0, Stable = 1, } export interface Client { - /** - * Construct and simulate a initialize transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. - */ - initialize: ( - { admin, factory }: { admin: string; factory: string }, - options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - } - ) => Promise>; - /** * Construct and simulate a swap transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ @@ -270,6 +256,69 @@ export interface Client { simulate?: boolean; }) => Promise>>; + /** + * Construct and simulate a propose_admin transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + propose_admin: ( + { new_admin, time_limit }: { new_admin: string; time_limit: Option }, + options?: { + /** + * The fee to pay for the transaction. Default: BASE_FEE + */ + fee?: number; + + /** + * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT + */ + timeoutInSeconds?: number; + + /** + * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true + */ + simulate?: boolean; + } + ) => Promise>>; + + /** + * Construct and simulate a revoke_admin_change transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + revoke_admin_change: (options?: { + /** + * The fee to pay for the transaction. Default: BASE_FEE + */ + fee?: number; + + /** + * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT + */ + timeoutInSeconds?: number; + + /** + * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true + */ + simulate?: boolean; + }) => Promise>>; + + /** + * Construct and simulate a accept_admin transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + accept_admin: (options?: { + /** + * The fee to pay for the transaction. Default: BASE_FEE + */ + fee?: number; + + /** + * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT + */ + timeoutInSeconds?: number; + + /** + * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true + */ + simulate?: boolean; + }) => Promise>>; + /** * Construct and simulate a update transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ @@ -292,9 +341,51 @@ export interface Client { simulate?: boolean; } ) => Promise>; + + /** + * Construct and simulate a query_version transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + query_version: (options?: { + /** + * The fee to pay for the transaction. Default: BASE_FEE + */ + fee?: number; + + /** + * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT + */ + timeoutInSeconds?: number; + + /** + * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true + */ + simulate?: boolean; + }) => Promise>; + + /** + * Construct and simulate a add_new_key_to_storage transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + add_new_key_to_storage: (options?: { + /** + * The fee to pay for the transaction. Default: BASE_FEE + */ + fee?: number; + + /** + * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT + */ + timeoutInSeconds?: number; + + /** + * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true + */ + simulate?: boolean; + }) => Promise>>; } export class Client extends ContractClient { static async deploy( + /** Constructor/Initialization Args for the contract's `__constructor` method */ + { admin, factory }: { admin: string; factory: string }, /** Options for initalizing a Client as well as for calling a method, with extras specific to deploying. */ options: MethodOptions & Omit & { @@ -306,18 +397,23 @@ export class Client extends ContractClient { format?: "hex" | "base64"; } ): Promise> { - return ContractClient.deploy(null, options); + return ContractClient.deploy({ admin, factory }, options); } constructor(public readonly options: ContractClientOptions) { super( new ContractSpec([ - "AAAAAAAAAAAAAAAKaW5pdGlhbGl6ZQAAAAAAAgAAAAAAAAAFYWRtaW4AAAAAAAATAAAAAAAAAAdmYWN0b3J5AAAAABMAAAAA", "AAAAAAAAAAAAAAAEc3dhcAAAAAcAAAAAAAAACXJlY2lwaWVudAAAAAAAABMAAAAAAAAACm9wZXJhdGlvbnMAAAAAA+oAAAfQAAAABFN3YXAAAAAAAAAADm1heF9zcHJlYWRfYnBzAAAAAAPoAAAABwAAAAAAAAAGYW1vdW50AAAAAAALAAAAAAAAAAlwb29sX3R5cGUAAAAAAAfQAAAACFBvb2xUeXBlAAAAAAAAAAhkZWFkbGluZQAAA+gAAAAGAAAAAAAAABNtYXhfYWxsb3dlZF9mZWVfYnBzAAAAA+gAAAAHAAAAAA==", "AAAAAAAAAAAAAAANc2ltdWxhdGVfc3dhcAAAAAAAAAMAAAAAAAAACm9wZXJhdGlvbnMAAAAAA+oAAAfQAAAABFN3YXAAAAAAAAAABmFtb3VudAAAAAAACwAAAAAAAAAJcG9vbF90eXBlAAAAAAAH0AAAAAhQb29sVHlwZQAAAAEAAAfQAAAAFFNpbXVsYXRlU3dhcFJlc3BvbnNl", "AAAAAAAAAAAAAAAVc2ltdWxhdGVfcmV2ZXJzZV9zd2FwAAAAAAAAAwAAAAAAAAAKb3BlcmF0aW9ucwAAAAAD6gAAB9AAAAAEU3dhcAAAAAAAAAAGYW1vdW50AAAAAAALAAAAAAAAAAlwb29sX3R5cGUAAAAAAAfQAAAACFBvb2xUeXBlAAAAAQAAB9AAAAAbU2ltdWxhdGVSZXZlcnNlU3dhcFJlc3BvbnNlAA==", "AAAAAAAAAAAAAAARbWlncmF0ZV9hZG1pbl9rZXkAAAAAAAAAAAAAAQAAA+kAAAPtAAAAAAAAB9AAAAANQ29udHJhY3RFcnJvcgAAAA==", + "AAAAAAAAAAAAAAANcHJvcG9zZV9hZG1pbgAAAAAAAAIAAAAAAAAACW5ld19hZG1pbgAAAAAAABMAAAAAAAAACnRpbWVfbGltaXQAAAAAA+gAAAAGAAAAAQAAA+kAAAATAAAH0AAAAA1Db250cmFjdEVycm9yAAAA", + "AAAAAAAAAAAAAAATcmV2b2tlX2FkbWluX2NoYW5nZQAAAAAAAAAAAQAAA+kAAAPtAAAAAAAAB9AAAAANQ29udHJhY3RFcnJvcgAAAA==", + "AAAAAAAAAAAAAAAMYWNjZXB0X2FkbWluAAAAAAAAAAEAAAPpAAAAEwAAB9AAAAANQ29udHJhY3RFcnJvcgAAAA==", + "AAAAAAAAAAAAAAANX19jb25zdHJ1Y3RvcgAAAAAAAAIAAAAAAAAABWFkbWluAAAAAAAAEwAAAAAAAAAHZmFjdG9yeQAAAAATAAAAAA==", "AAAAAAAAAAAAAAAGdXBkYXRlAAAAAAABAAAAAAAAAA1uZXdfd2FzbV9oYXNoAAAAAAAD7gAAACAAAAAA", - "AAAABAAAAAAAAAAAAAAADUNvbnRyYWN0RXJyb3IAAAAAAAAEAAAAAAAAABJBbHJlYWR5SW5pdGlhbGl6ZWQAAAAAAAEAAAAAAAAAD09wZXJhdGlvbnNFbXB0eQAAAAACAAAAAAAAABJJbmNvcnJlY3RBc3NldFN3YXAAAAAAAAMAAAAAAAAAC0FkbWluTm90U2V0AAAAAAQ=", + "AAAAAAAAAAAAAAANcXVlcnlfdmVyc2lvbgAAAAAAAAAAAAABAAAAEA==", + "AAAAAAAAAAAAAAAWYWRkX25ld19rZXlfdG9fc3RvcmFnZQAAAAAAAAAAAAEAAAPpAAAD7QAAAAAAAAfQAAAADUNvbnRyYWN0RXJyb3IAAAA=", + "AAAABAAAAAAAAAAAAAAADUNvbnRyYWN0RXJyb3IAAAAAAAAHAAAAAAAAABJBbHJlYWR5SW5pdGlhbGl6ZWQAAAAAAMgAAAAAAAAAD09wZXJhdGlvbnNFbXB0eQAAAADJAAAAAAAAABJJbmNvcnJlY3RBc3NldFN3YXAAAAAAAMoAAAAAAAAAC0FkbWluTm90U2V0AAAAAMsAAAAAAAAACVNhbWVBZG1pbgAAAAAAAMwAAAAAAAAAFE5vQWRtaW5DaGFuZ2VJblBsYWNlAAAAzQAAAAAAAAASQWRtaW5DaGFuZ2VFeHBpcmVkAAAAAADO", "AAAAAQAAAAAAAAAAAAAABFN3YXAAAAADAAAAAAAAAAlhc2tfYXNzZXQAAAAAAAATAAAAAAAAABRhc2tfYXNzZXRfbWluX2Ftb3VudAAAA+gAAAALAAAAAAAAAAtvZmZlcl9hc3NldAAAAAAT", "AAAAAQAAAAAAAAAAAAAABFBhaXIAAAACAAAAAAAAAAd0b2tlbl9hAAAAABMAAAAAAAAAB3Rva2VuX2IAAAAAEw==", "AAAAAgAAAAAAAAAAAAAAB0RhdGFLZXkAAAAABAAAAAEAAAAAAAAAB1BhaXJLZXkAAAAAAQAAB9AAAAAEUGFpcgAAAAAAAAAAAAAACkZhY3RvcnlLZXkAAAAAAAAAAAAAAAAABUFkbWluAAAAAAAAAAAAAAAAAAALSW5pdGlhbGl6ZWQA", @@ -328,17 +424,23 @@ export class Client extends ContractClient { "AAAAAQAAAAAAAAAAAAAADVRva2VuSW5pdEluZm8AAAAAAAACAAAAAAAAAAd0b2tlbl9hAAAAABMAAAAAAAAAB3Rva2VuX2IAAAAAEw==", "AAAAAQAAAAAAAAAAAAAADVN0YWtlSW5pdEluZm8AAAAAAAAEAAAAAAAAAAdtYW5hZ2VyAAAAABMAAAAAAAAADm1heF9jb21wbGV4aXR5AAAAAAAEAAAAAAAAAAhtaW5fYm9uZAAAAAsAAAAAAAAACm1pbl9yZXdhcmQAAAAAAAs=", "AAAAAQAAAAAAAAAAAAAAFUxpcXVpZGl0eVBvb2xJbml0SW5mbwAAAAAAAAkAAAAAAAAABWFkbWluAAAAAAAAEwAAAAAAAAAUZGVmYXVsdF9zbGlwcGFnZV9icHMAAAAHAAAAAAAAAA1mZWVfcmVjaXBpZW50AAAAAAAAEwAAAAAAAAAYbWF4X2FsbG93ZWRfc2xpcHBhZ2VfYnBzAAAABwAAAAAAAAAWbWF4X2FsbG93ZWRfc3ByZWFkX2JwcwAAAAAABwAAAAAAAAAQbWF4X3JlZmVycmFsX2JwcwAAAAcAAAAAAAAAD3N0YWtlX2luaXRfaW5mbwAAAAfQAAAADVN0YWtlSW5pdEluZm8AAAAAAAAAAAAADHN3YXBfZmVlX2JwcwAAAAcAAAAAAAAAD3Rva2VuX2luaXRfaW5mbwAAAAfQAAAADVRva2VuSW5pdEluZm8AAAA=", + "AAAAAQAAAAAAAAAAAAAAC0FkbWluQ2hhbmdlAAAAAAIAAAAAAAAACW5ld19hZG1pbgAAAAAAABMAAAAAAAAACnRpbWVfbGltaXQAAAAAA+gAAAAG", + "AAAAAQAAAAAAAAAAAAAAD0F1dG9VbnN0YWtlSW5mbwAAAAACAAAAAAAAAAxzdGFrZV9hbW91bnQAAAALAAAAAAAAAA9zdGFrZV90aW1lc3RhbXAAAAAABg==", "AAAAAwAAAAAAAAAAAAAACFBvb2xUeXBlAAAAAgAAAAAAAAADWHlrAAAAAAAAAAAAAAAABlN0YWJsZQAAAAAAAQ==", ]), options ); } public readonly fromJSON = { - initialize: this.txFromJSON, swap: this.txFromJSON, simulate_swap: this.txFromJSON, simulate_reverse_swap: this.txFromJSON, migrate_admin_key: this.txFromJSON>, + propose_admin: this.txFromJSON>, + revoke_admin_change: this.txFromJSON>, + accept_admin: this.txFromJSON>, update: this.txFromJSON, + query_version: this.txFromJSON, + add_new_key_to_storage: this.txFromJSON>, }; } diff --git a/packages/contracts/src/phoenix-pair/index.ts b/packages/contracts/src/phoenix-pair/index.ts index b76358ec..050eaf73 100644 --- a/packages/contracts/src/phoenix-pair/index.ts +++ b/packages/contracts/src/phoenix-pair/index.ts @@ -30,63 +30,42 @@ if (typeof window !== "undefined") { window.Buffer = window.Buffer || Buffer; } -export const Errors = { - 1: { message: "SpreadExceedsLimit" }, - - 2: { message: "ProvideLiquiditySlippageToleranceTooHigh" }, - - 3: { message: "ProvideLiquidityAtLeastOneTokenMustBeBiggerThenZero" }, - - 4: { message: "WithdrawLiquidityMinimumAmountOfAOrBIsNotSatisfied" }, - - 5: { message: "SplitDepositBothPoolsAndDepositMustBePositive" }, - - 6: { message: "ValidateFeeBpsTotalFeesCantBeGreaterThan100" }, - - 7: { message: "GetDepositAmountsMinABiggerThenDesiredA" }, - - 8: { message: "GetDepositAmountsMinBBiggerThenDesiredB" }, - - 9: { message: "GetDepositAmountsAmountABiggerThenDesiredA" }, - - 10: { message: "GetDepositAmountsAmountALessThenMinA" }, - - 11: { message: "GetDepositAmountsAmountBBiggerThenDesiredB" }, - - 12: { message: "GetDepositAmountsAmountBLessThenMinB" }, - - 13: { message: "TotalSharesEqualZero" }, - - 14: { message: "DesiredAmountsBelowOrEqualZero" }, - - 15: { message: "MinAmountsBelowZero" }, - - 16: { message: "AssetNotInPool" }, - - 17: { message: "AlreadyInitialized" }, - - 18: { message: "TokenABiggerThanTokenB" }, - - 19: { message: "InvalidBps" }, - - 20: { message: "SlippageInvalid" }, - - 21: { message: "SwapMinReceivedBiggerThanReturn" }, - - 22: { message: "TransactionAfterTimestampDeadline" }, - - 23: { message: "CannotConvertU256ToI128" }, - - 24: { message: "UserDeclinesPoolFee" }, - - 25: { message: "SwapFeeBpsOverLimit" }, - - 26: { message: "NotEnoughSharesToBeMinted" }, - - 27: { message: "NotEnoughLiquidityProvided" }, - - 28: { message: "AdminNotSet" }, +export const ContractError = { + 300: { message: "SpreadExceedsLimit" }, + 301: { message: "ProvideLiquiditySlippageToleranceTooHigh" }, + 302: { message: "ProvideLiquidityAtLeastOneTokenMustBeBiggerThenZero" }, + 303: { message: "WithdrawLiquidityMinimumAmountOfAOrBIsNotSatisfied" }, + 304: { message: "SplitDepositBothPoolsAndDepositMustBePositive" }, + 305: { message: "ValidateFeeBpsTotalFeesCantBeGreaterThan100" }, + 306: { message: "GetDepositAmountsMinABiggerThenDesiredA" }, + 307: { message: "GetDepositAmountsMinBBiggerThenDesiredB" }, + 308: { message: "GetDepositAmountsAmountABiggerThenDesiredA" }, + 309: { message: "GetDepositAmountsAmountALessThenMinA" }, + 310: { message: "GetDepositAmountsAmountBBiggerThenDesiredB" }, + 311: { message: "GetDepositAmountsAmountBLessThenMinB" }, + 312: { message: "TotalSharesEqualZero" }, + 313: { message: "DesiredAmountsBelowOrEqualZero" }, + 314: { message: "MinAmountsBelowZero" }, + 315: { message: "AssetNotInPool" }, + 316: { message: "AlreadyInitialized" }, + 317: { message: "TokenABiggerThanTokenB" }, + 318: { message: "InvalidBps" }, + 319: { message: "SlippageInvalid" }, + 320: { message: "SwapMinReceivedBiggerThanReturn" }, + 321: { message: "TransactionAfterTimestampDeadline" }, + 322: { message: "CannotConvertU256ToI128" }, + 323: { message: "UserDeclinesPoolFee" }, + 324: { message: "SwapFeeBpsOverLimit" }, + 325: { message: "NotEnoughSharesToBeMinted" }, + 326: { message: "NotEnoughLiquidityProvided" }, + 327: { message: "AdminNotSet" }, + 328: { message: "ContractMathError" }, + 329: { message: "NegativeInputProvided" }, + 330: { message: "SameAdmin" }, + 331: { message: "NoAdminChangeInPlace" }, + 332: { message: "AdminChangeExpired" }, }; + export enum PairType { Xyk = 0, } @@ -225,55 +204,22 @@ export interface LiquidityPoolInitInfo { token_init_info: TokenInitInfo; } +export interface AdminChange { + new_admin: string; + time_limit: Option; +} + +export interface AutoUnstakeInfo { + stake_amount: i128; + stake_timestamp: u64; +} + export enum PoolType { Xyk = 0, Stable = 1, } export interface Client { - /** - * Construct and simulate a initialize transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. - */ - initialize: ( - { - stake_wasm_hash, - token_wasm_hash, - lp_init_info, - factory_addr, - share_token_decimals, - share_token_name, - share_token_symbol, - default_slippage_bps, - max_allowed_fee_bps, - }: { - stake_wasm_hash: Buffer; - token_wasm_hash: Buffer; - lp_init_info: LiquidityPoolInitInfo; - factory_addr: string; - share_token_decimals: u32; - share_token_name: string; - share_token_symbol: string; - default_slippage_bps: i64; - max_allowed_fee_bps: i64; - }, - options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - } - ) => Promise>; - /** * Construct and simulate a provide_liquidity transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ @@ -286,6 +232,7 @@ export interface Client { min_b, custom_slippage_bps, deadline, + auto_stake, }: { sender: string; desired_a: Option; @@ -294,6 +241,7 @@ export interface Client { min_b: Option; custom_slippage_bps: Option; deadline: Option; + auto_stake: boolean; }, options?: { /** @@ -362,12 +310,14 @@ export interface Client { min_a, min_b, deadline, + auto_unstake, }: { sender: string; share_amount: i128; min_a: i128; min_b: i128; deadline: Option; + auto_unstake: Option; }, options?: { /** @@ -428,10 +378,7 @@ export interface Client { * Construct and simulate a upgrade transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ upgrade: ( - { - new_wasm_hash, - new_default_slippage_bps, - }: { new_wasm_hash: Buffer; new_default_slippage_bps: i64 }, + { new_wasm_hash }: { new_wasm_hash: Buffer }, options?: { /** * The fee to pay for the transaction. Default: BASE_FEE @@ -660,10 +607,10 @@ export interface Client { }) => Promise>>; /** - * Construct and simulate a update transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Construct and simulate a propose_admin transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ - update: ( - { new_wasm_hash }: { new_wasm_hash: Buffer }, + propose_admin: ( + { new_admin, time_limit }: { new_admin: string; time_limit: Option }, options?: { /** * The fee to pay for the transaction. Default: BASE_FEE @@ -680,11 +627,131 @@ export interface Client { */ simulate?: boolean; } - ) => Promise>; + ) => Promise>>; + + /** + * Construct and simulate a revoke_admin_change transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + revoke_admin_change: (options?: { + /** + * The fee to pay for the transaction. Default: BASE_FEE + */ + fee?: number; + + /** + * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT + */ + timeoutInSeconds?: number; + + /** + * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true + */ + simulate?: boolean; + }) => Promise>>; + + /** + * Construct and simulate a accept_admin transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + accept_admin: (options?: { + /** + * The fee to pay for the transaction. Default: BASE_FEE + */ + fee?: number; + + /** + * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT + */ + timeoutInSeconds?: number; + + /** + * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true + */ + simulate?: boolean; + }) => Promise>>; + + /** + * Construct and simulate a query_admin transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + query_admin: (options?: { + /** + * The fee to pay for the transaction. Default: BASE_FEE + */ + fee?: number; + + /** + * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT + */ + timeoutInSeconds?: number; + + /** + * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true + */ + simulate?: boolean; + }) => Promise>>; + + /** + * Construct and simulate a query_version transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + query_version: (options?: { + /** + * The fee to pay for the transaction. Default: BASE_FEE + */ + fee?: number; + + /** + * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT + */ + timeoutInSeconds?: number; + + /** + * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true + */ + simulate?: boolean; + }) => Promise>; + + /** + * Construct and simulate a add_new_key_to_storage transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + add_new_key_to_storage: (options?: { + /** + * The fee to pay for the transaction. Default: BASE_FEE + */ + fee?: number; + + /** + * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT + */ + timeoutInSeconds?: number; + + /** + * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true + */ + simulate?: boolean; + }) => Promise>>; } export class Client extends ContractClient { static async deploy( - /** Options for initalizing a Client as well as for calling a method, with extras specific to deploying. */ + /** Constructor/Initialization Args for the contract's `__constructor` method */ + { + stake_wasm_hash, + token_wasm_hash, + lp_init_info, + factory_addr, + share_token_name, + share_token_symbol, + default_slippage_bps, + max_allowed_fee_bps, + }: { + stake_wasm_hash: Buffer; + token_wasm_hash: Buffer; + lp_init_info: LiquidityPoolInitInfo; + factory_addr: string; + share_token_name: string; + share_token_symbol: string; + default_slippage_bps: i64; + max_allowed_fee_bps: i64; + }, + /** Options for initializing a Client as well as for calling a method, with extras specific to deploying. */ options: MethodOptions & Omit & { /** The hash of the Wasm blob, which must already be installed on-chain. */ @@ -695,17 +762,28 @@ export class Client extends ContractClient { format?: "hex" | "base64"; } ): Promise> { - return ContractClient.deploy(null, options); + return ContractClient.deploy( + { + stake_wasm_hash, + token_wasm_hash, + lp_init_info, + factory_addr, + share_token_name, + share_token_symbol, + default_slippage_bps, + max_allowed_fee_bps, + }, + options + ); } constructor(public readonly options: ContractClientOptions) { super( new ContractSpec([ - "AAAAAAAAAAAAAAAKaW5pdGlhbGl6ZQAAAAAACQAAAAAAAAAPc3Rha2Vfd2FzbV9oYXNoAAAAA+4AAAAgAAAAAAAAAA90b2tlbl93YXNtX2hhc2gAAAAD7gAAACAAAAAAAAAADGxwX2luaXRfaW5mbwAAB9AAAAAVTGlxdWlkaXR5UG9vbEluaXRJbmZvAAAAAAAAAAAAAAxmYWN0b3J5X2FkZHIAAAATAAAAAAAAABRzaGFyZV90b2tlbl9kZWNpbWFscwAAAAQAAAAAAAAAEHNoYXJlX3Rva2VuX25hbWUAAAAQAAAAAAAAABJzaGFyZV90b2tlbl9zeW1ib2wAAAAAABAAAAAAAAAAFGRlZmF1bHRfc2xpcHBhZ2VfYnBzAAAABwAAAAAAAAATbWF4X2FsbG93ZWRfZmVlX2JwcwAAAAAHAAAAAA==", - "AAAAAAAAAAAAAAARcHJvdmlkZV9saXF1aWRpdHkAAAAAAAAHAAAAAAAAAAZzZW5kZXIAAAAAABMAAAAAAAAACWRlc2lyZWRfYQAAAAAAA+gAAAALAAAAAAAAAAVtaW5fYQAAAAAAA+gAAAALAAAAAAAAAAlkZXNpcmVkX2IAAAAAAAPoAAAACwAAAAAAAAAFbWluX2IAAAAAAAPoAAAACwAAAAAAAAATY3VzdG9tX3NsaXBwYWdlX2JwcwAAAAPoAAAABwAAAAAAAAAIZGVhZGxpbmUAAAPoAAAABgAAAAA=", + "AAAAAAAAAAAAAAARcHJvdmlkZV9saXF1aWRpdHkAAAAAAAAIAAAAAAAAAAZzZW5kZXIAAAAAABMAAAAAAAAACWRlc2lyZWRfYQAAAAAAA+gAAAALAAAAAAAAAAVtaW5fYQAAAAAAA+gAAAALAAAAAAAAAAlkZXNpcmVkX2IAAAAAAAPoAAAACwAAAAAAAAAFbWluX2IAAAAAAAPoAAAACwAAAAAAAAATY3VzdG9tX3NsaXBwYWdlX2JwcwAAAAPoAAAABwAAAAAAAAAIZGVhZGxpbmUAAAPoAAAABgAAAAAAAAAKYXV0b19zdGFrZQAAAAAAAQAAAAA=", "AAAAAAAAAAAAAAAEc3dhcAAAAAcAAAAAAAAABnNlbmRlcgAAAAAAEwAAAAAAAAALb2ZmZXJfYXNzZXQAAAAAEwAAAAAAAAAMb2ZmZXJfYW1vdW50AAAACwAAAAAAAAAUYXNrX2Fzc2V0X21pbl9hbW91bnQAAAPoAAAACwAAAAAAAAAObWF4X3NwcmVhZF9icHMAAAAAA+gAAAAHAAAAAAAAAAhkZWFkbGluZQAAA+gAAAAGAAAAAAAAABNtYXhfYWxsb3dlZF9mZWVfYnBzAAAAA+gAAAAHAAAAAQAAAAs=", - "AAAAAAAAAAAAAAASd2l0aGRyYXdfbGlxdWlkaXR5AAAAAAAFAAAAAAAAAAZzZW5kZXIAAAAAABMAAAAAAAAADHNoYXJlX2Ftb3VudAAAAAsAAAAAAAAABW1pbl9hAAAAAAAACwAAAAAAAAAFbWluX2IAAAAAAAALAAAAAAAAAAhkZWFkbGluZQAAA+gAAAAGAAAAAQAAA+0AAAACAAAACwAAAAs=", + "AAAAAAAAAAAAAAASd2l0aGRyYXdfbGlxdWlkaXR5AAAAAAAGAAAAAAAAAAZzZW5kZXIAAAAAABMAAAAAAAAADHNoYXJlX2Ftb3VudAAAAAsAAAAAAAAABW1pbl9hAAAAAAAACwAAAAAAAAAFbWluX2IAAAAAAAALAAAAAAAAAAhkZWFkbGluZQAAA+gAAAAGAAAAAAAAAAxhdXRvX3Vuc3Rha2UAAAPoAAAH0AAAAA9BdXRvVW5zdGFrZUluZm8AAAAAAQAAA+0AAAACAAAACwAAAAs=", "AAAAAAAAAAAAAAANdXBkYXRlX2NvbmZpZwAAAAAAAAYAAAAAAAAACW5ld19hZG1pbgAAAAAAA+gAAAATAAAAAAAAAA10b3RhbF9mZWVfYnBzAAAAAAAD6AAAAAcAAAAAAAAADWZlZV9yZWNpcGllbnQAAAAAAAPoAAAAEwAAAAAAAAAYbWF4X2FsbG93ZWRfc2xpcHBhZ2VfYnBzAAAD6AAAAAcAAAAAAAAAFm1heF9hbGxvd2VkX3NwcmVhZF9icHMAAAAAA+gAAAAHAAAAAAAAABBtYXhfcmVmZXJyYWxfYnBzAAAD6AAAAAcAAAAA", - "AAAAAAAAAAAAAAAHdXBncmFkZQAAAAACAAAAAAAAAA1uZXdfd2FzbV9oYXNoAAAAAAAD7gAAACAAAAAAAAAAGG5ld19kZWZhdWx0X3NsaXBwYWdlX2JwcwAAAAcAAAAA", + "AAAAAAAAAAAAAAAHdXBncmFkZQAAAAABAAAAAAAAAA1uZXdfd2FzbV9oYXNoAAAAAAAD7gAAACAAAAAA", "AAAAAAAAAAAAAAAMcXVlcnlfY29uZmlnAAAAAAAAAAEAAAfQAAAABkNvbmZpZwAA", "AAAAAAAAAAAAAAAZcXVlcnlfc2hhcmVfdG9rZW5fYWRkcmVzcwAAAAAAAAAAAAABAAAAEw==", "AAAAAAAAAAAAAAAccXVlcnlfc3Rha2VfY29udHJhY3RfYWRkcmVzcwAAAAAAAAABAAAAEw==", @@ -716,8 +794,14 @@ export class Client extends ContractClient { "AAAAAAAAAAAAAAALcXVlcnlfc2hhcmUAAAAAAQAAAAAAAAAGYW1vdW50AAAAAAALAAAAAQAAA+0AAAACAAAH0AAAAAVBc3NldAAAAAAAB9AAAAAFQXNzZXQAAAA=", "AAAAAAAAAAAAAAAVcXVlcnlfdG90YWxfaXNzdWVkX2xwAAAAAAAAAAAAAAEAAAAL", "AAAAAAAAAAAAAAARbWlncmF0ZV9hZG1pbl9rZXkAAAAAAAAAAAAAAQAAA+kAAAPtAAAAAAAAB9AAAAANQ29udHJhY3RFcnJvcgAAAA==", - "AAAAAAAAAAAAAAAGdXBkYXRlAAAAAAABAAAAAAAAAA1uZXdfd2FzbV9oYXNoAAAAAAAD7gAAACAAAAAA", - "AAAABAAAAAAAAAAAAAAADUNvbnRyYWN0RXJyb3IAAAAAAAAcAAAAAAAAABJTcHJlYWRFeGNlZWRzTGltaXQAAAAAAAEAAAAAAAAAKFByb3ZpZGVMaXF1aWRpdHlTbGlwcGFnZVRvbGVyYW5jZVRvb0hpZ2gAAAACAAAAAAAAADNQcm92aWRlTGlxdWlkaXR5QXRMZWFzdE9uZVRva2VuTXVzdEJlQmlnZ2VyVGhlblplcm8AAAAAAwAAAAAAAAAyV2l0aGRyYXdMaXF1aWRpdHlNaW5pbXVtQW1vdW50T2ZBT3JCSXNOb3RTYXRpc2ZpZWQAAAAAAAQAAAAAAAAALVNwbGl0RGVwb3NpdEJvdGhQb29sc0FuZERlcG9zaXRNdXN0QmVQb3NpdGl2ZQAAAAAAAAUAAAAAAAAAK1ZhbGlkYXRlRmVlQnBzVG90YWxGZWVzQ2FudEJlR3JlYXRlclRoYW4xMDAAAAAABgAAAAAAAAAnR2V0RGVwb3NpdEFtb3VudHNNaW5BQmlnZ2VyVGhlbkRlc2lyZWRBAAAAAAcAAAAAAAAAJ0dldERlcG9zaXRBbW91bnRzTWluQkJpZ2dlclRoZW5EZXNpcmVkQgAAAAAIAAAAAAAAACpHZXREZXBvc2l0QW1vdW50c0Ftb3VudEFCaWdnZXJUaGVuRGVzaXJlZEEAAAAAAAkAAAAAAAAAJEdldERlcG9zaXRBbW91bnRzQW1vdW50QUxlc3NUaGVuTWluQQAAAAoAAAAAAAAAKkdldERlcG9zaXRBbW91bnRzQW1vdW50QkJpZ2dlclRoZW5EZXNpcmVkQgAAAAAACwAAAAAAAAAkR2V0RGVwb3NpdEFtb3VudHNBbW91bnRCTGVzc1RoZW5NaW5CAAAADAAAAAAAAAAUVG90YWxTaGFyZXNFcXVhbFplcm8AAAANAAAAAAAAAB5EZXNpcmVkQW1vdW50c0JlbG93T3JFcXVhbFplcm8AAAAAAA4AAAAAAAAAE01pbkFtb3VudHNCZWxvd1plcm8AAAAADwAAAAAAAAAOQXNzZXROb3RJblBvb2wAAAAAABAAAAAAAAAAEkFscmVhZHlJbml0aWFsaXplZAAAAAAAEQAAAAAAAAAWVG9rZW5BQmlnZ2VyVGhhblRva2VuQgAAAAAAEgAAAAAAAAAKSW52YWxpZEJwcwAAAAAAEwAAAAAAAAAPU2xpcHBhZ2VJbnZhbGlkAAAAABQAAAAAAAAAH1N3YXBNaW5SZWNlaXZlZEJpZ2dlclRoYW5SZXR1cm4AAAAAFQAAAAAAAAAhVHJhbnNhY3Rpb25BZnRlclRpbWVzdGFtcERlYWRsaW5lAAAAAAAAFgAAAAAAAAAXQ2Fubm90Q29udmVydFUyNTZUb0kxMjgAAAAAFwAAAAAAAAATVXNlckRlY2xpbmVzUG9vbEZlZQAAAAAYAAAAAAAAABNTd2FwRmVlQnBzT3ZlckxpbWl0AAAAABkAAAAAAAAAGU5vdEVub3VnaFNoYXJlc1RvQmVNaW50ZWQAAAAAAAAaAAAAAAAAABpOb3RFbm91Z2hMaXF1aWRpdHlQcm92aWRlZAAAAAAAGwAAAAAAAAALQWRtaW5Ob3RTZXQAAAAAHA==", + "AAAAAAAAAAAAAAANcHJvcG9zZV9hZG1pbgAAAAAAAAIAAAAAAAAACW5ld19hZG1pbgAAAAAAABMAAAAAAAAACnRpbWVfbGltaXQAAAAAA+gAAAAGAAAAAQAAA+kAAAATAAAH0AAAAA1Db250cmFjdEVycm9yAAAA", + "AAAAAAAAAAAAAAATcmV2b2tlX2FkbWluX2NoYW5nZQAAAAAAAAAAAQAAA+kAAAPtAAAAAAAAB9AAAAANQ29udHJhY3RFcnJvcgAAAA==", + "AAAAAAAAAAAAAAAMYWNjZXB0X2FkbWluAAAAAAAAAAEAAAPpAAAAEwAAB9AAAAANQ29udHJhY3RFcnJvcgAAAA==", + "AAAAAAAAAAAAAAALcXVlcnlfYWRtaW4AAAAAAAAAAAEAAAPpAAAAEwAAB9AAAAANQ29udHJhY3RFcnJvcgAAAA==", + "AAAAAAAAAAAAAAANX19jb25zdHJ1Y3RvcgAAAAAAAAgAAAAAAAAAD3N0YWtlX3dhc21faGFzaAAAAAPuAAAAIAAAAAAAAAAPdG9rZW5fd2FzbV9oYXNoAAAAA+4AAAAgAAAAAAAAAAxscF9pbml0X2luZm8AAAfQAAAAFUxpcXVpZGl0eVBvb2xJbml0SW5mbwAAAAAAAAAAAAAMZmFjdG9yeV9hZGRyAAAAEwAAAAAAAAAQc2hhcmVfdG9rZW5fbmFtZQAAABAAAAAAAAAAEnNoYXJlX3Rva2VuX3N5bWJvbAAAAAAAEAAAAAAAAAAUZGVmYXVsdF9zbGlwcGFnZV9icHMAAAAHAAAAAAAAABNtYXhfYWxsb3dlZF9mZWVfYnBzAAAAAAcAAAAA", + "AAAAAAAAAAAAAAANcXVlcnlfdmVyc2lvbgAAAAAAAAAAAAABAAAAEA==", + "AAAAAAAAAAAAAAAWYWRkX25ld19rZXlfdG9fc3RvcmFnZQAAAAAAAAAAAAEAAAPpAAAD7QAAAAAAAAfQAAAADUNvbnRyYWN0RXJyb3IAAAA=", + "AAAABAAAAAAAAAAAAAAADUNvbnRyYWN0RXJyb3IAAAAAAAAhAAAAAAAAABJTcHJlYWRFeGNlZWRzTGltaXQAAAAAASwAAAAAAAAAKFByb3ZpZGVMaXF1aWRpdHlTbGlwcGFnZVRvbGVyYW5jZVRvb0hpZ2gAAAEtAAAAAAAAADNQcm92aWRlTGlxdWlkaXR5QXRMZWFzdE9uZVRva2VuTXVzdEJlQmlnZ2VyVGhlblplcm8AAAABLgAAAAAAAAAyV2l0aGRyYXdMaXF1aWRpdHlNaW5pbXVtQW1vdW50T2ZBT3JCSXNOb3RTYXRpc2ZpZWQAAAAAAS8AAAAAAAAALVNwbGl0RGVwb3NpdEJvdGhQb29sc0FuZERlcG9zaXRNdXN0QmVQb3NpdGl2ZQAAAAAAATAAAAAAAAAAK1ZhbGlkYXRlRmVlQnBzVG90YWxGZWVzQ2FudEJlR3JlYXRlclRoYW4xMDAAAAABMQAAAAAAAAAnR2V0RGVwb3NpdEFtb3VudHNNaW5BQmlnZ2VyVGhlbkRlc2lyZWRBAAAAATIAAAAAAAAAJ0dldERlcG9zaXRBbW91bnRzTWluQkJpZ2dlclRoZW5EZXNpcmVkQgAAAAEzAAAAAAAAACpHZXREZXBvc2l0QW1vdW50c0Ftb3VudEFCaWdnZXJUaGVuRGVzaXJlZEEAAAAAATQAAAAAAAAAJEdldERlcG9zaXRBbW91bnRzQW1vdW50QUxlc3NUaGVuTWluQQAAATUAAAAAAAAAKkdldERlcG9zaXRBbW91bnRzQW1vdW50QkJpZ2dlclRoZW5EZXNpcmVkQgAAAAABNgAAAAAAAAAkR2V0RGVwb3NpdEFtb3VudHNBbW91bnRCTGVzc1RoZW5NaW5CAAABNwAAAAAAAAAUVG90YWxTaGFyZXNFcXVhbFplcm8AAAE4AAAAAAAAAB5EZXNpcmVkQW1vdW50c0JlbG93T3JFcXVhbFplcm8AAAAAATkAAAAAAAAAE01pbkFtb3VudHNCZWxvd1plcm8AAAABOgAAAAAAAAAOQXNzZXROb3RJblBvb2wAAAAAATsAAAAAAAAAEkFscmVhZHlJbml0aWFsaXplZAAAAAABPAAAAAAAAAAWVG9rZW5BQmlnZ2VyVGhhblRva2VuQgAAAAABPQAAAAAAAAAKSW52YWxpZEJwcwAAAAABPgAAAAAAAAAPU2xpcHBhZ2VJbnZhbGlkAAAAAT8AAAAAAAAAH1N3YXBNaW5SZWNlaXZlZEJpZ2dlclRoYW5SZXR1cm4AAAABQAAAAAAAAAAhVHJhbnNhY3Rpb25BZnRlclRpbWVzdGFtcERlYWRsaW5lAAAAAAABQQAAAAAAAAAXQ2Fubm90Q29udmVydFUyNTZUb0kxMjgAAAABQgAAAAAAAAATVXNlckRlY2xpbmVzUG9vbEZlZQAAAAFDAAAAAAAAABNTd2FwRmVlQnBzT3ZlckxpbWl0AAAAAUQAAAAAAAAAGU5vdEVub3VnaFNoYXJlc1RvQmVNaW50ZWQAAAAAAAFFAAAAAAAAABpOb3RFbm91Z2hMaXF1aWRpdHlQcm92aWRlZAAAAAABRgAAAAAAAAALQWRtaW5Ob3RTZXQAAAABRwAAAAAAAAARQ29udHJhY3RNYXRoRXJyb3IAAAAAAAFIAAAAAAAAABVOZWdhdGl2ZUlucHV0UHJvdmlkZWQAAAAAAAFJAAAAAAAAAAlTYW1lQWRtaW4AAAAAAAFKAAAAAAAAABROb0FkbWluQ2hhbmdlSW5QbGFjZQAAAUsAAAAAAAAAEkFkbWluQ2hhbmdlRXhwaXJlZAAAAAABTA==", "AAAAAwAAAAAAAAAAAAAACFBhaXJUeXBlAAAAAQAAAAAAAAADWHlrAAAAAAA=", "AAAAAQAAAAAAAAAAAAAABkNvbmZpZwAAAAAACgAAAAAAAAANZmVlX3JlY2lwaWVudAAAAAAAABMAAABUVGhlIG1heGltdW0gYW1vdW50IG9mIHNsaXBwYWdlIChpbiBicHMpIHRoYXQgaXMgdG9sZXJhdGVkIGR1cmluZyBwcm92aWRpbmcgbGlxdWlkaXR5AAAAGG1heF9hbGxvd2VkX3NsaXBwYWdlX2JwcwAAAAcAAABDVGhlIG1heGltdW0gYW1vdW50IG9mIHNwcmVhZCAoaW4gYnBzKSB0aGF0IGlzIHRvbGVyYXRlZCBkdXJpbmcgc3dhcAAAAAAWbWF4X2FsbG93ZWRfc3ByZWFkX2JwcwAAAAAABwAAADhUaGUgbWF4aW11bSBhbGxvd2VkIHBlcmNlbnRhZ2UgKGluIGJwcykgZm9yIHJlZmVycmFsIGZlZQAAABBtYXhfcmVmZXJyYWxfYnBzAAAABwAAAAAAAAAJcG9vbF90eXBlAAAAAAAH0AAAAAhQYWlyVHlwZQAAAAAAAAALc2hhcmVfdG9rZW4AAAAAEwAAAAAAAAAOc3Rha2VfY29udHJhY3QAAAAAABMAAAAAAAAAB3Rva2VuX2EAAAAAEwAAAAAAAAAHdG9rZW5fYgAAAAATAAAAZFRoZSB0b3RhbCBmZWVzIChpbiBicHMpIGNoYXJnZWQgYnkgYSBwb29sIG9mIHRoaXMgdHlwZS4KSW4gcmVsYXRpb24gdG8gdGhlIHJldHVybmVkIGFtb3VudCBvZiB0b2tlbnMAAAANdG90YWxfZmVlX2JwcwAAAAAAAAc=", "AAAAAQAAAAAAAAAAAAAABUFzc2V0AAAAAAAAAgAAABRBZGRyZXNzIG9mIHRoZSBhc3NldAAAAAdhZGRyZXNzAAAAABMAAAAsVGhlIHRvdGFsIGFtb3VudCBvZiB0aG9zZSB0b2tlbnMgaW4gdGhlIHBvb2wAAAAGYW1vdW50AAAAAAAL", @@ -730,13 +814,14 @@ export class Client extends ContractClient { "AAAAAQAAAAAAAAAAAAAADVRva2VuSW5pdEluZm8AAAAAAAACAAAAAAAAAAd0b2tlbl9hAAAAABMAAAAAAAAAB3Rva2VuX2IAAAAAEw==", "AAAAAQAAAAAAAAAAAAAADVN0YWtlSW5pdEluZm8AAAAAAAAEAAAAAAAAAAdtYW5hZ2VyAAAAABMAAAAAAAAADm1heF9jb21wbGV4aXR5AAAAAAAEAAAAAAAAAAhtaW5fYm9uZAAAAAsAAAAAAAAACm1pbl9yZXdhcmQAAAAAAAs=", "AAAAAQAAAAAAAAAAAAAAFUxpcXVpZGl0eVBvb2xJbml0SW5mbwAAAAAAAAkAAAAAAAAABWFkbWluAAAAAAAAEwAAAAAAAAAUZGVmYXVsdF9zbGlwcGFnZV9icHMAAAAHAAAAAAAAAA1mZWVfcmVjaXBpZW50AAAAAAAAEwAAAAAAAAAYbWF4X2FsbG93ZWRfc2xpcHBhZ2VfYnBzAAAABwAAAAAAAAAWbWF4X2FsbG93ZWRfc3ByZWFkX2JwcwAAAAAABwAAAAAAAAAQbWF4X3JlZmVycmFsX2JwcwAAAAcAAAAAAAAAD3N0YWtlX2luaXRfaW5mbwAAAAfQAAAADVN0YWtlSW5pdEluZm8AAAAAAAAAAAAADHN3YXBfZmVlX2JwcwAAAAcAAAAAAAAAD3Rva2VuX2luaXRfaW5mbwAAAAfQAAAADVRva2VuSW5pdEluZm8AAAA=", + "AAAAAQAAAAAAAAAAAAAAC0FkbWluQ2hhbmdlAAAAAAIAAAAAAAAACW5ld19hZG1pbgAAAAAAABMAAAAAAAAACnRpbWVfbGltaXQAAAAAA+gAAAAG", + "AAAAAQAAAAAAAAAAAAAAD0F1dG9VbnN0YWtlSW5mbwAAAAACAAAAAAAAAAxzdGFrZV9hbW91bnQAAAALAAAAAAAAAA9zdGFrZV90aW1lc3RhbXAAAAAABg==", "AAAAAwAAAAAAAAAAAAAACFBvb2xUeXBlAAAAAgAAAAAAAAADWHlrAAAAAAAAAAAAAAAABlN0YWJsZQAAAAAAAQ==", ]), options ); } public readonly fromJSON = { - initialize: this.txFromJSON, provide_liquidity: this.txFromJSON, swap: this.txFromJSON, withdraw_liquidity: this.txFromJSON, @@ -752,6 +837,11 @@ export class Client extends ContractClient { query_share: this.txFromJSON, query_total_issued_lp: this.txFromJSON, migrate_admin_key: this.txFromJSON>, - update: this.txFromJSON, + propose_admin: this.txFromJSON>, + revoke_admin_change: this.txFromJSON>, + accept_admin: this.txFromJSON>, + query_admin: this.txFromJSON>, + query_version: this.txFromJSON, + add_new_key_to_storage: this.txFromJSON>, }; } diff --git a/packages/contracts/src/phoenix-stake/index.ts b/packages/contracts/src/phoenix-stake/index.ts index fd0036ec..0b3634bf 100644 --- a/packages/contracts/src/phoenix-stake/index.ts +++ b/packages/contracts/src/phoenix-stake/index.ts @@ -1,5 +1,4 @@ import { Buffer } from "buffer"; -import { Address } from "@stellar/stellar-sdk"; import { AssembledTransaction, Client as ContractClient, @@ -10,16 +9,11 @@ import { } from "@stellar/stellar-sdk/contract"; import type { u32, - i32, u64, i64, u128, i128, - u256, - i256, Option, - Typepoint, - Duration, } from "@stellar/stellar-sdk/contract"; export * from "@stellar/stellar-sdk"; export * as contract from "@stellar/stellar-sdk/contract"; @@ -34,34 +28,26 @@ export type DistributionDataKey = | { tag: "RewardHistory"; values: readonly [string] } | { tag: "TotalStakedHistory"; values: void }; -export const Errors = { - 1: { message: "AlreadyInitialized" }, - - 2: { message: "InvalidMinBond" }, - - 3: { message: "InvalidMinReward" }, - - 4: { message: "InvalidBond" }, - - 5: { message: "Unauthorized" }, - - 6: { message: "MinRewardNotEnough" }, - - 7: { message: "RewardsInvalid" }, - - 8: { message: "StakeNotFound" }, - - 9: { message: "InvalidTime" }, - - 10: { message: "DistributionExists" }, - - 11: { message: "InvalidRewardAmount" }, - - 12: { message: "InvalidMaxComplexity" }, - - 13: { message: "DistributionNotFound" }, - - 14: { message: "AdminNotSet" }, +export const ContractError = { + 500: { message: "AlreadyInitialized" }, + 501: { message: "InvalidMinBond" }, + 502: { message: "InvalidMinReward" }, + 503: { message: "InvalidBond" }, + 504: { message: "Unauthorized" }, + 505: { message: "MinRewardNotEnough" }, + 506: { message: "RewardsInvalid" }, + 509: { message: "StakeNotFound" }, + 510: { message: "InvalidTime" }, + 511: { message: "DistributionExists" }, + 512: { message: "InvalidRewardAmount" }, + 513: { message: "InvalidMaxComplexity" }, + 514: { message: "DistributionNotFound" }, + 515: { message: "AdminNotSet" }, + 516: { message: "ContractMathError" }, + 517: { message: "RewardCurveDoesNotExist" }, + 518: { message: "SameAdmin" }, + 519: { message: "NoAdminChangeInPlace" }, + 520: { message: "AdminChangeExpired" }, }; export interface ConfigResponse { @@ -161,51 +147,22 @@ export interface LiquidityPoolInitInfo { token_init_info: TokenInitInfo; } +export interface AdminChange { + new_admin: string; + time_limit: Option; +} + +export interface AutoUnstakeInfo { + stake_amount: i128; + stake_timestamp: u64; +} + export enum PoolType { Xyk = 0, Stable = 1, } export interface Client { - /** - * Construct and simulate a initialize transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. - */ - initialize: ( - { - admin, - lp_token, - min_bond, - min_reward, - manager, - owner, - max_complexity, - }: { - admin: string; - lp_token: string; - min_bond: i128; - min_reward: i128; - manager: string; - owner: string; - max_complexity: u32; - }, - options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - } - ) => Promise>; - /** * Construct and simulate a bond transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ @@ -480,7 +437,25 @@ export interface Client { } export class Client extends ContractClient { static async deploy( - /** Options for initalizing a Client as well as for calling a method, with extras specific to deploying. */ + /** Constructor/Initialization Args for the contract's `__constructor` method */ + { + admin, + lp_token, + min_bond, + min_reward, + manager, + owner, + max_complexity, + }: { + admin: string; + lp_token: string; + min_bond: i128; + min_reward: i128; + manager: string; + owner: string; + max_complexity: u32; + }, + /** Options for initializing a Client as well as for calling a method, with extras specific to deploying. */ options: MethodOptions & Omit & { /** The hash of the Wasm blob, which must already be installed on-chain. */ @@ -491,12 +466,15 @@ export class Client extends ContractClient { format?: "hex" | "base64"; } ): Promise> { - return ContractClient.deploy(null, options); + return ContractClient.deploy( + { admin, lp_token, min_bond, min_reward, manager, owner, max_complexity }, + options + ); } constructor(public readonly options: ContractClientOptions) { super( new ContractSpec([ - "AAAAAAAAAAAAAAAKaW5pdGlhbGl6ZQAAAAAABwAAAAAAAAAFYWRtaW4AAAAAAAATAAAAAAAAAAhscF90b2tlbgAAABMAAAAAAAAACG1pbl9ib25kAAAACwAAAAAAAAAKbWluX3Jld2FyZAAAAAAACwAAAAAAAAAHbWFuYWdlcgAAAAATAAAAAAAAAAVvd25lcgAAAAAAABMAAAAAAAAADm1heF9jb21wbGV4aXR5AAAAAAAEAAAAAA==", + "AAAAAAAAAAAAAAANX19jb25zdHJ1Y3RvcgAAAAAAAAcAAAAAAAAABWFkbWluAAAAAAAAEwAAAAAAAAAIbHBfdG9rZW4AAAATAAAAAAAAAAhtaW5fYm9uZAAAAAsAAAAAAAAACm1pbl9yZXdhcmQAAAAAAAsAAAAAAAAAB21hbmFnZXIAAAAAEwAAAAAAAAAFb3duZXIAAAAAAAATAAAAAAAAAA5tYXhfY29tcGxleGl0eQAAAAAABAAAAAA=", "AAAAAAAAAAAAAAAEYm9uZAAAAAIAAAAAAAAABnNlbmRlcgAAAAAAEwAAAAAAAAAGdG9rZW5zAAAAAAALAAAAAA==", "AAAAAAAAAAAAAAAGdW5ib25kAAAAAAADAAAAAAAAAAZzZW5kZXIAAAAAABMAAAAAAAAADHN0YWtlX2Ftb3VudAAAAAsAAAAAAAAAD3N0YWtlX3RpbWVzdGFtcAAAAAAGAAAAAA==", "AAAAAAAAAAAAAAAYY3JlYXRlX2Rpc3RyaWJ1dGlvbl9mbG93AAAAAgAAAAAAAAAGc2VuZGVyAAAAAAATAAAAAAAAAAVhc3NldAAAAAAAABMAAAAA", @@ -510,7 +488,7 @@ export class Client extends ContractClient { "AAAAAAAAAAAAAAARbWlncmF0ZV9hZG1pbl9rZXkAAAAAAAAAAAAAAQAAA+kAAAPtAAAAAAAAB9AAAAANQ29udHJhY3RFcnJvcgAAAA==", "AAAAAAAAAAAAAAAGdXBkYXRlAAAAAAABAAAAAAAAAA1uZXdfd2FzbV9oYXNoAAAAAAAD7gAAACAAAAAA", "AAAAAgAAAAAAAAAAAAAAE0Rpc3RyaWJ1dGlvbkRhdGFLZXkAAAAAAgAAAAEAAAAAAAAADVJld2FyZEhpc3RvcnkAAAAAAAABAAAAEwAAAAAAAAAAAAAAElRvdGFsU3Rha2VkSGlzdG9yeQAA", - "AAAABAAAAAAAAAAAAAAADUNvbnRyYWN0RXJyb3IAAAAAAAAOAAAAAAAAABJBbHJlYWR5SW5pdGlhbGl6ZWQAAAAAAAEAAAAAAAAADkludmFsaWRNaW5Cb25kAAAAAAACAAAAAAAAABBJbnZhbGlkTWluUmV3YXJkAAAAAwAAAAAAAAALSW52YWxpZEJvbmQAAAAABAAAAAAAAAAMVW5hdXRob3JpemVkAAAABQAAAAAAAAASTWluUmV3YXJkTm90RW5vdWdoAAAAAAAGAAAAAAAAAA5SZXdhcmRzSW52YWxpZAAAAAAABwAAAAAAAAANU3Rha2VOb3RGb3VuZAAAAAAAAAgAAAAAAAAAC0ludmFsaWRUaW1lAAAAAAkAAAAAAAAAEkRpc3RyaWJ1dGlvbkV4aXN0cwAAAAAACgAAAAAAAAATSW52YWxpZFJld2FyZEFtb3VudAAAAAALAAAAAAAAABRJbnZhbGlkTWF4Q29tcGxleGl0eQAAAAwAAAAAAAAAFERpc3RyaWJ1dGlvbk5vdEZvdW5kAAAADQAAAAAAAAALQWRtaW5Ob3RTZXQAAAAADg==", + "AAAABAAAAAAAAAAAAAAADUNvbnRyYWN0RXJyb3IAAAAAAAATAAAAAAAAABJBbHJlYWR5SW5pdGlhbGl6ZWQAAAAAAfQAAAAAAAAADkludmFsaWRNaW5Cb25kAAAAAAH1AAAAAAAAABBJbnZhbGlkTWluUmV3YXJkAAAB9gAAAAAAAAALSW52YWxpZEJvbmQAAAAB9wAAAAAAAAAMVW5hdXRob3JpemVkAAAB+AAAAAAAAAASTWluUmV3YXJkTm90RW5vdWdoAAAAAAH5AAAAAAAAAA5SZXdhcmRzSW52YWxpZAAAAAAB+gAAAAAAAAANU3Rha2VOb3RGb3VuZAAAAAAAAf0AAAAAAAAAC0ludmFsaWRUaW1lAAAAAf4AAAAAAAAAEkRpc3RyaWJ1dGlvbkV4aXN0cwAAAAAB/wAAAAAAAAATSW52YWxpZFJld2FyZEFtb3VudAAAAAIAAAAAAAAAABRJbnZhbGlkTWF4Q29tcGxleGl0eQAAAgEAAAAAAAAAFERpc3RyaWJ1dGlvbk5vdEZvdW5kAAACAgAAAAAAAAALQWRtaW5Ob3RTZXQAAAACAwAAAAAAAAARQ29udHJhY3RNYXRoRXJyb3IAAAAAAAIEAAAAAAAAABdSZXdhcmRDdXJ2ZURvZXNOb3RFeGlzdAAAAAIFAAAAAAAAAAlTYW1lQWRtaW4AAAAAAAIGAAAAAAAAABROb0FkbWluQ2hhbmdlSW5QbGFjZQAAAgcAAAAAAAAAEkFkbWluQ2hhbmdlRXhwaXJlZAAAAAACCA==", "AAAAAQAAAAAAAAAAAAAADkNvbmZpZ1Jlc3BvbnNlAAAAAAABAAAAAAAAAAZjb25maWcAAAAAB9AAAAAGQ29uZmlnAAA=", "AAAAAQAAAAAAAAAAAAAADlN0YWtlZFJlc3BvbnNlAAAAAAADAAAAAAAAABBsYXN0X3Jld2FyZF90aW1lAAAABgAAAAAAAAAGc3Rha2VzAAAAAAPqAAAH0AAAAAVTdGFrZQAAAAAAAAAAAAALdG90YWxfc3Rha2UAAAAACw==", "AAAAAQAAAAAAAAAAAAAAEEFubnVhbGl6ZWRSZXdhcmQAAAACAAAAAAAAAAZhbW91bnQAAAAAABAAAAAAAAAABWFzc2V0AAAAAAAAEw==", @@ -523,13 +501,14 @@ export class Client extends ContractClient { "AAAAAQAAAAAAAAAAAAAADVRva2VuSW5pdEluZm8AAAAAAAACAAAAAAAAAAd0b2tlbl9hAAAAABMAAAAAAAAAB3Rva2VuX2IAAAAAEw==", "AAAAAQAAAAAAAAAAAAAADVN0YWtlSW5pdEluZm8AAAAAAAAEAAAAAAAAAAdtYW5hZ2VyAAAAABMAAAAAAAAADm1heF9jb21wbGV4aXR5AAAAAAAEAAAAAAAAAAhtaW5fYm9uZAAAAAsAAAAAAAAACm1pbl9yZXdhcmQAAAAAAAs=", "AAAAAQAAAAAAAAAAAAAAFUxpcXVpZGl0eVBvb2xJbml0SW5mbwAAAAAAAAkAAAAAAAAABWFkbWluAAAAAAAAEwAAAAAAAAAUZGVmYXVsdF9zbGlwcGFnZV9icHMAAAAHAAAAAAAAAA1mZWVfcmVjaXBpZW50AAAAAAAAEwAAAAAAAAAYbWF4X2FsbG93ZWRfc2xpcHBhZ2VfYnBzAAAABwAAAAAAAAAWbWF4X2FsbG93ZWRfc3ByZWFkX2JwcwAAAAAABwAAAAAAAAAQbWF4X3JlZmVycmFsX2JwcwAAAAcAAAAAAAAAD3N0YWtlX2luaXRfaW5mbwAAAAfQAAAADVN0YWtlSW5pdEluZm8AAAAAAAAAAAAADHN3YXBfZmVlX2JwcwAAAAcAAAAAAAAAD3Rva2VuX2luaXRfaW5mbwAAAAfQAAAADVRva2VuSW5pdEluZm8AAAA=", + "AAAAAQAAAAAAAAAAAAAAC0FkbWluQ2hhbmdlAAAAAAIAAAAAAAAACW5ld19hZG1pbgAAAAAAABMAAAAAAAAACnRpbWVfbGltaXQAAAAAA+gAAAAG", + "AAAAAQAAAAAAAAAAAAAAD0F1dG9VbnN0YWtlSW5mbwAAAAACAAAAAAAAAAxzdGFrZV9hbW91bnQAAAALAAAAAAAAAA9zdGFrZV90aW1lc3RhbXAAAAAABg==", "AAAAAwAAAAAAAAAAAAAACFBvb2xUeXBlAAAAAgAAAAAAAAADWHlrAAAAAAAAAAAAAAAABlN0YWJsZQAAAAAAAQ==", ]), options ); } public readonly fromJSON = { - initialize: this.txFromJSON, bond: this.txFromJSON, unbond: this.txFromJSON, create_distribution_flow: this.txFromJSON, diff --git a/packages/contracts/src/phoenix-vesting/index.ts b/packages/contracts/src/phoenix-vesting/index.ts index 369de870..3f736c6c 100644 --- a/packages/contracts/src/phoenix-vesting/index.ts +++ b/packages/contracts/src/phoenix-vesting/index.ts @@ -1,5 +1,4 @@ import { Buffer } from "buffer"; -import { Address } from "@stellar/stellar-sdk"; import { AssembledTransaction, Client as ContractClient, @@ -10,16 +9,11 @@ import { } from "@stellar/stellar-sdk/contract"; import type { u32, - i32, u64, i64, u128, i128, - u256, - i256, Option, - Typepoint, - Duration, } from "@stellar/stellar-sdk/contract"; export * from "@stellar/stellar-sdk"; export * as contract from "@stellar/stellar-sdk/contract"; @@ -31,75 +25,87 @@ if (typeof window !== "undefined") { } export const Errors = { - 0: { message: "Std" }, + 700: { message: "VestingNotFoundForAddress" }, - 1: { message: "VestingNotFoundForAddress" }, + 701: { message: "AllowanceNotFoundForGivenPair" }, - 2: { message: "AllowanceNotFoundForGivenPair" }, + 702: { message: "MinterNotFound" }, - 3: { message: "MinterNotFound" }, + 703: { message: "NoBalanceFoundForAddress" }, - 4: { message: "NoBalanceFoundForAddress" }, + 704: { message: "NoConfigFound" }, - 5: { message: "NoConfigFound" }, + 705: { message: "NoAdminFound" }, - 6: { message: "NoAdminFound" }, + 706: { message: "MissingBalance" }, - 7: { message: "MissingBalance" }, + 707: { message: "VestingComplexityTooHigh" }, - 8: { message: "VestingComplexityTooHigh" }, + 708: { message: "TotalVestedOverCapacity" }, - 9: { message: "TotalVestedOverCapacity" }, + 709: { message: "InvalidTransferAmount" }, - 10: { message: "InvalidTransferAmount" }, + 710: { message: "CantMoveVestingTokens" }, - 11: { message: "CantMoveVestingTokens" }, + 711: { message: "NotEnoughCapacity" }, - 12: { message: "NotEnoughCapacity" }, + 712: { message: "NotAuthorized" }, - 13: { message: "NotAuthorized" }, + 713: { message: "NeverFullyVested" }, - 14: { message: "NeverFullyVested" }, + 714: { message: "VestsMoreThanSent" }, - 15: { message: "VestsMoreThanSent" }, + 715: { message: "InvalidBurnAmount" }, - 16: { message: "InvalidBurnAmount" }, + 716: { message: "InvalidMintAmount" }, - 17: { message: "InvalidMintAmount" }, + 717: { message: "InvalidAllowanceAmount" }, - 18: { message: "InvalidAllowanceAmount" }, + 718: { message: "DuplicateInitialBalanceAddresses" }, - 19: { message: "DuplicateInitialBalanceAddresses" }, + 719: { message: "CurveError" }, - 20: { message: "CurveError" }, + 720: { message: "NoWhitelistFound" }, - 21: { message: "NoWhitelistFound" }, + 721: { message: "NoTokenInfoFound" }, - 22: { message: "NoTokenInfoFound" }, + 722: { message: "NoVestingComplexityValueFound" }, - 23: { message: "NoVestingComplexityValueFound" }, + 723: { message: "NoAddressesToAdd" }, - 24: { message: "NoAddressesToAdd" }, + 724: { message: "NoEnoughtTokensToStart" }, - 25: { message: "NoEnoughtTokensToStart" }, + 725: { message: "NotEnoughBalance" }, - 26: { message: "NotEnoughBalance" }, + 726: { message: "VestingBothPresent" }, - 27: { message: "VestingBothPresent" }, + 727: { message: "VestingNonePresent" }, - 28: { message: "VestingNonePresent" }, + 728: { message: "CurveConstant" }, - 29: { message: "CurveConstant" }, + 729: { message: "CurveSLNotDecreasing" }, - 30: { message: "CurveSLNotDecreasing" }, + 730: { message: "AlreadyInitialized" }, - 31: { message: "AlreadyInitialized" }, + 731: { message: "AdminNotFound" }, - 32: { message: "AdminNotFound" }, + 732: { message: "ContractMathError" }, - 33: { message: "ContractMathError" }, + 733: { message: "SameAdmin" }, + + 734: { message: "NoAdminChangeInPlace" }, + + 735: { message: "AdminChangeExpired" }, + + 745: { message: "SameTokenAddress" }, + + 746: { message: "InvalidMaxComplexity" }, }; +export interface Config { + is_with_minter: boolean; +} + export interface VestingTokenInfo { address: string; decimals: u32; @@ -219,76 +225,22 @@ export interface LiquidityPoolInitInfo { token_init_info: TokenInitInfo; } +export interface AdminChange { + new_admin: string; + time_limit: Option; +} + +export interface AutoUnstakeInfo { + stake_amount: i128; + stake_timestamp: u64; +} + export enum PoolType { Xyk = 0, Stable = 1, } export interface Client { - /** - * Construct and simulate a initialize transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. - */ - initialize: ( - { - admin, - vesting_token, - max_vesting_complexity, - }: { - admin: string; - vesting_token: VestingTokenInfo; - max_vesting_complexity: u32; - }, - options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - } - ) => Promise>; - - /** - * Construct and simulate a initialize_with_minter transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. - */ - initialize_with_minter: ( - { - admin, - vesting_token, - max_vesting_complexity, - minter_info, - }: { - admin: string; - vesting_token: VestingTokenInfo; - max_vesting_complexity: u32; - minter_info: MinterInfo; - }, - options?: { - /** - * The fee to pay for the transaction. Default: BASE_FEE - */ - fee?: number; - - /** - * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT - */ - timeoutInSeconds?: number; - - /** - * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true - */ - simulate?: boolean; - } - ) => Promise>; - /** * Construct and simulate a create_vesting_schedules transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ @@ -536,6 +488,26 @@ export interface Client { simulate?: boolean; }) => Promise>; + /** + * Construct and simulate a query_config transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + query_config: (options?: { + /** + * The fee to pay for the transaction. Default: BASE_FEE + */ + fee?: number; + + /** + * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT + */ + timeoutInSeconds?: number; + + /** + * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true + */ + simulate?: boolean; + }) => Promise>; + /** * Construct and simulate a query_vesting_contract_balance transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ @@ -579,6 +551,52 @@ export interface Client { } ) => Promise>; + /** + * Construct and simulate a update_vesting_token transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + update_vesting_token: ( + { new_token_address }: { new_token_address: string }, + options?: { + /** + * The fee to pay for the transaction. Default: BASE_FEE + */ + fee?: number; + + /** + * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT + */ + timeoutInSeconds?: number; + + /** + * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true + */ + simulate?: boolean; + } + ) => Promise>>; + + /** + * Construct and simulate a update_max_complexity transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + update_max_complexity: ( + { new_max_complexity }: { new_max_complexity: u32 }, + options?: { + /** + * The fee to pay for the transaction. Default: BASE_FEE + */ + fee?: number; + + /** + * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT + */ + timeoutInSeconds?: number; + + /** + * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true + */ + simulate?: boolean; + } + ) => Promise>>; + /** * Construct and simulate a update transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ @@ -622,6 +640,69 @@ export interface Client { simulate?: boolean; }) => Promise>>; + /** + * Construct and simulate a propose_admin transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + propose_admin: ( + { new_admin, time_limit }: { new_admin: string; time_limit: Option }, + options?: { + /** + * The fee to pay for the transaction. Default: BASE_FEE + */ + fee?: number; + + /** + * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT + */ + timeoutInSeconds?: number; + + /** + * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true + */ + simulate?: boolean; + } + ) => Promise>>; + + /** + * Construct and simulate a revoke_admin_change transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + revoke_admin_change: (options?: { + /** + * The fee to pay for the transaction. Default: BASE_FEE + */ + fee?: number; + + /** + * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT + */ + timeoutInSeconds?: number; + + /** + * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true + */ + simulate?: boolean; + }) => Promise>>; + + /** + * Construct and simulate a accept_admin transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + accept_admin: (options?: { + /** + * The fee to pay for the transaction. Default: BASE_FEE + */ + fee?: number; + + /** + * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT + */ + timeoutInSeconds?: number; + + /** + * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true + */ + simulate?: boolean; + }) => Promise>>; + /** * Construct and simulate a add_new_key_to_storage transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. */ @@ -641,9 +722,41 @@ export interface Client { */ simulate?: boolean; }) => Promise>>; + + /** + * Construct and simulate a query_version transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + */ + query_version: (options?: { + /** + * The fee to pay for the transaction. Default: BASE_FEE + */ + fee?: number; + + /** + * The maximum amount of time to wait for the transaction to complete. Default: DEFAULT_TIMEOUT + */ + timeoutInSeconds?: number; + + /** + * Whether to automatically simulate the transaction when constructing the AssembledTransaction. Default: true + */ + simulate?: boolean; + }) => Promise>; } export class Client extends ContractClient { static async deploy( + /** Constructor/Initialization Args for the contract's `__constructor` method */ + { + admin, + vesting_token, + max_vesting_complexity, + minter_info, + }: { + admin: string; + vesting_token: VestingTokenInfo; + max_vesting_complexity: u32; + minter_info: Option; + }, /** Options for initalizing a Client as well as for calling a method, with extras specific to deploying. */ options: MethodOptions & Omit & { @@ -655,13 +768,14 @@ export class Client extends ContractClient { format?: "hex" | "base64"; } ): Promise> { - return ContractClient.deploy(null, options); + return ContractClient.deploy( + { admin, vesting_token, max_vesting_complexity, minter_info }, + options + ); } constructor(public readonly options: ContractClientOptions) { super( new ContractSpec([ - "AAAAAAAAAAAAAAAKaW5pdGlhbGl6ZQAAAAAAAwAAAAAAAAAFYWRtaW4AAAAAAAATAAAAAAAAAA12ZXN0aW5nX3Rva2VuAAAAAAAH0AAAABBWZXN0aW5nVG9rZW5JbmZvAAAAAAAAABZtYXhfdmVzdGluZ19jb21wbGV4aXR5AAAAAAAEAAAAAA==", - "AAAAAAAAAAAAAAAWaW5pdGlhbGl6ZV93aXRoX21pbnRlcgAAAAAABAAAAAAAAAAFYWRtaW4AAAAAAAATAAAAAAAAAA12ZXN0aW5nX3Rva2VuAAAAAAAH0AAAABBWZXN0aW5nVG9rZW5JbmZvAAAAAAAAABZtYXhfdmVzdGluZ19jb21wbGV4aXR5AAAAAAAEAAAAAAAAAAttaW50ZXJfaW5mbwAAAAfQAAAACk1pbnRlckluZm8AAAAAAAA=", "AAAAAAAAAAAAAAAYY3JlYXRlX3Zlc3Rpbmdfc2NoZWR1bGVzAAAAAQAAAAAAAAARdmVzdGluZ19zY2hlZHVsZXMAAAAAAAPqAAAH0AAAAA9WZXN0aW5nU2NoZWR1bGUAAAAAAA==", "AAAAAAAAAAAAAAAFY2xhaW0AAAAAAAACAAAAAAAAAAZzZW5kZXIAAAAAABMAAAAAAAAABWluZGV4AAAAAAAABgAAAAA=", "AAAAAAAAAAAAAAAEYnVybgAAAAIAAAAAAAAABnNlbmRlcgAAAAAAEwAAAAAAAAAGYW1vdW50AAAAAAAKAAAAAA==", @@ -673,12 +787,21 @@ export class Client extends ContractClient { "AAAAAAAAAAAAAAAWcXVlcnlfYWxsX3Zlc3RpbmdfaW5mbwAAAAAAAQAAAAAAAAAHYWRkcmVzcwAAAAATAAAAAQAAA+oAAAfQAAAAE1Zlc3RpbmdJbmZvUmVzcG9uc2UA", "AAAAAAAAAAAAAAAQcXVlcnlfdG9rZW5faW5mbwAAAAAAAAABAAAH0AAAABBWZXN0aW5nVG9rZW5JbmZv", "AAAAAAAAAAAAAAAMcXVlcnlfbWludGVyAAAAAAAAAAEAAAfQAAAACk1pbnRlckluZm8AAA==", + "AAAAAAAAAAAAAAAMcXVlcnlfY29uZmlnAAAAAAAAAAEAAAfQAAAABkNvbmZpZwAA", "AAAAAAAAAAAAAAAecXVlcnlfdmVzdGluZ19jb250cmFjdF9iYWxhbmNlAAAAAAAAAAAAAQAAAAs=", "AAAAAAAAAAAAAAAYcXVlcnlfYXZhaWxhYmxlX3RvX2NsYWltAAAAAgAAAAAAAAAHYWRkcmVzcwAAAAATAAAAAAAAAAVpbmRleAAAAAAAAAYAAAABAAAACw==", + "AAAAAAAAAAAAAAAUdXBkYXRlX3Zlc3RpbmdfdG9rZW4AAAABAAAAAAAAABFuZXdfdG9rZW5fYWRkcmVzcwAAAAAAABMAAAABAAAD6QAAA+0AAAAAAAAH0AAAAA1Db250cmFjdEVycm9yAAAA", + "AAAAAAAAAAAAAAAVdXBkYXRlX21heF9jb21wbGV4aXR5AAAAAAAAAQAAAAAAAAASbmV3X21heF9jb21wbGV4aXR5AAAAAAAEAAAAAQAAA+kAAAPtAAAAAAAAB9AAAAANQ29udHJhY3RFcnJvcgAAAA==", "AAAAAAAAAAAAAAAGdXBkYXRlAAAAAAABAAAAAAAAAA1uZXdfd2FzbV9oYXNoAAAAAAAD7gAAACAAAAAA", "AAAAAAAAAAAAAAARbWlncmF0ZV9hZG1pbl9rZXkAAAAAAAAAAAAAAQAAA+kAAAPtAAAAAAAAB9AAAAANQ29udHJhY3RFcnJvcgAAAA==", + "AAAAAAAAAAAAAAANcHJvcG9zZV9hZG1pbgAAAAAAAAIAAAAAAAAACW5ld19hZG1pbgAAAAAAABMAAAAAAAAACnRpbWVfbGltaXQAAAAAA+gAAAAGAAAAAQAAA+kAAAATAAAH0AAAAA1Db250cmFjdEVycm9yAAAA", + "AAAAAAAAAAAAAAATcmV2b2tlX2FkbWluX2NoYW5nZQAAAAAAAAAAAQAAA+kAAAPtAAAAAAAAB9AAAAANQ29udHJhY3RFcnJvcgAAAA==", + "AAAAAAAAAAAAAAAMYWNjZXB0X2FkbWluAAAAAAAAAAEAAAPpAAAAEwAAB9AAAAANQ29udHJhY3RFcnJvcgAAAA==", + "AAAAAAAAAAAAAAANX19jb25zdHJ1Y3RvcgAAAAAAAAQAAAAAAAAABWFkbWluAAAAAAAAEwAAAAAAAAANdmVzdGluZ190b2tlbgAAAAAAB9AAAAAQVmVzdGluZ1Rva2VuSW5mbwAAAAAAAAAWbWF4X3Zlc3RpbmdfY29tcGxleGl0eQAAAAAABAAAAAAAAAALbWludGVyX2luZm8AAAAD6AAAB9AAAAAKTWludGVySW5mbwAAAAAAAA==", "AAAAAAAAAAAAAAAWYWRkX25ld19rZXlfdG9fc3RvcmFnZQAAAAAAAAAAAAEAAAPpAAAD7QAAAAAAAAfQAAAADUNvbnRyYWN0RXJyb3IAAAA=", - "AAAABAAAAAAAAAAAAAAADUNvbnRyYWN0RXJyb3IAAAAAAAAiAAAAAAAAAANTdGQAAAAAAAAAAAAAAAAZVmVzdGluZ05vdEZvdW5kRm9yQWRkcmVzcwAAAAAAAAEAAAAAAAAAHUFsbG93YW5jZU5vdEZvdW5kRm9yR2l2ZW5QYWlyAAAAAAAAAgAAAAAAAAAOTWludGVyTm90Rm91bmQAAAAAAAMAAAAAAAAAGE5vQmFsYW5jZUZvdW5kRm9yQWRkcmVzcwAAAAQAAAAAAAAADU5vQ29uZmlnRm91bmQAAAAAAAAFAAAAAAAAAAxOb0FkbWluRm91bmQAAAAGAAAAAAAAAA5NaXNzaW5nQmFsYW5jZQAAAAAABwAAAAAAAAAYVmVzdGluZ0NvbXBsZXhpdHlUb29IaWdoAAAACAAAAAAAAAAXVG90YWxWZXN0ZWRPdmVyQ2FwYWNpdHkAAAAACQAAAAAAAAAVSW52YWxpZFRyYW5zZmVyQW1vdW50AAAAAAAACgAAAAAAAAAVQ2FudE1vdmVWZXN0aW5nVG9rZW5zAAAAAAAACwAAAAAAAAARTm90RW5vdWdoQ2FwYWNpdHkAAAAAAAAMAAAAAAAAAA1Ob3RBdXRob3JpemVkAAAAAAAADQAAAAAAAAAQTmV2ZXJGdWxseVZlc3RlZAAAAA4AAAAAAAAAEVZlc3RzTW9yZVRoYW5TZW50AAAAAAAADwAAAAAAAAARSW52YWxpZEJ1cm5BbW91bnQAAAAAAAAQAAAAAAAAABFJbnZhbGlkTWludEFtb3VudAAAAAAAABEAAAAAAAAAFkludmFsaWRBbGxvd2FuY2VBbW91bnQAAAAAABIAAAAAAAAAIER1cGxpY2F0ZUluaXRpYWxCYWxhbmNlQWRkcmVzc2VzAAAAEwAAAAAAAAAKQ3VydmVFcnJvcgAAAAAAFAAAAAAAAAAQTm9XaGl0ZWxpc3RGb3VuZAAAABUAAAAAAAAAEE5vVG9rZW5JbmZvRm91bmQAAAAWAAAAAAAAAB1Ob1Zlc3RpbmdDb21wbGV4aXR5VmFsdWVGb3VuZAAAAAAAABcAAAAAAAAAEE5vQWRkcmVzc2VzVG9BZGQAAAAYAAAAAAAAABZOb0Vub3VnaHRUb2tlbnNUb1N0YXJ0AAAAAAAZAAAAAAAAABBOb3RFbm91Z2hCYWxhbmNlAAAAGgAAAAAAAAASVmVzdGluZ0JvdGhQcmVzZW50AAAAAAAbAAAAAAAAABJWZXN0aW5nTm9uZVByZXNlbnQAAAAAABwAAAAAAAAADUN1cnZlQ29uc3RhbnQAAAAAAAAdAAAAAAAAABRDdXJ2ZVNMTm90RGVjcmVhc2luZwAAAB4AAAAAAAAAEkFscmVhZHlJbml0aWFsaXplZAAAAAAAHwAAAAAAAAANQWRtaW5Ob3RGb3VuZAAAAAAAACAAAAAAAAAAEUNvbnRyYWN0TWF0aEVycm9yAAAAAAAAIQ==", + "AAAAAAAAAAAAAAANcXVlcnlfdmVyc2lvbgAAAAAAAAAAAAABAAAAEA==", + "AAAABAAAAAAAAAAAAAAADUNvbnRyYWN0RXJyb3IAAAAAAAAmAAAAAAAAABlWZXN0aW5nTm90Rm91bmRGb3JBZGRyZXNzAAAAAAACvAAAAAAAAAAdQWxsb3dhbmNlTm90Rm91bmRGb3JHaXZlblBhaXIAAAAAAAK9AAAAAAAAAA5NaW50ZXJOb3RGb3VuZAAAAAACvgAAAAAAAAAYTm9CYWxhbmNlRm91bmRGb3JBZGRyZXNzAAACvwAAAAAAAAANTm9Db25maWdGb3VuZAAAAAAAAsAAAAAAAAAADE5vQWRtaW5Gb3VuZAAAAsEAAAAAAAAADk1pc3NpbmdCYWxhbmNlAAAAAALCAAAAAAAAABhWZXN0aW5nQ29tcGxleGl0eVRvb0hpZ2gAAALDAAAAAAAAABdUb3RhbFZlc3RlZE92ZXJDYXBhY2l0eQAAAALEAAAAAAAAABVJbnZhbGlkVHJhbnNmZXJBbW91bnQAAAAAAALFAAAAAAAAABVDYW50TW92ZVZlc3RpbmdUb2tlbnMAAAAAAALGAAAAAAAAABFOb3RFbm91Z2hDYXBhY2l0eQAAAAAAAscAAAAAAAAADU5vdEF1dGhvcml6ZWQAAAAAAALIAAAAAAAAABBOZXZlckZ1bGx5VmVzdGVkAAACyQAAAAAAAAARVmVzdHNNb3JlVGhhblNlbnQAAAAAAALKAAAAAAAAABFJbnZhbGlkQnVybkFtb3VudAAAAAAAAssAAAAAAAAAEUludmFsaWRNaW50QW1vdW50AAAAAAACzAAAAAAAAAAWSW52YWxpZEFsbG93YW5jZUFtb3VudAAAAAACzQAAAAAAAAAgRHVwbGljYXRlSW5pdGlhbEJhbGFuY2VBZGRyZXNzZXMAAALOAAAAAAAAAApDdXJ2ZUVycm9yAAAAAALPAAAAAAAAABBOb1doaXRlbGlzdEZvdW5kAAAC0AAAAAAAAAAQTm9Ub2tlbkluZm9Gb3VuZAAAAtEAAAAAAAAAHU5vVmVzdGluZ0NvbXBsZXhpdHlWYWx1ZUZvdW5kAAAAAAAC0gAAAAAAAAAQTm9BZGRyZXNzZXNUb0FkZAAAAtMAAAAAAAAAFk5vRW5vdWdodFRva2Vuc1RvU3RhcnQAAAAAAtQAAAAAAAAAEE5vdEVub3VnaEJhbGFuY2UAAALVAAAAAAAAABJWZXN0aW5nQm90aFByZXNlbnQAAAAAAtYAAAAAAAAAElZlc3RpbmdOb25lUHJlc2VudAAAAAAC1wAAAAAAAAANQ3VydmVDb25zdGFudAAAAAAAAtgAAAAAAAAAFEN1cnZlU0xOb3REZWNyZWFzaW5nAAAC2QAAAAAAAAASQWxyZWFkeUluaXRpYWxpemVkAAAAAALaAAAAAAAAAA1BZG1pbk5vdEZvdW5kAAAAAAAC2wAAAAAAAAARQ29udHJhY3RNYXRoRXJyb3IAAAAAAALcAAAAAAAAAAlTYW1lQWRtaW4AAAAAAALdAAAAAAAAABROb0FkbWluQ2hhbmdlSW5QbGFjZQAAAt4AAAAAAAAAEkFkbWluQ2hhbmdlRXhwaXJlZAAAAAAC3wAAAAAAAAAQU2FtZVRva2VuQWRkcmVzcwAAAukAAAAAAAAAFEludmFsaWRNYXhDb21wbGV4aXR5AAAC6g==", + "AAAAAQAAAAAAAAAAAAAABkNvbmZpZwAAAAAAAQAAAAAAAAAOaXNfd2l0aF9taW50ZXIAAAAAAAE=", "AAAAAQAAAAAAAAAAAAAAEFZlc3RpbmdUb2tlbkluZm8AAAAEAAAAAAAAAAdhZGRyZXNzAAAAABMAAAAAAAAACGRlY2ltYWxzAAAABAAAAAAAAAAEbmFtZQAAABAAAAAAAAAABnN5bWJvbAAAAAAAEA==", "AAAAAQAAAAAAAAAAAAAAD1Zlc3RpbmdTY2hlZHVsZQAAAAACAAAAAAAAAAVjdXJ2ZQAAAAAAB9AAAAAFQ3VydmUAAAAAAAAAAAAACXJlY2lwaWVudAAAAAAAABM=", "AAAAAQAAAAAAAAAAAAAAC1Zlc3RpbmdJbmZvAAAAAAMAAAAAAAAAB2JhbGFuY2UAAAAACgAAAAAAAAAJcmVjaXBpZW50AAAAAAAAEwAAAAAAAAAIc2NoZWR1bGUAAAfQAAAABUN1cnZlAAAA", @@ -693,14 +816,14 @@ export class Client extends ContractClient { "AAAAAQAAAAAAAAAAAAAADVRva2VuSW5pdEluZm8AAAAAAAACAAAAAAAAAAd0b2tlbl9hAAAAABMAAAAAAAAAB3Rva2VuX2IAAAAAEw==", "AAAAAQAAAAAAAAAAAAAADVN0YWtlSW5pdEluZm8AAAAAAAAEAAAAAAAAAAdtYW5hZ2VyAAAAABMAAAAAAAAADm1heF9jb21wbGV4aXR5AAAAAAAEAAAAAAAAAAhtaW5fYm9uZAAAAAsAAAAAAAAACm1pbl9yZXdhcmQAAAAAAAs=", "AAAAAQAAAAAAAAAAAAAAFUxpcXVpZGl0eVBvb2xJbml0SW5mbwAAAAAAAAkAAAAAAAAABWFkbWluAAAAAAAAEwAAAAAAAAAUZGVmYXVsdF9zbGlwcGFnZV9icHMAAAAHAAAAAAAAAA1mZWVfcmVjaXBpZW50AAAAAAAAEwAAAAAAAAAYbWF4X2FsbG93ZWRfc2xpcHBhZ2VfYnBzAAAABwAAAAAAAAAWbWF4X2FsbG93ZWRfc3ByZWFkX2JwcwAAAAAABwAAAAAAAAAQbWF4X3JlZmVycmFsX2JwcwAAAAcAAAAAAAAAD3N0YWtlX2luaXRfaW5mbwAAAAfQAAAADVN0YWtlSW5pdEluZm8AAAAAAAAAAAAADHN3YXBfZmVlX2JwcwAAAAcAAAAAAAAAD3Rva2VuX2luaXRfaW5mbwAAAAfQAAAADVRva2VuSW5pdEluZm8AAAA=", + "AAAAAQAAAAAAAAAAAAAAC0FkbWluQ2hhbmdlAAAAAAIAAAAAAAAACW5ld19hZG1pbgAAAAAAABMAAAAAAAAACnRpbWVfbGltaXQAAAAAA+gAAAAG", + "AAAAAQAAAAAAAAAAAAAAD0F1dG9VbnN0YWtlSW5mbwAAAAACAAAAAAAAAAxzdGFrZV9hbW91bnQAAAALAAAAAAAAAA9zdGFrZV90aW1lc3RhbXAAAAAABg==", "AAAAAwAAAAAAAAAAAAAACFBvb2xUeXBlAAAAAgAAAAAAAAADWHlrAAAAAAAAAAAAAAAABlN0YWJsZQAAAAAAAQ==", ]), options ); } public readonly fromJSON = { - initialize: this.txFromJSON, - initialize_with_minter: this.txFromJSON, create_vesting_schedules: this.txFromJSON, claim: this.txFromJSON, burn: this.txFromJSON, @@ -712,10 +835,17 @@ export class Client extends ContractClient { query_all_vesting_info: this.txFromJSON>, query_token_info: this.txFromJSON, query_minter: this.txFromJSON, + query_config: this.txFromJSON, query_vesting_contract_balance: this.txFromJSON, query_available_to_claim: this.txFromJSON, + update_vesting_token: this.txFromJSON>, + update_max_complexity: this.txFromJSON>, update: this.txFromJSON, migrate_admin_key: this.txFromJSON>, + propose_admin: this.txFromJSON>, + revoke_admin_change: this.txFromJSON>, + accept_admin: this.txFromJSON>, add_new_key_to_storage: this.txFromJSON>, + query_version: this.txFromJSON, }; } diff --git a/packages/contracts/src/soroban-token/index.ts b/packages/contracts/src/soroban-token/index.ts index 1d9356a5..3cfb1463 100644 --- a/packages/contracts/src/soroban-token/index.ts +++ b/packages/contracts/src/soroban-token/index.ts @@ -1,26 +1,12 @@ import { Buffer } from "buffer"; -import { Address } from "@stellar/stellar-sdk"; import { AssembledTransaction, Client as ContractClient, ClientOptions as ContractClientOptions, MethodOptions, - Result, Spec as ContractSpec, } from "@stellar/stellar-sdk/contract"; -import type { - u32, - i32, - u64, - i64, - u128, - i128, - u256, - i256, - Option, - Typepoint, - Duration, -} from "@stellar/stellar-sdk/contract"; +import type { u32, i128 } from "@stellar/stellar-sdk/contract"; export * from "@stellar/stellar-sdk"; export * as contract from "@stellar/stellar-sdk/contract"; export * as rpc from "@stellar/stellar-sdk/rpc"; diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json index 0f5824dc..e9fce2c9 100644 --- a/packages/contracts/tsconfig.json +++ b/packages/contracts/tsconfig.json @@ -5,6 +5,7 @@ "jsx": "react", "outDir": "./build", "target": "ESNext", + "module": "Node16", "moduleResolution": "Node16" } } diff --git a/packages/core/README.md b/packages/core/README.md index 5ad00b12..01619983 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,6 +1,15 @@ # Core Package -The Core package is the central component of the Phoenix Frontend project, built with Next.js. It brings together all the necessary packages and provides a unified setup for the application. This package acts as the entry point for the front end, allowing seamless integration of UI components, state management, and utility functions. +The Core package is the central component of the Phoenix Frontend project, built with Next.js 15. It brings together all the necessary packages and provides a unified setup for the application. This package acts as the entry point for the front end, allowing seamless integration of UI components, state management, and utility functions. + +## Features + +- Built with Next.js 15 for optimal performance and developer experience +- Integrated routing with application directories structure +- Page components for Swap, Pools, Earn, NFT and Help Center features +- Toast notifications and modal system +- Integration with Phoenix contract ecosystem +- Responsive design with proper mobile support ## Installation @@ -9,11 +18,25 @@ To install the Core package, follow these steps: 1. Ensure that you have Node.js and yarn installed on your machine. 2. Navigate to the root directory of the Phoenix Frontend project. 3. Run `yarn install` to install all project dependencies. +4. Run the setup script to build required dependencies: + ```bash + ./setup.sh + ``` ## Usage To use the Core package, follow these steps: -1. Navigate to `packages/core` -2. Run `yarn dev` +1. Navigate to the project root +2. Run `yarn dev:core` or `yarn dev` to start the development server 3. You can now browse on [http://localhost:3000](http://localhost:3000) to try it out locally! + +## Project Structure + +- `/app` - Next.js app directory with route components +- `/components` - Reusable UI components specific to the application +- `/context` - React context providers +- `/hooks` - Custom React hooks +- `/lib` - Utility functions specific to the Core package +- `/providers` - Higher-order components for providing context to the application +- `/public` - Static assets diff --git a/packages/core/app/earn/page.tsx b/packages/core/app/earn/page.tsx new file mode 100644 index 00000000..e3ea75a0 --- /dev/null +++ b/packages/core/app/earn/page.tsx @@ -0,0 +1,614 @@ +"use client"; +import { + Tab, + Tabs, + Typography, + useMediaQuery, + useTheme, + Button, +} from "@mui/material"; +import { Box } from "@mui/system"; +import { useAppStore, usePersistStore } from "@phoenix-protocol/state"; +import { + StrategiesTable, + YieldSummary, + BondModal, + UnbondModal, + ClaimAllModal, +} from "@phoenix-protocol/ui"; + +// Fix the imports to properly import StrategyRegistry +import { Strategy, StrategyMetadata } from "@phoenix-protocol/strategies"; +import { StrategyRegistry } from "@phoenix-protocol/strategies"; + +import { motion, AnimatePresence } from "framer-motion"; +import { useEffect, useState, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { useContractTransaction } from "../../hooks/useContractTransaction"; + +// Define the Token type +interface Token { + name: string; + icon: string; + usdValue: number; + amount: number; + category: string; +} + +export default function EarnPage(): JSX.Element { + const router = useRouter(); + const appStore = useAppStore(); + const persistStore = usePersistStore(); + const walletAddress = persistStore.wallet.address; + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("md")); + const { executeContractTransaction } = useContractTransaction(); + + // Strategy state + const [allStrategies, setAllStrategies] = useState< + { strategy: Strategy; metadata: StrategyMetadata }[] + >([]); + const [userStrategies, setUserStrategies] = useState< + { strategy: Strategy; metadata: StrategyMetadata }[] + >([]); + const [totalValue, setTotalValue] = useState(0); + const [claimableRewards, setClaimableRewards] = useState(0); + const [isLoading, setIsLoading] = useState(true); + + // Modal States + const [bondModalOpen, setBondModalOpen] = useState(false); + const [unbondModalOpen, setUnbondModalOpen] = useState(false); + const [claimAllModalOpen, setClaimAllModalOpen] = useState(false); + const [selectedStrategy, setSelectedStrategy] = + useState(null); + + // Filters + const [tabValue, setTabValue] = useState(0); + + // Initialize strategies on first load + useEffect(() => { + // Don't register mock providers anymore - we're using real ones + // registerMockProviders(); + + appStore.setLoading(true); + loadStrategies(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Load user-specific data when wallet changes + useEffect(() => { + if (walletAddress) { + loadUserStrategies(); + calculateUserStats(); + } else { + setUserStrategies([]); + setTotalValue(0); + setClaimableRewards(0); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [walletAddress, allStrategies]); + + // Set loading spinner timeout + useEffect(() => { + const timer = setTimeout(() => { + appStore.setLoading(false); + }, 1000); + return () => clearTimeout(timer); + }, [appStore]); + + // Load all strategies with their metadata + const loadStrategies = useCallback(async () => { + setIsLoading(true); + try { + console.log("Getting strategies from StrategyRegistry..."); + console.log("StrategyRegistry available?", !!StrategyRegistry); + + const providers = StrategyRegistry.getProviders(); + console.log("Available providers:", providers.length); + providers.forEach((p) => console.log("- Provider:", p.id)); + + const strategies = await StrategyRegistry.getAllStrategies(); + console.log("Strategies found:", strategies.length); + + const strategiesWithMetadata = await Promise.all( + strategies.map(async (strategy) => { + const metadata = await StrategyRegistry.getStrategyMetadata(strategy); + // Fetch user data immediately if wallet is connected + let userStake = 0; + let userRewards = 0; + let hasJoined = false; + if (walletAddress) { + try { + userStake = await strategy.getUserStake(walletAddress); + userRewards = await strategy.getUserRewards(walletAddress); + hasJoined = await strategy.hasUserJoined(walletAddress); + } catch (e) { + console.error( + "Error fetching user data for strategy", + metadata.id, + e + ); + } + } + return { + strategy, + metadata: { ...metadata, userStake, userRewards, hasJoined }, + }; + }) + ); + setAllStrategies(strategiesWithMetadata); + } catch (error) { + console.error("Error loading strategies:", error); + } finally { + setIsLoading(false); + } + }, [walletAddress]); + + // Filter out strategies the user has joined + const loadUserStrategies = useCallback(async () => { + if (!walletAddress) return; + const joined = allStrategies.filter((s) => s.metadata.hasJoined); + setUserStrategies(joined); + }, [walletAddress, allStrategies]); + + // Calculate total value and rewards from user strategies + const calculateUserStats = useCallback(async () => { + if (!walletAddress) return; + let totalStaked = 0; + let totalRewards = 0; + allStrategies.forEach(({ metadata }) => { + totalStaked += metadata.userStake || 0; + totalRewards += metadata.userRewards || 0; + }); + setTotalValue(totalStaked); + setClaimableRewards(totalRewards); + }, [walletAddress, allStrategies]); + + // Modal Handlers + const handleBondClick = useCallback((strategyMeta: StrategyMetadata) => { + setSelectedStrategy(strategyMeta); + setBondModalOpen(true); + }, []); + + const handleUnbondClick = useCallback( + (strategyMeta: StrategyMetadata) => { + if (!walletAddress) return; + setSelectedStrategy(strategyMeta); + setUnbondModalOpen(true); + }, + [walletAddress] + ); + + const handleClaimAllClick = useCallback(() => { + if (!walletAddress || claimableRewards <= 0) return; + setClaimAllModalOpen(true); + }, [walletAddress, claimableRewards]); + + const handleCloseModals = () => { + setBondModalOpen(false); + setUnbondModalOpen(false); + setClaimAllModalOpen(false); + setSelectedStrategy(null); + }; + + // Transaction Execution Handlers + const handleConfirmBond = useCallback( + async (tokenAmounts: { token: Token; amount: number }[]) => { + if (!selectedStrategy || !walletAddress) return; + + const strategyInstance = allStrategies.find( + (s) => s.metadata.id === selectedStrategy.id + )?.strategy; + + if (!strategyInstance) { + console.error("Strategy instance not found for bonding."); + // Potentially show a toast error to the user + return; + } + + const amountA = tokenAmounts[0]?.amount; + const amountB = + tokenAmounts.length > 1 ? tokenAmounts[1]?.amount : undefined; + + if (amountA === undefined) { + console.error("Amount A is undefined for bonding"); + // Potentially show a toast error + return; + } + // Note: The strategy's bond method should internally check if amountB is required. + + await executeContractTransaction({ + contractType: selectedStrategy.contractType, // This is indicative; strategy uses its own client + contractAddress: selectedStrategy.contractAddress, // This is indicative + transactionFunction: async (_client, _restore) => { + // _client and _restore from useContractTransaction are ignored here. + // The strategyInstance.bond method now returns the AssembledTransaction. + return strategyInstance.bond(walletAddress!, amountA, amountB); + }, + options: { + onSuccess: () => { + loadStrategies(); // Refresh data on success + handleCloseModals(); + }, + }, + }); + }, + [ + selectedStrategy, + walletAddress, + executeContractTransaction, + allStrategies, + loadStrategies, + // handleCloseModals, // Added if it's stable or include its dependencies + ] + ); + + const handleConfirmUnbond = useCallback( + async (params: number | { lpAmount: bigint; timestamp: bigint }) => { + if (!selectedStrategy || !walletAddress) return; + + const strategyInstance = allStrategies.find( + (s) => s.metadata.id === selectedStrategy.id + )?.strategy; + + if (!strategyInstance) { + console.error("Strategy instance not found for unbonding."); + return; + } + + await executeContractTransaction({ + contractType: selectedStrategy.contractType, // Indicative + contractAddress: selectedStrategy.contractAddress, // Indicative + transactionFunction: async (_client, _restore) => { + return strategyInstance.unbond(walletAddress!, params); + }, + options: { + onSuccess: () => { + loadStrategies(); + handleCloseModals(); + }, + }, + }); + }, + [ + selectedStrategy, + walletAddress, + executeContractTransaction, + allStrategies, + loadStrategies, + // handleCloseModals, // Added if it's stable or include its dependencies + ] + ); + + const handleClaimStrategy = useCallback( + (strategy: Strategy, metadata: StrategyMetadata): Promise => { + return new Promise((resolve, reject) => { + if (!walletAddress) { + console.warn( + "Claim attempt with no wallet address for strategy:", + metadata.id + ); + reject(new Error("Wallet not connected. Cannot claim.")); + return; + } + + executeContractTransaction({ + contractType: metadata.contractType, + contractAddress: metadata.contractAddress, + transactionFunction: (_client, _restore) => { + // Ensure walletAddress is not null here, though checked above. + // The strategy.claim method itself should handle Soroban client interactions. + return strategy.claim(walletAddress!); + }, + options: { + onSuccess: () => { + console.log( + `Claim successful for ${metadata.name}. Refreshing strategies.` + ); + loadStrategies(); // Refresh data on success + resolve(); // Resolve the promise that ClaimAllModal is awaiting + }, + // Errors from executeContractTransaction (e.g. user rejection, network issues) + // will be caught by the .catch() below. + }, + }).catch((error) => { + // This catches rejections from the promise returned by executeContractTransaction. + console.error(`Claim failed for ${metadata.name}:`, error); + reject(error); // Reject the promise that ClaimAllModal is awaiting + }); + }); + }, + [walletAddress, executeContractTransaction, loadStrategies] + ); + + // Tab handling + const handleChange = (event: React.SyntheticEvent, newValue: number) => { + setTabValue(newValue); + }; + + const handleViewStrategyDetails = useCallback( + (strategyId: string) => { + router.push(`/earn/${strategyId}`); + }, + [router] + ); + + // Prepare data for UI components + const userStrategyIds = userStrategies.map((s) => s.metadata.id); + const discoverableRawStrategies = allStrategies.filter( + (s) => !userStrategyIds.includes(s.metadata.id) + ); + + const discoverStrategiesUI = discoverableRawStrategies.map((s) => ({ + ...s.metadata, + isMobile, + })); + + const userStrategiesUI = userStrategies.map((s) => ({ + ...s.metadata, + isMobile, + })); + + // Prepare claimable strategies for modal + const claimableForModal = allStrategies + .filter( + (s) => + s.metadata.hasJoined && + s.metadata.userRewards && + s.metadata.userRewards > 0 + ) + .map((s) => ({ + strategy: s.strategy, + metadata: s.metadata, + rewards: s.metadata.userRewards || 0, + })); + + return ( + + + + {/* Hero Section */} + + + + Earn + + + Maximize your yields with advanced DeFi strategies. Stake, farm, and + earn passive income on your crypto assets. + + + + {/* Yield Summary - Hero Section */} + + + + + {/* Strategies Section */} + + + {/* Tabs */} + + + + + + + + + {tabValue === 0 ? ( + walletAddress && + !isLoading && + discoverStrategiesUI.length === 0 && + userStrategies.length > 0 ? ( + + + You've explored all available strategies! + + + All strategies are currently part of 'Your + Strategies'. + + + + ) : ( + + ) + ) : ( + + )} + + + + + + + {/* Modals */} + + + { + handleCloseModals(); + loadStrategies(); + }} + claimableStrategies={claimableForModal} + onClaimStrategy={handleClaimStrategy} + /> + + ); +} diff --git a/packages/core/app/help-center/category/[categoryID]/page.tsx b/packages/core/app/help-center/category/[categoryID]/page.tsx index 0ceafb72..b4356ac7 100644 --- a/packages/core/app/help-center/category/[categoryID]/page.tsx +++ b/packages/core/app/help-center/category/[categoryID]/page.tsx @@ -29,6 +29,7 @@ export default function Page(props: CategoryPageProps) { useEffect(() => { init(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const init = async () => { diff --git a/packages/core/app/help-center/page.tsx b/packages/core/app/help-center/page.tsx index 53698389..3a276f54 100644 --- a/packages/core/app/help-center/page.tsx +++ b/packages/core/app/help-center/page.tsx @@ -67,11 +67,7 @@ export default function Page() { [] ); - useEffect(() => { - init(); - }, []); - - const init = async () => { + const init = React.useCallback(async () => { try { // Fetch categories const fetchCategories = await HelpCenter.getAllCategories(); @@ -108,7 +104,7 @@ export default function Page() { } finally { appStore.setLoading(false); } - }; + }, [appStore]); useEffect(() => { if (searchValue) { diff --git a/packages/core/app/history/page.tsx b/packages/core/app/history/page.tsx index 05e620f2..59b9041f 100644 --- a/packages/core/app/history/page.tsx +++ b/packages/core/app/history/page.tsx @@ -13,6 +13,7 @@ import { } from "@phoenix-protocol/ui"; import { API, constants, TradeAPi } from "@phoenix-protocol/utils"; import { fetchAllTrades, scaToToken } from "@phoenix-protocol/utils"; +import { motion } from "framer-motion"; import { PriceHistoryResponse, @@ -295,6 +296,7 @@ export default function Page() { if (newFilters.dateRange.to) { to = (newFilters.dateRange.to.getTime() / 1000).toFixed(0); } + const trades = await fetchAllTrades( appStore, pageSize, @@ -303,8 +305,57 @@ export default function Page() { to, activeView === "personal" ? appStorePersist.wallet.address : undefined ); - setHistory(trades); - console.log(trades[0]); + + // Apply client-side filtering for tradeSize and tradeValue + let filteredTrades = trades; + + if ( + newFilters.tradeSize.from !== undefined || + newFilters.tradeSize.to !== undefined + ) { + filteredTrades = filteredTrades.filter((trade) => { + // Use fromAmount as the trade size + const size = trade.fromAmount; + if ( + newFilters.tradeSize.from !== undefined && + newFilters.tradeSize.to !== undefined + ) { + return ( + size >= newFilters.tradeSize.from && size <= newFilters.tradeSize.to + ); + } else if (newFilters.tradeSize.from !== undefined) { + return size >= newFilters.tradeSize.from; + } else if (newFilters.tradeSize.to !== undefined) { + return size <= newFilters.tradeSize.to; + } + return true; + }); + } + + if ( + newFilters.tradeValue.from !== undefined || + newFilters.tradeValue.to !== undefined + ) { + filteredTrades = filteredTrades.filter((trade) => { + const value = parseFloat(trade.tradeValue); + if ( + newFilters.tradeValue.from !== undefined && + newFilters.tradeValue.to !== undefined + ) { + return ( + value >= newFilters.tradeValue.from && + value <= newFilters.tradeValue.to + ); + } else if (newFilters.tradeValue.from !== undefined) { + return value >= newFilters.tradeValue.from; + } else if (newFilters.tradeValue.to !== undefined) { + return value <= newFilters.tradeValue.to; + } + return true; + }); + } + + setHistory(filteredTrades); setHistoryLoading(false); }; @@ -328,80 +379,286 @@ export default function Page() { return ( - - - Transaction History - - - - setSelectedTimeEpoch(e)} - selectedTab={selectedTimeEpoch} - totalVolume={totalVolume} - /> - - - {historicalPrices.length > 0 && ( - + + {/* Hero Section */} + + + Transaction History + + + + Track all trading activity and market statistics across the Phoenix + DeFi ecosystem + + + {/* Stats Section */} + + + + {meta.totalTrades} + + + Total Trades + + + + + + {meta.totalUsers} + + + Total Users + + + + + + $ + {totalVolume.toLocaleString(undefined, { + maximumFractionDigits: 0, + })} + + + Volume ( + {selectedTimeEpoch === "D" + ? "24h" + : selectedTimeEpoch === "M" + ? "30d" + : "1y"} + ) + + + + + + {/* Charts Section */} + + + + setSelectedTimeEpoch(e)} + selectedTab={selectedTimeEpoch} + totalVolume={totalVolume} + /> + + + {historicalPrices.length > 0 && ( + + + + )} + + + + + {!historyLoading ? ( + { + setHistory([]); + setActiveView(view); + }} + loggedIn={!!appStorePersist.wallet.address} + activeFilters={activeFilters} + applyFilters={(newFilters: ActiveFilters) => + applyFilters(newFilters) + } + handleSort={(column) => + handleSortChange( + column as any, + sortOrder === "asc" ? "desc" : "asc" + ) + } /> + ) : ( + )} - - - - {!historyLoading ? ( - { - setHistory([]); - setActiveView(view); + + applyFilters(newFilters)} - handleSort={(column) => - handleSortChange( - column as any, - sortOrder === "asc" ? "desc" : "asc" - ) - } - /> - ) : ( - - )} - - + + + + + + + + + {/* Main content grid with improved responsive design */} + + {/* Dashboard Stats - Full width */} + + + {loadingDashboard ? ( + - Be one of the first and become a genesis NFT creator! - - - - - - - - {/* Dashboard Stats */} - - {loadingDashboard ? ( - - - - ) : ( - - )} - - {/* Price Charts */} - - {loadingDashboard ? ( - - - - ) : ( - - )} - - - {loadingDashboard ? ( - - - - ) : ( - - )} - - {/* Wallet Balance Table */} - - {loadingBalances ? ( - - - - ) : allTokens.length ? ( - - ) : ( - - Looks like you haven{"'"}t acquired any tokens. - - )} + + + ) : ( + + )} + - - + + {/* Main Content Area */} + + + {/* Price Charts - Responsive layout */} + + + {loadingDashboard ? ( + + + + ) : ( + + )} + + + + + + {loadingDashboard ? ( + + + + ) : ( + + )} + + + + {/* Wallet Balance Table - Enhanced */} + + + + {loadingBalances ? ( + + + + ) : allTokens.length ? ( + + ) : ( + + + No tokens found in your wallet + + + Start trading to see your token balances here + + + + )} + + + + + {/* NFT Preview Section - Enhanced */} + + + + + + + + - - - {/* Crypto CTA */} - - - - window.open("https://app.kado.money")} /> + + {/* Sidebar - Now properly aligned */} + + + {/* Volume Tile */} + + + + + + + {/* TVL Tile */} + + + + + + + {/* CryptoCTA */} + + + + window.open("https://app.kado.money")} + /> + + + + - - {/* Asset Info Modal */} - {selectedTokenForInfo && ( + {/* Asset Info Modal */} + setTokenInfoOpen(false)} - asset={selectedTokenForInfo} + onClose={() => { + setTokenInfoOpen(false); + if (loadingAssetInfo) { + setLoadingAssetInfo(false); + } + }} + asset={selectedTokenForInfo || emptyAssetPlaceholder} + userBalance={0} + pools={selectedAssetPools} + // @ts-ignore + tradingVolume7d={tradingVolume7d} + loading={loadingAssetInfo} /> - )} - {/* Vesting Modal */} - {vestingInfo.length > 0 && ( - setVestingModalOpen(false)} - vestingInfo={vestingInfo} - queryAvailableToClaim={(index) => queryAvailableToClaim(index)} - claim={(index) => claim(index)} - /> - )} + {/* Vesting Modal */} + {vestingInfo.length > 0 && ( + setVestingModalOpen(false)} + vestingInfo={vestingInfo} + queryAvailableToClaim={(index) => queryAvailableToClaim(index)} + claim={(index) => claim(index)} + /> + )} + ); } diff --git a/packages/core/app/pools/[poolAddress]/page.tsx b/packages/core/app/pools/[poolAddress]/page.tsx index fbe6a055..4b61ad8e 100644 --- a/packages/core/app/pools/[poolAddress]/page.tsx +++ b/packages/core/app/pools/[poolAddress]/page.tsx @@ -1,6 +1,7 @@ +/* eslint-disable react-hooks/exhaustive-deps */ "use client"; -import React, { useEffect, useState, use } from "react"; +import React, { useEffect, useState, use, useMemo, useCallback } from "react"; import * as refuse from "react-usestateref"; import { Box, GlobalStyles, Grid, Skeleton, Typography } from "@mui/material"; import { @@ -56,7 +57,11 @@ interface PoolPageProps { const overviewStyles = ( ); @@ -111,14 +116,133 @@ export default function Page(props: PoolPageProps) { const [unstakeAmount, setUnstakeAmount] = useState(0); const [unstakeTimestamp, setUnstakeTimestamp] = useState(0); - const PairContract = new PhoenixPairContract.Client({ - contractId: params.poolAddress, - networkPassphrase: constants.NETWORK_PASSPHRASE, - rpcUrl: constants.RPC_URL, - }); + // Memoize the PairContract + const PairContract = useMemo( + () => + new PhoenixPairContract.Client({ + contractId: params.poolAddress, + networkPassphrase: constants.NETWORK_PASSPHRASE, + rpcUrl: constants.RPC_URL, + }), + [params.poolAddress] + ); + const appStore = useAppStore(); - const fetchStakingAddress = async (): Promise => { + const loadRewards = useCallback( + async (stakeContract = StakeContract) => { + try { + // Stake Contract + const _rewards = await stakeContract?.query_withdrawable_rewards({ + user: storePersist.wallet.address!, + }); + + const __rewards = _rewards?.result.rewards?.map(async (reward: any) => { + // Get the token + const token = await store.fetchTokenInfo(reward.reward_address); + return { + name: token?.symbol.toUpperCase(), + icon: `/cryptoIcons/${token?.symbol.toLowerCase()}.svg`, + usdValue: "0", + amount: + Number(reward.reward_amount.toString()) / 10 ** token?.decimals!, + category: "", + }; + }); + + const rew = await Promise.all(__rewards); + setRewards(rew); + } catch (e) { + console.log(e); + } + }, + [StakeContract, store, storePersist.wallet.address] + ); + + const fetchStakes = useCallback( + async ( + name = lpToken?.name, + stakeContract = StakeContract, + calcApr = maxApr, + tokenPrice = lpTokenPrice + ) => { + if (storePersist.wallet.address) { + // Get user stakes + const stakesA = await stakeContract?.query_staked( + { + address: storePersist.wallet.address!, + }, + { simulate: false } + ); + + const stakes = await stakesA.simulate({ restore: true }); + // If stakes are okay + if (stakes?.result) { + // If filled + if (stakes.result.stakes.length > 0) { + //@ts-ignore + const _stakes: Entry[] = stakes.result.stakes.map((stake: any) => { + return { + icon: `/cryptoIcons/poolIcon.png`, + title: name!, + apr: + // Calculate APR + + ( + (time.daysSinceTimestamp(Number(stake.stake_timestamp)) > 60 + ? 60 + : time.daysSinceTimestamp( + Number(stake.stake_timestamp) + )) * + (calcApr / 2 / 60) + ).toFixed(2) + "%", + lockedPeriod: + time.daysSinceTimestamp(Number(stake.stake_timestamp)) + + " days", + amount: { + tokenAmount: Number(stake.stake) / 10 ** 7, + tokenValueInUsd: ( + (Number(stake.stake) / 10 ** 7) * + tokenPrice + ).toFixed(2), + }, + onClick: () => { + setIsFixUnstake(false); + setUnstakeAmount(Number(stake.stake) / 10 ** 7); + setUnstakeTimestamp(stake.stake_timestamp); + setUnstakeModalOpen(true); + }, + onClickFix: () => { + setIsFixUnstake(true); + setUnstakeAmount(Number(stake.stake) / 10 ** 7); + setUnstakeTimestamp(stake.stake_timestamp); + setUnstakeModalOpen(true); + }, + }; + }); + setUserStakes(_stakes); + await loadRewards(stakeContract); + return _stakes; + } + } + } + }, + [ + lpToken?.name, + StakeContract, + maxApr, + lpTokenPrice, + storePersist.wallet.address, + loadRewards, + ] + ); + + /** + * Fetch staking address with error handling + */ + const fetchStakingAddress = useCallback(async (): Promise< + string | undefined + > => { try { // Fetch pool config and info from chain const [pairConfig, pairInfo] = await Promise.all([ @@ -126,155 +250,183 @@ export default function Page(props: PoolPageProps) { PairContract.query_pool_info(), ]); - console.log(pairConfig.result); - console.log("hi"); // When results ok... if (pairConfig?.result && pairInfo?.result) { - // Fetch token infos from chain and save in global appstore - const [_tokenA, _tokenB, _lpToken, stakeContractAddress] = - await Promise.all([ - store.fetchTokenInfo(pairConfig.result.token_a), - store.fetchTokenInfo(pairConfig.result.token_b), - store.fetchTokenInfo(pairConfig.result.share_token, true), - new PhoenixStakeContract.Client({ - contractId: pairConfig.result.stake_contract.toString(), - networkPassphrase: constants.NETWORK_PASSPHRASE, - rpcUrl: constants.RPC_URL, - publicKey: storePersist.wallet.address, - signTransaction: (tx: string) => new Signer().sign(tx), - }), - ]); - return pairConfig.result.stake_contract.toString(); + // Fetch token infos + const stakeContractId = pairConfig.result.stake_contract.toString(); + const stakeContractAddress = new PhoenixStakeContract.Client({ + contractId: stakeContractId, + networkPassphrase: constants.NETWORK_PASSPHRASE, + rpcUrl: constants.RPC_URL, + publicKey: storePersist.wallet.address, + signTransaction: (tx: string) => new Signer().sign(tx), + }); + + return stakeContractId; } } catch (e) { - console.log(e); + console.log("Error fetching staking address:", e); } - }; - // Provide Liquidity - const provideLiquidity = async ( - tokenAAmount: number, - tokenBAmount: number - ) => { - await executeContractTransaction({ - contractType: "pair", - contractAddress: params.poolAddress, - transactionFunction: async (client, restore) => { - return client.provide_liquidity( - { - sender: storePersist.wallet.address!, - desired_a: BigInt( - (tokenAAmount * 10 ** (tokenA?.decimals || 7)).toFixed(0) - ), - desired_b: BigInt( - (tokenBAmount * 10 ** (tokenB?.decimals || 7)).toFixed(0) - ), - min_a: undefined, - min_b: undefined, - custom_slippage_bps: undefined, - deadline: undefined, - }, - { simulate: !restore } - ); - }, - }); - // Refresh pool data + return undefined; + }, [PairContract, storePersist.wallet.address]); + + /** + * Refresh pool data and balances after operations + */ + const refreshPoolData = useCallback(async () => { await getPool(); - setTokenAmounts([tokenAAmount, tokenBAmount]); - setTimeout(() => { - getPool(); - }, 7000); - }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + /** + * Provide liquidity to the pool with callback for balance updates + */ + const provideLiquidity = useCallback( + async (tokenAAmount: number, tokenBAmount: number) => { + await executeContractTransaction({ + contractType: "pair", + contractAddress: params.poolAddress, + transactionFunction: async (client, restore) => { + return client.provide_liquidity( + { + sender: storePersist.wallet.address!, + desired_a: BigInt( + (tokenAAmount * 10 ** (tokenA?.decimals || 7)).toFixed(0) + ), + desired_b: BigInt( + (tokenBAmount * 10 ** (tokenB?.decimals || 7)).toFixed(0) + ), + min_a: undefined, + min_b: undefined, + custom_slippage_bps: undefined, + deadline: undefined, + auto_stake: false, + }, + { simulate: !restore } + ); + }, + }); + }, + [ + executeContractTransaction, + params.poolAddress, + storePersist.wallet.address, + tokenA?.decimals, + tokenB?.decimals, + refreshPoolData, + ] + ); - // Remove Liquidity - const removeLiquidity = async (lpTokenAmount: number, fix?: boolean) => { - await executeContractTransaction({ - contractType: "pair", - contractAddress: params.poolAddress, - transactionFunction: async (client, restore) => { - return client.withdraw_liquidity( - { - sender: storePersist.wallet.address!, - share_amount: BigInt( - (lpTokenAmount * 10 ** (lpToken?.decimals || 7)).toFixed(0) - ), - min_a: BigInt(1), - min_b: BigInt(1), - deadline: undefined, - }, - { simulate: !restore } - ); - }, - }); - setTokenAmounts([lpTokenAmount]); - // Wait 7 Seconds for the next block and fetch new balances - setTimeout(() => { - getPool(); - }, 7000); - }; + /** + * Remove liquidity from the pool with callback for balance updates + */ + const removeLiquidity = useCallback( + async (lpTokenAmount: number, fix?: boolean) => { + await executeContractTransaction({ + contractType: "pair", + contractAddress: params.poolAddress, + transactionFunction: async (client, restore) => { + return client.withdraw_liquidity( + { + sender: storePersist.wallet.address!, + share_amount: BigInt( + (lpTokenAmount * 10 ** (lpToken?.decimals || 7)).toFixed(0) + ), + min_a: BigInt(1), + min_b: BigInt(1), + deadline: undefined, + auto_unstake: undefined, + }, + { simulate: !restore } + ); + }, + }); + }, + [ + executeContractTransaction, + params.poolAddress, + storePersist.wallet.address, + lpToken?.decimals, + refreshPoolData, + ] + ); - // Stake - const stake = async (lpTokenAmount: number) => { - let stakeAddress: string | undefined = stakeContractAddress; - if (stakeContractAddress === "") { - stakeAddress = await fetchStakingAddress(); - } + /** + * Stake LP tokens with callback for balance updates + */ + const stake = useCallback( + async (lpTokenAmount: number) => { + let stakeAddress: string | undefined = stakeContractAddress; + if (!stakeAddress) { + stakeAddress = await fetchStakingAddress(); + if (!stakeAddress) return; + } - await executeContractTransaction({ - contractType: "stake", - contractAddress: stakeAddress!, - transactionFunction: async (client, restore) => { - return client.bond( - { - sender: storePersist.wallet.address!, - tokens: BigInt( - (lpTokenAmount * 10 ** (lpToken?.decimals || 7)).toFixed(0) - ), - }, - { simulate: !restore } - ); - }, - }); - await fetchStakes(); - setTokenAmounts([lpTokenAmount]); - // Wait 7 Seconds for the next block and fetch new balances - setTimeout(() => { - getPool(); - }, 7000); - }; + await executeContractTransaction({ + contractType: "stake", + contractAddress: stakeAddress, + transactionFunction: async (client, restore) => { + return client.bond( + { + sender: storePersist.wallet.address!, + tokens: BigInt( + (lpTokenAmount * 10 ** (lpToken?.decimals || 7)).toFixed(0) + ), + }, + { simulate: !restore } + ); + }, + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + stakeContractAddress, + fetchStakingAddress, + executeContractTransaction, + storePersist.wallet.address, + lpToken?.decimals, + fetchStakes, + refreshPoolData, + ] + ); - // Stake - const unstake = async ( - lpTokenAmount: number, - stake_timestamp: number, - fix?: boolean - ) => { - let stakeAddress: string | undefined = stakeContractAddress; - if (stakeContractAddress === "") { - stakeAddress = await fetchStakingAddress(); - } + /** + * Unstake LP tokens with callback for balance updates + */ + const unstake = useCallback( + async (lpTokenAmount: number, stake_timestamp: number, fix?: boolean) => { + let stakeAddress: string | undefined = stakeContractAddress; + if (!stakeAddress) { + stakeAddress = await fetchStakingAddress(); + if (!stakeAddress) return; + } - await executeContractTransaction({ - contractType: "stake", - contractAddress: stakeAddress!, - transactionFunction: async (client, restore) => { - return client.unbond( - { - sender: storePersist.wallet.address!, - stake_amount: BigInt( - (lpTokenAmount * 10 ** (lpToken?.decimals || 7)).toFixed(0) - ), - stake_timestamp: BigInt(stake_timestamp), - }, - { simulate: !restore } - ); - }, - }); - setTokenAmounts([lpTokenAmount]); - // Wait 7 Seconds for the next block and fetch new balances - setTimeout(() => { - getPool(); - }, 7000); - }; + await executeContractTransaction({ + contractType: "stake", + contractAddress: stakeAddress, + transactionFunction: async (client, restore) => { + return client.unbond( + { + sender: storePersist.wallet.address!, + stake_amount: BigInt( + (lpTokenAmount * 10 ** (lpToken?.decimals || 7)).toFixed(0) + ), + stake_timestamp: BigInt(stake_timestamp), + }, + { simulate: !restore } + ); + }, + }); + }, + [ + stakeContractAddress, + fetchStakingAddress, + executeContractTransaction, + storePersist.wallet.address, + lpToken?.decimals, + refreshPoolData, + ] + ); // Function to fetch pool config and info from chain const getPool = async () => { @@ -285,8 +437,6 @@ export default function Page(props: PoolPageProps) { PairContract.query_pool_info(), ]); - console.log(pairConfig.result); - // When results ok... if (pairConfig?.result && pairInfo?.result) { // Fetch token infos from chain and save in global appstore @@ -303,7 +453,6 @@ export default function Page(props: PoolPageProps) { publicKey: storePersist.wallet.address, }), ]); - console.log(_lpToken); setStakeContractAddress(pairConfig.result.stake_contract.toString()); // Fetch prices and calculate TVL @@ -449,108 +598,19 @@ export default function Page(props: PoolPageProps) { } }; - const fetchStakes = async ( - name = lpToken?.name, - stakeContract = StakeContract, - calcApr = maxApr, - tokenPrice = lpTokenPrice - ) => { - if (storePersist.wallet.address) { - // Get user stakes - const stakesA = await stakeContract?.query_staked( - { - address: storePersist.wallet.address!, - }, - { simulate: false } - ); - - const stakes = await stakesA.simulate({ restore: true }); - // If stakes are okay - if (stakes?.result) { - // If filled - if (stakes.result.stakes.length > 0) { - //@ts-ignore - const _stakes: Entry[] = stakes.result.stakes.map((stake: any) => { - return { - icon: `/cryptoIcons/poolIcon.png`, - title: name!, - apr: - // Calculate APR - - ( - (time.daysSinceTimestamp(Number(stake.stake_timestamp)) > 60 - ? 60 - : time.daysSinceTimestamp(Number(stake.stake_timestamp))) * - (calcApr / 2 / 60) - ).toFixed(2) + "%", - lockedPeriod: - time.daysSinceTimestamp(Number(stake.stake_timestamp)) + - " days", - amount: { - tokenAmount: Number(stake.stake) / 10 ** 7, - tokenValueInUsd: ( - (Number(stake.stake) / 10 ** 7) * - tokenPrice - ).toFixed(2), - }, - onClick: () => { - setIsFixUnstake(false); - setUnstakeAmount(Number(stake.stake) / 10 ** 7); - setUnstakeTimestamp(stake.stake_timestamp); - setUnstakeModalOpen(true); - }, - onClickFix: () => { - setIsFixUnstake(true); - setUnstakeAmount(Number(stake.stake) / 10 ** 7); - setUnstakeTimestamp(stake.stake_timestamp); - setUnstakeModalOpen(true); - }, - }; - }); - setUserStakes(_stakes); - await loadRewards(stakeContract); - return _stakes; - } - } - } - }; - - const loadRewards = async (stakeContract = StakeContract) => { - try { - // Stake Contract - const _rewards = await stakeContract?.query_withdrawable_rewards({ - user: storePersist.wallet.address!, - }); - - const __rewards = _rewards?.result.rewards?.map(async (reward: any) => { - // Get the token - const token = await store.fetchTokenInfo(reward.reward_address); - return { - name: token?.symbol.toUpperCase(), - icon: `/cryptoIcons/${token?.symbol.toLowerCase()}.svg`, - usdValue: "0", - amount: - Number(reward.reward_amount.toString()) / 10 ** token?.decimals!, - category: "", - }; - }); - - const rew = await Promise.all(__rewards); - setRewards(rew); - } catch (e) { - console.log(e); - } - }; - - const claimTokens = async () => { + /** + * Claim rewards with callback for balance updates + */ + const claimTokens = useCallback(async () => { let stakeAddress: string | undefined = stakeContractAddress; - if (stakeContractAddress === "") { + if (!stakeAddress) { stakeAddress = await fetchStakingAddress(); + if (!stakeAddress) return; } await executeContractTransaction({ contractType: "stake", - contractAddress: stakeAddress!, + contractAddress: stakeAddress, transactionFunction: async (client, restore) => { return client.withdraw_rewards( { @@ -560,15 +620,19 @@ export default function Page(props: PoolPageProps) { ); }, }); - // Wait 7 Seconds for the next block and fetch new balances - setTimeout(() => { - getPool(); - }, 7000); - }; - + }, [ + stakeContractAddress, + fetchStakingAddress, + executeContractTransaction, + storePersist.wallet.address, + refreshPoolData, + ]); + + // Fetch pool data when address changes useEffect(() => { - getPool(); - // eslint-disable-next-line react-hooks/exhaustive-deps + if (storePersist.wallet.address) { + getPool(); + } }, [storePersist.wallet.address]); if (!params.poolAddress || poolNotFound) { @@ -582,21 +646,101 @@ export default function Page(props: PoolPageProps) { ); } return ( - + {/* Hacky Title Injector - Waiting for Next Helmet for Next15 */} {overviewStyles} {loading && } - + + {/* Enhanced Pool Header */} + + {/* Glow effect */} + + {tokenA?.icon ? ( - + @@ -609,16 +753,38 @@ export default function Page(props: PoolPageProps) { )} {tokenA?.name ? ( - - {tokenA?.name}-{tokenB?.name} - + + + {tokenA?.name}-{tokenB?.name} + + + Liquidity Pool + + ) : ( )} - + - + ([]); // State to hold pool data const storePersist = usePersistStore(); // Persisted state const [poolFilter, setPoolFilter] = useState("ALL"); const [sortBy, setSortBy] = useState("HighAPR"); - const isInitialMount = useRef(true); // To track the initial component mount const appStore = useAppStore(); + const [tvl, setTvl] = useState(0); /** * Fetch pool information by its address. * * @async * @function fetchPool - * @param {string} poolAddress - The address of the liquidity pool. + * @param {string} poolAddress - The address of the pool contract. * @returns {Promise} A promise that resolves to the pool information or undefined in case of failure. */ - const fetchPool = useCallback(async (poolAddress: string) => { - try { - const PairContract = new PhoenixPairContract.Client({ - contractId: poolAddress, - networkPassphrase: constants.NETWORK_PASSPHRASE, - rpcUrl: constants.RPC_URL, - }); - - const [pairConfig, pairInfo] = await Promise.all([ - PairContract.query_config(), - PairContract.query_pool_info(), - ]); - - if (pairConfig?.result && pairInfo?.result) { - const [tokenA, tokenB] = await Promise.all([ - store.fetchTokenInfo(pairConfig.result.token_a), - store.fetchTokenInfo(pairConfig.result.token_b), - ]); + const fetchPool = useCallback( + async (poolAddress: string) => { + try { + const PairContract = new PhoenixPairContract.Client({ + contractId: poolAddress, + networkPassphrase: constants.NETWORK_PASSPHRASE, + rpcUrl: constants.RPC_URL, + }); - // Fetch prices and calculate TVL - const [priceA, priceB] = await Promise.all([ - API.getPrice(tokenA?.symbol || ""), - API.getPrice(tokenB?.symbol || ""), + const [pairConfig, pairInfo] = await Promise.all([ + PairContract.query_config(), + PairContract.query_pool_info(), ]); - const tvl = - (priceA * Number(pairInfo.result.asset_a.amount)) / - 10 ** Number(tokenA?.decimals) + - (priceB * Number(pairInfo.result.asset_b.amount)) / - 10 ** Number(tokenB?.decimals); + if (pairConfig?.result && pairInfo?.result) { + const [tokenA, tokenB, lpToken] = await Promise.all([ + store.fetchTokenInfo(pairConfig.result.token_a), + store.fetchTokenInfo(pairConfig.result.token_b), + store.fetchTokenInfo(pairConfig.result.share_token, true), + ]); - const stakingAddress = pairInfo.result.stake_address; + // Fetch prices and calculate TVL + const [priceA, priceB] = await Promise.all([ + API.getPrice(tokenA?.symbol || ""), + API.getPrice(tokenB?.symbol || ""), + ]); - const StakeContract = new PhoenixStakeContract.Client({ - contractId: stakingAddress, - networkPassphrase: constants.NETWORK_PASSPHRASE, - rpcUrl: constants.RPC_URL, - }); + const tvl = + (priceA * Number(pairInfo.result.asset_a.amount)) / + 10 ** Number(tokenA?.decimals) + + (priceB * Number(pairInfo.result.asset_b.amount)) / + 10 ** Number(tokenB?.decimals); + + const stakingAddress = pairInfo.result.stake_address; - const [stakingInfo, allPoolDetails] = await Promise.all([ - StakeContract.query_total_staked(), - new PhoenixFactoryContract.Client({ - contractId: FACTORY_ADDRESS, + const StakeContract = new PhoenixStakeContract.Client({ + contractId: stakingAddress, networkPassphrase: constants.NETWORK_PASSPHRASE, rpcUrl: constants.RPC_URL, - }).query_all_pools_details(), - ]); - - const totalStaked = Number(stakingInfo.result); - const totalTokens = Number( - allPoolDetails.result.find( - (pool: any) => pool.pool_address === poolAddress - )?.pool_response.asset_lp_share.amount - ); + }); - const ratioStaked = totalStaked / totalTokens; - const valueStaked = tvl * ratioStaked; - - // Calculate APR based on incentives - const poolIncentives = [ - { - address: "CBHCRSVX3ZZ7EGTSYMKPEFGZNWRVCSESQR3UABET4MIW52N4EVU6BIZX", - amount: 12500, - }, - { - address: "CBCZGGNOEUZG4CAAE7TGTQQHETZMKUT4OIPFHHPKEUX46U4KXBBZ3GLH", - amount: 25000, - }, - { - address: "CD5XNKK3B6BEF2N7ULNHHGAMOKZ7P6456BFNIHRF4WNTEDKBRWAE7IAA", - amount: 18750, - }, - ]; - - const poolIncentive = poolIncentives.find( - (incentive) => incentive.address === poolAddress - ); - - const phoprice = await fetchPho(); - const _apr = - ((poolIncentive?.amount || 0 * phoprice) / valueStaked) * 100 * 6; + const [stakingInfo, userStake] = await Promise.all([ + StakeContract.query_total_staked(), + StakeContract.query_staked({ + address: storePersist.wallet.address!, + }), + ]); - const apr = isNaN(_apr) ? 0 : _apr; + const totalStaked = Number(stakingInfo.result); + const valueStaked = + (totalStaked / 10 ** 7) * + (tvl / (Number(pairInfo.result.asset_lp_share.amount) / 10 ** 7)); - // Construct and return pool object if all fetches are successful - return { - tokens: [ + const poolIncentives = [ + { + // XLM / USDC + address: + "CBHCRSVX3ZZ7EGTSYMKPEFGZNWRVCSESQR3UABET4MIW52N4EVU6BIZX", + amount: 12500, + }, + // XLM/PHO { - name: tokenA?.symbol || "", - icon: `/cryptoIcons/${tokenA?.symbol.toLowerCase()}.svg`, - amount: - Number(pairInfo.result.asset_a.amount) / - 10 ** Number(tokenA?.decimals), - category: "", - usdValue: 0, + address: + "CBCZGGNOEUZG4CAAE7TGTQQHETZMKUT4OIPFHHPKEUX46U4KXBBZ3GLH", + amount: 25000, }, { - name: tokenB?.symbol || "", - icon: `/cryptoIcons/${tokenB?.symbol.toLowerCase()}.svg`, - amount: - Number(pairInfo.result.asset_b.amount) / - 10 ** Number(tokenB?.decimals), - category: "", - usdValue: 0, + // PHO/USDC + address: + "CD5XNKK3B6BEF2N7ULNHHGAMOKZ7P6456BFNIHRF4WNTEDKBRWAE7IAA", + amount: 18750, }, - ], - tvl: formatCurrency("USD", tvl.toString(), navigator.language), - maxApr: `${(apr / 2).toFixed(2)}%`, - userLiquidity: 0, - poolAddress: poolAddress, - }; + ]; + const poolIncentive = poolIncentives.find( + (incentive: any) => incentive.address === poolAddress + )!; + const phoprice = await fetchPho(); + const _apr = + ((poolIncentive?.amount * phoprice) / valueStaked) * 100 * 6; + + const apr = isNaN(_apr) ? 0 : _apr; + + return { + tokens: [ + { + name: tokenA?.symbol || "", + icon: `/cryptoIcons/${tokenA?.symbol.toLowerCase()}.svg`, + amount: + Number(pairInfo.result.asset_a.amount) / + 10 ** Number(tokenA?.decimals), + category: "", + usdValue: 0, + }, + { + name: tokenB?.symbol || "", + icon: `/cryptoIcons/${tokenB?.symbol.toLowerCase()}.svg`, + amount: + Number(pairInfo.result.asset_b.amount) / + 10 ** Number(tokenB?.decimals), + category: "", + usdValue: 0, + }, + ], + tvl: formatCurrency("USD", tvl.toString(), navigator.language), + maxApr: `${(apr / 2).toFixed(2)}%`, + userLiquidity: + (lpToken && lpToken.balance > 0) || userStake.total_stake > 0 + ? 1 + : 0, + poolAddress: poolAddress, + }; + } + } catch (e) { + console.log(e); } - } catch (e) { - console.log(e); - } - return; - }, []); + return; + }, + [store] + ); - /** - * Fetch all pools' data. - * - * @async - * @function fetchPools - */ - const fetchPools = useCallback(async () => { - try { - const FactoryContract = new PhoenixFactoryContract.Client({ - contractId: constants.FACTORY_ADDRESS, - networkPassphrase: constants.NETWORK_PASSPHRASE, - rpcUrl: constants.RPC_URL, - }); - - const pools = await FactoryContract.query_pools({}); - - const poolWithData = - pools && Array.isArray(pools.result) - ? await Promise.all( - pools.result.map(async (pool: string) => { - return await fetchPool(pool); - }) - ) - : []; - - const poolsFiltered: Pool[] = poolWithData.filter( - (el: any) => - el !== undefined && - el.tokens.length >= 2 && - el.poolAddress !== - "CBXBKAB6QIRUGTG77OQZHC46BIIPA5WDKIKZKPA2H7Q7CPKQ555W3EVB" - ); - setAllPools(poolsFiltered as Pool[]); - setLoading(false); - } catch (e) { - console.error(e); - appStore.setLoading(false); - } finally { - appStore.setLoading(false); - } - }, [fetchPool]); + const getTVL = async () => { + const allTickers = await API.getTickers(); + const _tvl = allTickers.reduce((total, ticker) => { + return total + ticker.liquidity_in_usd; + }, 0); + setTvl(_tvl); + }; // On component mount, fetch pools useEffect(() => { - fetchPools(); - }, [fetchPools]); + let isMounted = true; + + const loadPools = async () => { + if (!isMounted) return; + + console.log("Loading pools - fetching real data"); + + try { + const FactoryContract = new PhoenixFactoryContract.Client({ + contractId: constants.FACTORY_ADDRESS, + networkPassphrase: constants.NETWORK_PASSPHRASE, + rpcUrl: constants.RPC_URL, + }); + + getTVL(); + + const pools = await FactoryContract.query_pools({}); + + const poolWithData = + pools && Array.isArray(pools.result) + ? await Promise.all( + pools.result.map(async (pool: string) => { + return await fetchPool(pool); + }) + ) + : []; + + const poolsFiltered: Pool[] = poolWithData.filter( + (el: any) => + el !== undefined && + el.tokens.length >= 2 && + el.poolAddress !== + "CBXBKAB6QIRUGTG77OQZHC46BIIPA5WDKIKZKPA2H7Q7CPKQ555W3EVB" + ); + + if (isMounted) { + setAllPools(poolsFiltered); + appStore.setLoading(false); + console.log("Pools loaded successfully:", poolsFiltered.length); + } + } catch (e) { + console.error("Error loading pools:", e); + if (isMounted) { + appStore.setLoading(false); + } + } + }; + + // Add a small delay to ensure component is fully mounted + const timeoutId = setTimeout(loadPools, 100); + + return () => { + isMounted = false; + clearTimeout(timeoutId); + }; + }, []); // Include fetchPool and appStore in dependencies // Render: conditionally display skeleton loader or pool data - return loading ? ( - + return ( + {/* Hacky Title Injector - Waiting for Next Helmet for Next15 */} - - - ) : ( - - {/* Hacky Title Injector - Waiting for Next Helmet for Next15 */} - + {/* Background Elements */} + + + {appStore.loading ? ( + + + + ) : ( + + {/* Hero Section */} + + + + Liquidity Pools + + + + + + Provide liquidity to earn fees and rewards. Join our + decentralized pools and become part of the future of DeFi. + + + + + {/* Stats Section */} + + + + + {allPools.length} + + + Active Pools + + + + + + {(() => { + return tvl > 0 + ? `$${tvl.toLocaleString("en-US", { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + })}` + : "$0"; + })()} + + + Total TVL + + + + - - {}} - onShowDetailsClick={(pool) => { - router.push(`/pools/${pool.poolAddress}`); - }} - onFilterClick={(by: string) => { - setPoolFilter(by as PoolsFilter); - }} - onSortSelect={(by) => { - setSortBy(by); - }} - /> - + {/* Main Pools Component */} + + {}} + onShowDetailsClick={(pool) => { + router.push(`/pools/${pool.poolAddress}`); + }} + onFilterClick={(by: string) => { + setPoolFilter(by as PoolsFilter); + }} + onSortSelect={(by) => { + setSortBy(by); + }} + /> + + + )} ); } diff --git a/packages/core/app/style.css b/packages/core/app/style.css deleted file mode 100644 index cd16ed24..00000000 --- a/packages/core/app/style.css +++ /dev/null @@ -1,3 +0,0 @@ -body { - background: linear-gradient(180deg, #1f2123 0%, #131517 100%); -} diff --git a/packages/core/app/swap/page.tsx b/packages/core/app/swap/page.tsx index 2d0b27b3..b8141450 100644 --- a/packages/core/app/swap/page.tsx +++ b/packages/core/app/swap/page.tsx @@ -7,6 +7,7 @@ import React, { useState, useCallback, useMemo, + useRef, type JSX, } from "react"; import { useDebounce } from "use-debounce"; @@ -40,6 +41,7 @@ import { } from "@phoenix-protocol/utils"; import { LoadingSwap, SwapError, SwapSuccess } from "@/components/Modal/Modal"; import { Box } from "@mui/material"; +import ClientOnly from "@/providers/ClientOnlyProvider"; /** * SwapPage Component @@ -49,6 +51,9 @@ import { Box } from "@mui/material"; * @component */ export default function SwapPage(): JSX.Element { + // State for client-side rendering detection + const [isClient, setIsClient] = useState(false); + // State variables declaration and initialization const [optionsOpen, setOptionsOpen] = useState(false); const [assetSelectorOpen, setAssetSelectorOpen] = useState(false); @@ -75,20 +80,104 @@ export default function SwapPage(): JSX.Element { const [trustlineAssetAmount, setTrustlineAssetAmount] = useState(0); const [allPools, setAllPools] = useState([]); + const [operationsStableHash, setOperationsStableHash] = useState(""); // Using the store const storePersist = usePersistStore(); const appStore = useAppStore(); + // Flags to prevent multiple initializations and rerenders + const initialSetupComplete = useRef(false); + const operationsUpdateComplete = useRef(false); + const hasTokensLoaded = useRef(false); + const hasPoolsLoaded = useRef(false); + const prevOperations = useRef(""); + const simulationRequestRef = useRef(null); + const tokenLoadCount = useRef(0); + const operationsHash = useRef(""); + const [fromAmount] = useDebounce(tokenAmounts[0], 500); const { executeContractTransaction } = useContractTransaction(); + // Mark component as client-side rendered on mount + useEffect(() => { + setIsClient(true); + }, []); + + /** + * Fetches token balances after a transaction completes + */ + const refreshTokenBalances = useCallback(async () => { + if (fromToken?.name) { + await appStore.fetchTokenInfo(fromToken.name); + } + if (toToken?.name) { + await appStore.fetchTokenInfo(toToken.name); + } + }, [appStore, fromToken?.name, toToken?.name]); + + /** + * Handles adding a trustline for a token. + */ + const handleTrustLine = useCallback( + async (tokenAddress: string): Promise => { + if (!storePersist.wallet.address || !tokenAddress) return; + + try { + const trust = await checkTrustline( + storePersist.wallet.address, + tokenAddress + ); + + setTrustlineButtonActive(!trust.exists); + setTrustlineTokenSymbol(trust.asset?.code || ""); + + // Only fetch trustline asset if needed + if (!trust.exists) { + const tlAsset = await appStore.fetchTokenInfo( + "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA" + ); + if (tlAsset?.decimals) { + setTrustlineAssetAmount( + Number(tlAsset.balance) / 10 ** tlAsset.decimals + ); + } + } + + setTrustlineTokenName(trust.asset?.contract || ""); + } catch (error) { + console.error("Error checking trustline:", error); + } + }, + [storePersist.wallet.address, appStore] + ); + + /** + * Adds a trustline for the specified token. + */ + const addTrustLine = useCallback(async (): Promise => { + if (!storePersist.wallet.address || !trustlineTokenName) return; + + try { + setTxBroadcasting(true); + await fetchAndIssueTrustline( + storePersist.wallet.address, + trustlineTokenName + ); + setTrustlineButtonActive(false); + + // Refresh token balances after creating trustline + setTimeout(refreshTokenBalances, 7000); + } catch (e) { + console.log("Error creating trustline:", e); + } finally { + setTxBroadcasting(false); + } + }, [storePersist.wallet.address, trustlineTokenName, refreshTokenBalances]); + /** * Executes the swap transaction. - * This function signs and sends the transaction using WalletConnect or Signer. - * - * @async */ const doSwap = useCallback(async (): Promise => { try { @@ -110,325 +199,574 @@ export default function SwapPage(): JSX.Element { { simulate: !restore } ); }, + options: { + onSuccess: () => { + setTimeout(refreshTokenBalances, 7000); + }, + }, }); - - // Wait for the next block and fetch token balances - setTimeout(async () => { - await appStore.fetchTokenInfo(fromToken?.name!); - await appStore.fetchTokenInfo(toToken?.name!); - }, 7000); } catch (error) { console.log("Error during swap transaction", error); } }, [ - appStore, - fromToken?.name, maxSpread, operations, - storePersist, - toToken?.name, + storePersist.wallet.address, tokenAmounts, executeContractTransaction, + refreshTokenBalances, ]); /** - * Simulates the swap transaction to determine the exchange rate and network fee. - * - * @async + * Simulates the swap transaction with throttling to prevent excessive API calls */ const doSimulateSwap = useCallback(async (): Promise => { - if (fromToken && toToken) { - if (tokenAmounts[0] === 0) { - setTokenAmounts([0, 0]); - setExchangeRate(""); - setNetworkFee(""); - return; - } + // Clear any pending simulation requests + if (simulationRequestRef.current) { + clearTimeout(simulationRequestRef.current); + simulationRequestRef.current = null; + } - setLoadingSimulate(true); - try { - const contract = new PhoenixMultihopContract.Client({ - contractId: constants.MULTIHOP_ADDRESS, - networkPassphrase: constants.NETWORK_PASSPHRASE, - rpcUrl: constants.RPC_URL, - }); + // Guard clauses to prevent unnecessary simulation + if (!fromToken || !toToken || !operations.length || tokenAmounts[0] === 0) { + setTokenAmounts((prevAmounts) => [prevAmounts[0], 0]); + setExchangeRate(""); + setNetworkFee(""); + return; + } - const tx = await contract.simulate_swap({ - operations, - amount: BigInt(tokenAmounts[0] * 10 ** 7), - pool_type: 0, - }); + // Prevent simulation if already simulating + if (loadingSimulate) return; - if (tx.result.ask_amount && tx.result.commission_amounts) { - const _exchangeRate = - (Number(tx.result.ask_amount) - - Number(tx.result.commission_amounts[0][1])) / - Number(tokenAmounts[0]); + setLoadingSimulate(true); + try { + const contract = new PhoenixMultihopContract.Client({ + contractId: constants.MULTIHOP_ADDRESS, + networkPassphrase: constants.NETWORK_PASSPHRASE, + rpcUrl: constants.RPC_URL, + }); - setExchangeRate( - `${(_exchangeRate / 10 ** 7).toFixed(2)} ${toToken?.name} per ${ - fromToken?.name - }` - ); - setNetworkFee( - `${Number(tx.result.commission_amounts[0][1]) / 10 ** 7} ${ - fromToken?.name - }` - ); + const tx = await contract.simulate_swap({ + operations, + amount: BigInt(tokenAmounts[0] * 10 ** 7), + pool_type: 0, + }); + + if (tx.result.ask_amount && tx.result.commission_amounts) { + const askAmount = Number(tx.result.ask_amount); + const commissionAmount = Number(tx.result.commission_amounts[0][1]); + const netAmount = askAmount - commissionAmount; + const _exchangeRate = netAmount / Number(tokenAmounts[0]); + + setExchangeRate( + `1 ${fromToken.name} = ${(_exchangeRate / 10 ** 7).toFixed(6)} ${ + toToken.name + }` + ); + setNetworkFee( + `${(commissionAmount / 10 ** 7).toFixed(6)} ${fromToken.name}` + ); - setTokenAmounts((prevAmounts) => { - const newToTokenAmount = Number(tx.result.ask_amount) / 10 ** 7; + // Only update if the amount has actually changed + const newToTokenAmount = askAmount / 10 ** 7; + setTokenAmounts((prevAmounts) => { + if (Math.abs(prevAmounts[1] - newToTokenAmount) > 0.000001) { return [prevAmounts[0], newToTokenAmount]; - }); - } - } catch (e) { - console.log(e); + } + return prevAmounts; + }); } + } catch (e) { + console.error("Simulation error:", e); + setExchangeRate(""); + setNetworkFee(""); + setTokenAmounts((prevAmounts) => [prevAmounts[0], 0]); + } finally { setLoadingSimulate(false); } - }, [fromToken?.name, toToken, fromAmount, operations, tokenAmounts]); + }, [fromToken?.name, toToken?.name, operations.length, tokenAmounts[0]]); /** * Handles user selecting a token from the asset selector. - * - * @param {Token} token - The token selected by the user. */ const handleTokenClick = useCallback( (token: Token): void => { if (isFrom) { - setTokens( - (tokens) => - [ - ...tokens.filter((el) => el.name !== token.name), - fromToken, - ].filter(Boolean) as Token[] - ); setFromToken(token); + // If the selected token was the toToken, swap them + if (toToken && token.name === toToken.name) { + setToToken(fromToken); + } + + // Update the tokens list, maintaining the current toToken + setTokens((prevTokens) => { + const filteredTokens = prevTokens.filter( + (t) => + t.name !== token.name && (!toToken || t.name !== toToken.name) + ); + // Add fromToken back to the list if it exists + return fromToken ? [...filteredTokens, fromToken] : filteredTokens; + }); } else { - setTokens( - (tokens) => - [...tokens.filter((el) => el.name !== token.name), toToken].filter( - Boolean - ) as Token[] - ); setToToken(token); + // If the selected token was the fromToken, swap them + if (fromToken && token.name === fromToken.name) { + setFromToken(toToken); + } + + // Update the tokens list, maintaining the current fromToken + setTokens((prevTokens) => { + const filteredTokens = prevTokens.filter( + (t) => + t.name !== token.name && (!fromToken || t.name !== fromToken.name) + ); + // Add toToken back to the list if it exists + return toToken ? [...filteredTokens, toToken] : filteredTokens; + }); } setAssetSelectorOpen(false); + // Reset simulation flags when token changes + operationsUpdateComplete.current = false; + prevOperations.current = ""; + operationsHash.current = ""; + setOperationsStableHash(""); }, - [fromToken, isFrom, toToken] + [fromToken, toToken, isFrom] ); /** * Opens the asset selector. - * - * @param {boolean} isFromToken - Whether the asset selector is for the "from" token. */ const handleSelectorOpen = useCallback((isFromToken: boolean): void => { setAssetSelectorOpen(true); setIsFrom(isFromToken); }, []); - // Effect hook to fetch all tokens once the component mounts + /** + * Load pools data from the contract - minimizing API calls + */ + const loadPoolsData = useCallback(async () => { + if (hasPoolsLoaded.current) return allPools; + + try { + const factoryContract = new PhoenixFactoryContract.Client({ + contractId: constants.FACTORY_ADDRESS, + networkPassphrase: constants.NETWORK_PASSPHRASE, + rpcUrl: constants.RPC_URL, + }); + const { result } = await factoryContract.query_all_pools_details(); + + const allPairs = result.map((pool: any) => ({ + asset_a: pool.pool_response.asset_a.address, + asset_b: pool.pool_response.asset_b.address, + })); + + hasPoolsLoaded.current = true; + setAllPools(allPairs); + appStore.setLoading(false); + return allPairs; + } catch (error) { + console.error("Failed to load pools data:", error); + appStore.setLoading(false); + return []; + } + }, [appStore]); + + // Effect hook to fetch all tokens once the component mounts - with better controls useEffect(() => { - const getAllTokens = async (): Promise => { + if (!isClient || hasTokensLoaded.current) return; + + const getAllTokens = async () => { + // Avoid multiple loads + tokenLoadCount.current += 1; + if (tokenLoadCount.current > 1) return; + + if (appStore.allTokens.length > 0) { + // Avoid fetching if we already have tokens + setIsLoading(false); + setTokens(appStore.allTokens.slice(2)); + setFromToken(appStore.allTokens[0]); + setToToken(appStore.allTokens[1]); + hasTokensLoaded.current = true; + appStore.setLoading(false); + + // Still load pools in the background + setTimeout(() => { + loadPoolsData(); + }, 500); + + return; + } + setIsLoading(true); try { + // Load tokens first const allTokens = await appStore.getAllTokens(); - setTokens(allTokens.slice(2)); - setFromToken(allTokens[0]); - setToToken(allTokens[1]); - setIsLoading(false); - // Get all pools - const factoryContract = new PhoenixFactoryContract.Client({ - contractId: constants.FACTORY_ADDRESS, - networkPassphrase: constants.NETWORK_PASSPHRASE, - rpcUrl: constants.RPC_URL, - }); - const { result } = await factoryContract.query_all_pools_details(); - - const allPairs = result.map((pool: any) => ({ - asset_a: pool.pool_response.asset_a.address, - asset_b: pool.pool_response.asset_b.address, - })); - setAllPools(allPairs); + if (allTokens?.length > 1) { + setTokens(allTokens.slice(2)); + setFromToken(allTokens[0]); + setToToken(allTokens[1]); + hasTokensLoaded.current = true; + } } catch (e) { - console.error(e); + console.error("Failed to load tokens:", e); } finally { + setIsLoading(false); appStore.setLoading(false); + + // Load pools separately regardless of token loading success + setTimeout(() => { + loadPoolsData(); + }, 500); } }; + getAllTokens(); - }, []); + }, [appStore, loadPoolsData, isClient]); - // Effect hook to simulate swaps on token change - useEffect(() => { - if (fromToken && toToken && operations.length > 0) { - doSimulateSwap(); + // Update operations when tokens change with stricter control to prevent infinite loops + const updateSwapOperations = useCallback(() => { + // Only run this once per token pair change + if ( + !fromToken || + !toToken || + !appStore.allTokens.length || + !allPools.length || + fromToken.name === toToken.name + ) { + return; } - }, [fromToken, toToken, fromAmount, operations.length]); - // Effect hook to update operations when tokens change - useEffect(() => { - if (fromToken && toToken) { - const fromTokenContractID = appStore.allTokens.find( - (token: Token) => token.name === fromToken.name - )?.contractId; - const toTokenContractID = appStore.allTokens.find( - (token: Token) => token.name === toToken.name - )?.contractId; - - if (!fromTokenContractID || !toTokenContractID) return; - - const { operations: ops } = findBestPath( - toTokenContractID, - fromTokenContractID, - allPools - ); - const _operations = ops.reverse(); - const _swapRoute = _operations - .map( - (op) => - appStore.allTokens.find( - (token: any) => token.contractId === op.ask_asset - )?.name - ) - .filter(Boolean); - - setOperations((prevOps) => - prevOps !== _operations ? _operations : prevOps - ); + // Calculate current state hash to compare + const currentPairKey = `${fromToken.name}-${toToken.name}`; + + // Skip if operations are already up to date for this token pair + if (prevOperations.current === currentPairKey) { + return; + } + + // Find token contract IDs + const fromTokenContractID = appStore.allTokens.find( + (token: Token) => token.name === fromToken.name + )?.contractId; + + const toTokenContractID = appStore.allTokens.find( + (token: Token) => token.name === toToken.name + )?.contractId; + + if (!fromTokenContractID || !toTokenContractID) return; + + // Create a new best path + const { operations: ops } = findBestPath( + toTokenContractID, + fromTokenContractID, + allPools + ); + + if (!ops?.length) return; + + const _operations = ops.reverse(); + const _swapRoute = _operations + .map( + (op) => + appStore.allTokens.find( + (token: any) => token.contractId === op.ask_asset + )?.name + ) + .filter(Boolean); + + // Create operations hash to prevent unnecessary simulation triggers + const operationsString = JSON.stringify(_operations); + const currentOperationsHash = btoa(operationsString).slice(0, 16); // Only update if operations have actually changed + if (operationsHash.current !== currentOperationsHash) { + // Update operations and swap route + setOperations(_operations); setSwapRoute(`${fromToken.name} -> ${_swapRoute.join(" -> ")}`); - if (storePersist.wallet.address) { - handleTrustLine(toTokenContractID); - } + + // Update hash references + operationsHash.current = currentOperationsHash; + setOperationsStableHash(currentOperationsHash); + prevOperations.current = currentPairKey; + operationsUpdateComplete.current = true; } - }, [allPools, fromToken?.name, toToken?.name, storePersist.wallet.address]); - /** - * Handles adding a trustline for a token. - * - * @param {string} tokenAddress - The address of the token. - * @async - */ - const handleTrustLine = useCallback( - async (tokenAddress: string): Promise => { - const trust = await checkTrustline( - storePersist.wallet.address!, - tokenAddress - ); - setTrustlineButtonActive(!trust.exists); - setTrustlineTokenSymbol(trust.asset?.code || ""); - const tlAsset = await appStore.fetchTokenInfo( - "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA" - ); - setTrustlineAssetAmount( - Number(tlAsset?.balance) / 10 ** tlAsset?.decimals! - ); - setTrustlineTokenName(trust.asset?.contract || ""); - }, - [storePersist.wallet.address] - ); + // Check trustline if wallet is connected + if (storePersist.wallet.address && toTokenContractID) { + handleTrustLine(toTokenContractID); + } + }, [ + allPools, + fromToken, + toToken, + appStore.allTokens, + storePersist.wallet.address, + handleTrustLine, + ]); - /** - * Adds a trustline for the specified token. - * - * @async - */ - const addTrustLine = useCallback(async (): Promise => { - try { - setTxBroadcasting(true); - await fetchAndIssueTrustline( - storePersist.wallet.address!, - trustlineTokenName - ); - setTrustlineButtonActive(false); - } catch (e) { - console.log(e); + // Update operations when tokens or pools change - with safeguards + useEffect(() => { + if (!isClient || !fromToken || !toToken || !allPools.length) return; + + // Update operations only if something meaningful has changed + updateSwapOperations(); + }, [ + isClient, + fromToken?.name, // Only depend on the name, not the whole object + toToken?.name, // Only depend on the name, not the whole object + allPools.length, // Only depend on the length, not the whole array + updateSwapOperations, + ]); + + // Simulate swap with debounced amount changes and throttling + useEffect(() => { + if (!isClient) return; + + // Skip if no operations or tokens + if ( + !fromToken || + !toToken || + !operations.length || + !operationsUpdateComplete.current + ) { + return; } - setTxBroadcasting(false); - }, [storePersist.wallet.address, trustlineTokenName]); + + // Skip if amount is zero + if (fromAmount <= 0) { + if (tokenAmounts[1] !== 0) { + setTokenAmounts([tokenAmounts[0], 0]); + } + return; + } + + // Use throttled simulation with a minimum interval between calls + if (simulationRequestRef.current) { + clearTimeout(simulationRequestRef.current); + } + + simulationRequestRef.current = setTimeout(() => { + // Call simulation directly instead of using doSimulateSwap callback + if ( + !fromToken || + !toToken || + !operations.length || + tokenAmounts[0] === 0 + ) { + setTokenAmounts((prevAmounts) => [prevAmounts[0], 0]); + setExchangeRate(""); + setNetworkFee(""); + return; + } + + // Prevent simulation if already simulating + if (loadingSimulate) return; + + setLoadingSimulate(true); + + const runSimulation = async () => { + try { + const contract = new PhoenixMultihopContract.Client({ + contractId: constants.MULTIHOP_ADDRESS, + networkPassphrase: constants.NETWORK_PASSPHRASE, + rpcUrl: constants.RPC_URL, + }); + + const tx = await contract.simulate_swap({ + operations, + amount: BigInt(tokenAmounts[0] * 10 ** 7), + pool_type: 0, + }); + + if (tx.result.ask_amount && tx.result.commission_amounts) { + const askAmount = Number(tx.result.ask_amount); + const commissionAmount = Number(tx.result.commission_amounts[0][1]); + const netAmount = askAmount - commissionAmount; + const _exchangeRate = netAmount / Number(tokenAmounts[0]); + + setExchangeRate( + `1 ${fromToken.name} = ${(_exchangeRate / 10 ** 7).toFixed(6)} ${ + toToken.name + }` + ); + setNetworkFee( + `${(commissionAmount / 10 ** 7).toFixed(6)} ${fromToken.name}` + ); + + // Only update if the amount has actually changed + const newToTokenAmount = askAmount / 10 ** 7; + setTokenAmounts((prevAmounts) => { + if (Math.abs(prevAmounts[1] - newToTokenAmount) > 0.000001) { + return [prevAmounts[0], newToTokenAmount]; + } + return prevAmounts; + }); + } + } catch (e) { + console.error("Simulation error:", e); + setExchangeRate(""); + setNetworkFee(""); + setTokenAmounts((prevAmounts) => [prevAmounts[0], 0]); + } finally { + setLoadingSimulate(false); + } + }; + + runSimulation(); + simulationRequestRef.current = null; + }, 300); // Reduced timeout for better responsiveness + + // Cleanup on unmount or when dependencies change + return () => { + if (simulationRequestRef.current) { + clearTimeout(simulationRequestRef.current); + simulationRequestRef.current = null; + } + }; + }, [ + fromAmount, + fromToken?.name, + toToken?.name, + operationsStableHash, + isClient, + ]); + + // Memoized swap container props to prevent unnecessary re-renders + const swapContainerProps = useMemo( + () => ({ + onOptionsClick: () => setOptionsOpen(true), + onSwapTokensClick: () => { + setTokenAmounts((prevAmounts) => [prevAmounts[1], prevAmounts[0]]); + setFromToken(toToken); + setToToken(fromToken); + // Reset flags when tokens are swapped + operationsUpdateComplete.current = false; + prevOperations.current = ""; + operationsHash.current = ""; + setOperationsStableHash(""); + }, + fromTokenValue: tokenAmounts[0].toString()!, + toTokenValue: tokenAmounts[1].toString()!, + fromToken: fromToken!, + toToken: toToken!, + onTokenSelectorClick: handleSelectorOpen, + onSwapButtonClick: doSwap, + onInputChange: (isFrom: boolean, value: string) => { + const numValue = Number(value) || 0; + setTokenAmounts((prevAmounts) => { + if (isFrom) { + if (prevAmounts[0] === numValue) return prevAmounts; + return [numValue, prevAmounts[1]]; + } else { + if (prevAmounts[1] === numValue) return prevAmounts; + return [prevAmounts[0], numValue]; + } + }); + }, + exchangeRate, + networkFee, + route: swapRoute, + loadingSimulate, + estSellPrice: "TODO", + minSellPrice: "TODO", + slippageTolerance: `${maxSpread}%`, + swapButtonDisabled: + tokenAmounts[0] <= 0 || + !storePersist.wallet.address || + !operations.length || + loadingSimulate || + txBroadcasting, + trustlineButtonActive, + trustlineAssetName: trustlineTokenSymbol, + trustlineButtonDisabled: trustlineAssetAmount < 0.5 || txBroadcasting, + onTrustlineButtonClick: addTrustLine, + }), + [ + tokenAmounts, + fromToken, + toToken, + exchangeRate, + networkFee, + swapRoute, + loadingSimulate, + maxSpread, + trustlineButtonActive, + trustlineTokenSymbol, + trustlineAssetAmount, + storePersist.wallet.address, + operations.length, + txBroadcasting, + doSwap, + handleSelectorOpen, + addTrustLine, + ] + ); return ( <> - {/* Hacky Title Injector - Waiting for Next Helmet for Next15 */} - + {/* Hacky Title Injector - Only on client side */} + {isClient && ( + + )} - {isLoading ? ( - + {isLoading || !isClient ? ( + ) : ( - - + + {!optionsOpen && !assetSelectorOpen && fromToken && toToken && ( - setOptionsOpen(true)} - onSwapTokensClick={() => { - setTokenAmounts((prevAmounts) => [ - prevAmounts[1], - prevAmounts[0], - ]); - setFromToken(toToken); - setToToken(fromToken); - }} - fromTokenValue={tokenAmounts[0].toString()} - toTokenValue={tokenAmounts[1].toString()} - fromToken={fromToken} - toToken={toToken} - onTokenSelectorClick={(isFromToken: boolean) => - handleSelectorOpen(isFromToken) - } - onSwapButtonClick={() => doSwap()} - onInputChange={(isFrom: boolean, value: string) => { - setTokenAmounts((prevAmounts) => - isFrom - ? [Number(value), 0] - : [prevAmounts[0], Number(value)] - ); - }} - exchangeRate={exchangeRate} - networkFee={networkFee} - route={swapRoute} - loadingSimulate={loadingSimulate} - estSellPrice={"TODO"} - minSellPrice={"TODO"} - slippageTolerance={`${maxSpread}%`} - swapButtonDisabled={ - tokenAmounts[0] <= 0 || - storePersist.wallet.address === undefined - } - trustlineButtonActive={trustlineButtonActive} - trustlineAssetName={trustlineTokenSymbol} - trustlineButtonDisabled={trustlineAssetAmount < 0.5} - onTrustlineButtonClick={() => addTrustLine()} - /> + )} {optionsOpen && ( setOptionsOpen(false)} - onChange={(option: string) => setMaxSpread(Number(option))} + onChange={(option: number) => setMaxSpread(option)} /> )} {assetSelectorOpen && ( {tokens.length > 0 ? ( { - return ( - - - - - - - Phoenix Logo - - - We're Upgrading to Phoenix DeFi Hub V2 - - - We{"'"}ll be offline for a couple of hours while we complete the - upgrade. - - - - - - For Updates Follow Us On - - - - - - - - - - @PhoenixDefiHub - - - - - - - - - - Discord - - - - - - - - Phoenix DeFi Hub App – V2 Changelog - - - - - UI/UX Overhaul: - - - Completely redesigned the app{"'"}s look and feel with a - modern, advanced design. Nearly every component has been - adjusted for a more seamless and visually refined user - experience. - - - - Dashboard Enhancements: - - - Assets on the dashboard are now clickable, revealing in-depth - statistics including Volume, Price, available Trading Pairs, - and more. - - - - New Earn Page: - - - Introduced a brand-new earn page offering multiple earning - strategies. - - - - • Starting with Phoenix Pools, users can now participate in - automated earning mechanisms. - - - • No more manual staking/unstaking of LP tokens – everything - is handled in a single transaction. - - - - - One-Click Rewards Claiming: - - - Added a feature that allows users to claim all available - rewards across the platform with a single click. - - - - Quality of Life Improvements: - - - Various enhancements across the app to improve usability, - performance, and overall experience. - - - - - - - We appreciate your patience while we improve the Phoenix DeFi - Hub experience. - - - - - - - ); -}; - -export default MaintenanceScreen; diff --git a/packages/core/components/SideNav/SideNav.tsx b/packages/core/components/SideNav/SideNav.tsx index c269ccfb..b6574ba0 100644 --- a/packages/core/components/SideNav/SideNav.tsx +++ b/packages/core/components/SideNav/SideNav.tsx @@ -43,6 +43,19 @@ const SideNav = ({ active: pathname == "/swap", href: "/swap", }, + { + label: "Earn", + icon: ( + Earn Icon + ), + active: pathname == "/earn", + href: "/earn", + }, { label: "Pools", icon: ( @@ -57,10 +70,10 @@ const SideNav = ({ href: "/pools", }, { - label: "Trade History", + label: "Statistics", icon: ( History Icon void} [onSuccess] - A callback function that is invoked + * after the transaction is successfully executed. This function should + * handle any post-transaction logic, such as updating the UI or triggering + * additional actions. It is optional and will not be called if not provided. + * + * @property {number} [fee] - Custom transaction fee in stroops. If not provided, + * the default constants.PHOENIX_BASE_FEE (200 stroops) will be used. + * + * @example + * // Using custom fee in transaction + * executeContractTransaction({ + * contractType: "pair", + * contractAddress: pairAddress, + * transactionFunction: (client, restore, txOptions) => client.swap( + * { sender: address, offer_asset: assetA, ask_asset: assetB, amount: amount.toString() }, + * { ...txOptions } // This passes the fee to the contract method + * ), + * options: { + * fee: 100000, // Custom fee in stroops (100,000 stroops = 0.01 XLM) + * onSuccess: () => refetchData() + * } + * }); + * + * // Using default PHOENIX_BASE_FEE + * executeContractTransaction({ + * contractType: "pair", + * contractAddress: pairAddress, + * transactionFunction: (client, restore, txOptions) => client.swap( + * { sender: address, offer_asset: assetA, ask_asset: assetB, amount: amount.toString() }, + * { ...txOptions } // This passes the default fee to the contract method + * ), + * options: { + * onSuccess: () => refetchData() + * } + * }); + */ +interface TransactionOptions { + onSuccess?: () => void; + fee?: number; +} + const contractClients = { pair: PhoenixPairContract.Client, multihop: PhoenixMultihopContract.Client, @@ -53,8 +97,10 @@ interface BaseExecuteContractTransactionParams { contractAddress: string; transactionFunction: ( client: ContractClientType, - restore?: boolean + restore?: boolean, + txOptions?: { fee?: number } ) => Promise>; + options?: TransactionOptions; } interface ExecuteContractTransactionParams @@ -84,7 +130,7 @@ const getContractClient = ( publicKey: string, storePersist: any ): ContractClientType => { - console.log(1); + console.log(`Creating contract client for ${contractType} contract`); const signTransaction = getSignerFunction(signer, storePersist); const commonOptions = { publicKey: publicKey, @@ -110,6 +156,7 @@ export const useContractTransaction = () => { contractType, contractAddress, transactionFunction, + options = {}, }: ExecuteContractTransactionParams) => { const signer = getSigner(storePersist, appStore); const networkPassphrase = constants.NETWORK_PASSPHRASE; @@ -128,25 +175,47 @@ export const useContractTransaction = () => { rpcUrl, publicKey, storePersist - ); + ); // Get transaction options with fee, using PHOENIX_BASE_FEE as default + const fee = + options.fee !== undefined + ? options.fee + : parseInt(constants.PHOENIX_BASE_FEE); + const txOptions = { fee }; + + // Log fee information + if (options.fee !== undefined) { + console.log(`Using custom transaction fee: ${fee} stroops`); + } else { + console.log(`Using default PHOENIX_BASE_FEE: ${fee} stroops`); + } + // Pass contract client and potential restore flag to transaction function const transaction = await transactionFunction( contractClient, - restore + restore, + txOptions ); + // Store fee in outer scope for use in resolve callback + const transactionFee = fee; + console.log("Attempting to sign and send transaction..."); - // Step 2: Handle signing and sending the transaction with async toast - const promise = new Promise<{ transactionId?: string }>( - async (resolve, reject) => { + // Step 2: Handle signing and sending with the new addAsyncToast + return addAsyncToast( + new Promise(async (resolve, reject) => { try { if (restore) { console.log("Restoring transaction state..."); await transaction.simulate({ restore: true }); + options.onSuccess?.(); // Call onSuccess callback after successful restore resolve({}); } else { const sentTransaction = await transaction.signAndSend(); + console.log( + `Transaction sent with fee: ${transactionFee} stroops` + ); + options.onSuccess?.(); // Call onSuccess callback after successful transaction resolve({ transactionId: sentTransaction.sendTransactionResponse?.hash, @@ -169,35 +238,31 @@ export const useContractTransaction = () => { "Error during restoring transaction:", restoreError ); - reject(restoreError); // Reject with the restoration error + reject(restoreError); } finally { closeRestoreModal(); } }); return; // Exit early since restore will handle the resolution } - - reject(error); // Reject with the error + reject(error); } - } + }), + loadingMessage, + { title: "Transaction" } ); - - // Add the promise to the toast for UI updates - addAsyncToast(promise, loadingMessage); - - // Delay at least 5 seconds before finishing the transaction process - await promise; - await new Promise((resolve) => setTimeout(resolve, 5000)); } catch (error) { console.log("Unexpected error executing contract transaction", error); - addAsyncToast(Promise.reject(error), loadingMessage); + return addAsyncToast(Promise.reject(error), loadingMessage, { + title: "Transaction", + }); } }; // Start transaction execution - await executeTransaction(); + return executeTransaction(); }, - [addAsyncToast, storePersist, openRestoreModal, closeRestoreModal] + [addAsyncToast, storePersist, appStore, openRestoreModal, closeRestoreModal] ); return { diff --git a/packages/core/hooks/useToast.ts b/packages/core/hooks/useToast.ts index 6a37891f..9df3e4fb 100644 --- a/packages/core/hooks/useToast.ts +++ b/packages/core/hooks/useToast.ts @@ -1,11 +1,3 @@ // ToastHook.ts -import { useContext } from "react"; -import ToastContext from "@/providers/ToastProvider"; - -export const useToast = () => { - const context = useContext(ToastContext); - if (!context) { - throw new Error("useToast must be used within a ToastProvider"); - } - return context; -}; +// Re-export the UI library's useToast hook +export { useToast } from "@phoenix-protocol/ui/src/Common/Toast"; diff --git a/packages/core/next.config.js b/packages/core/next.config.js index c611954a..6b27b754 100644 --- a/packages/core/next.config.js +++ b/packages/core/next.config.js @@ -2,6 +2,15 @@ const nextConfig = { experimental: { reactCompiler: true, + turbo: { + resolveAlias: { + '@phoenix-protocol/types': '../types/src', + '@phoenix-protocol/utils': '../utils/src', + '@phoenix-protocol/contracts': '../contracts/src', + '@phoenix-protocol/state': '../state/src', + '@phoenix-protocol/strategies': '../strategies/src' + } + } }, webpack: (config) => { config.resolve.alias.canvas = false; diff --git a/packages/core/package.json b/packages/core/package.json index b2886bc8..07bb0cdb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -3,9 +3,9 @@ "version": "0.0.1", "private": true, "scripts": { - "dev": "next dev --turbopack", - "dev:experimental": "next dev --experimental-https", - "build": "next build", + "dev": "SKIP_NODE_VERSION_CHECK=1 next dev --turbopack", + "dev:experimental": "SKIP_NODE_VERSION_CHECK=1 next dev --experimental-https", + "build": "SKIP_NODE_VERSION_CHECK=1 next build", "start": "next start", "lint": "next lint" }, @@ -23,9 +23,9 @@ "@vercel/analytics": "^1.1.3", "bufferutil": "^4.0.8", "eslint": "8.45.0", - "eslint-config-next": "15.1.3", + "eslint-config-next": "15.2.4", "moment-timezone": "^0.5.45", - "next": "15.1.3", + "next": "15.2.4", "pino-pretty": "^11.1.0", "react": "19.0.0", "react-dom": "19.0.0", @@ -38,15 +38,15 @@ }, "devDependencies": { "@types/node": "20.4.2", - "@types/react": "19.0.2", - "@types/react-dom": "19.0.2", + "@types/react": "19.0.12", + "@types/react-dom": "19.0.4", "@types/react-google-recaptcha": "^2.1.9", "babel-plugin-react-compiler": "^19.0.0-beta-0dec889-20241115", "css-loader": "^6.8.1", "style-loader": "^3.3.3" }, "resolutions": { - "@types/react": "19.0.2", - "@types/react-dom": "19.0.2" + "@types/react": "19.0.12", + "@types/react-dom": "19.0.4" } } diff --git a/packages/core/providers/ClientOnlyProvider.tsx b/packages/core/providers/ClientOnlyProvider.tsx new file mode 100644 index 00000000..ad569915 --- /dev/null +++ b/packages/core/providers/ClientOnlyProvider.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { ReactNode, useEffect, useState } from "react"; + +interface ClientOnlyProps { + children: ReactNode; + fallback?: ReactNode; +} + +/** + * ClientOnly component that renders its children only on the client-side + * to prevent hydration mismatch errors with client-specific code. + * + * @param {ClientOnlyProps} props - The component props + * @param {ReactNode} props.children - Content to render on client-side + * @param {ReactNode} props.fallback - Optional fallback content during SSR + */ +export default function ClientOnly({ + children, + fallback = null, +}: ClientOnlyProps) { + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); + + if (!isMounted) { + return <>{fallback}; + } + + return <>{children}; +} diff --git a/packages/core/providers/ToastProvider.tsx b/packages/core/providers/ToastProvider.tsx index 61907e24..9194676e 100644 --- a/packages/core/providers/ToastProvider.tsx +++ b/packages/core/providers/ToastProvider.tsx @@ -1,102 +1,18 @@ -// ToastProvider.tsx -import React, { createContext, useState, ReactNode, FC } from "react"; -import { Box } from "@mui/material"; -import { Toast } from "@/components/Toast"; - -// Define Toast type -interface ToastType { - id: number; - type: "success" | "error" | "loading"; - message: string; - transactionId?: string; -} - -// Define context value type -interface ToastContextType { - addToast: ( - message: string, - type: "success" | "error" | "loading", - transactionId?: string - ) => void; - addAsyncToast: ( - promise: Promise<{ transactionId?: string }>, - loadingMessage: string - ) => void; -} - -// Create Toast Context -const ToastContext = createContext(undefined); +import React, { ReactNode, FC } from "react"; +import { + ToastProvider as UIToastProvider, + ToastContainer, +} from "@phoenix-protocol/ui/src/Common/Toast"; export const ToastProvider: FC<{ children: ReactNode }> = ({ children }) => { - const [toasts, setToasts] = useState([]); - - const addToast = ( - message: string, - type: "success" | "error" | "loading", - transactionId?: string - ) => { - const id = Date.now(); - setToasts((prevToasts) => [ - ...prevToasts, - { id, type, message, transactionId }, - ]); - }; - - const removeToast = (id: number) => { - setToasts((prevToasts) => prevToasts.filter((toast) => toast.id !== id)); - }; - - // Function to add async toast - const addAsyncToast = async ( - promise: Promise<{ transactionId?: string }>, - loadingMessage: string - ) => { - const id = Date.now(); - // Show loading toast - addToast(loadingMessage, "loading"); - try { - const result = await promise; - // Update toast to success with transaction ID if present in the result - removeToast(id); - addToast( - "Transaction completed successfully!", - "success", - result.transactionId - ); - } catch (error: any) { - // Update toast to error - removeToast(id); - addToast( - error.message ? error.message : "Something went wrong.", - "error" - ); - } - }; - return ( - - {children} - - {toasts.map((toast) => ( - removeToast(toast.id)} - /> - ))} - - + + {children as any} + {/* ToastContainer will now automatically use toasts from context */} + + ); }; -export default ToastContext; +export { useToast } from "@phoenix-protocol/ui/src/Common/Toast"; +export default ToastProvider; diff --git a/packages/state/README.md b/packages/state/README.md index bee1e3b6..9d88bf2b 100644 --- a/packages/state/README.md +++ b/packages/state/README.md @@ -1,6 +1,14 @@ # State Package -The State package is a collection of state management utilities for the Phoenix Frontend project. It utilizes Zustand, a powerful library for managing and caching asynchronous data. +The State package is a collection of state management utilities for the Phoenix Frontend project. It utilizes Zustand, a powerful library for managing application state with a simple yet flexible API. + +## Features + +- Zustand-based state management +- Action creators for state manipulation +- Type-safe state implementation with TypeScript +- Optimized for performance with minimal re-renders +- Integration with Phoenix contracts ## Installation @@ -9,14 +17,47 @@ To install the State package, follow these steps: 1. Ensure that you have Node.js and yarn installed on your machine. 2. Navigate to the root directory of the Phoenix Frontend project. 3. Run `yarn install` to install all project dependencies. -4. Navigate to the `/packages/state` directory. ## Usage To use the State package in your application, follow these steps: -1. Import the desired state providers from the State package into your project. -2. Wrap your components with the imported state providers to access the provided state management functionalities. -3. Utilize the available hooks and utilities provided by React Query to manage and interact with the application's state. -4. Customize the state management as needed, utilizing the options and configurations available in React Query and the State package. +1. Import the desired stores from the State package: + + ```typescript + import { useAppStore, useWalletStore } from "@phoenix-protocol/state"; + ``` + +2. Use the store hooks in your components: + + ```typescript + function MyComponent() { + const { someState, someAction } = useAppStore(); + // Use the state and actions as needed + return
{someState}
; + } + ``` + +3. For more complex state management, you can create custom hooks that combine multiple stores: + ```typescript + function useCustomState() { + const appState = useAppStore(); + const walletState = useWalletStore(); + + // Combine state as needed + return { + ...appState, + wallet: walletState, + }; + } + ``` + +## Development + +To develop or extend the State package: + +1. Make changes to the files in the `src/state` directory +2. Run `yarn build:state` or `yarn dev` from the project root to build the package +3. Import and use your changes in other packages +The package is structured to make it easy to add new stores and actions while maintaining type safety and performance. diff --git a/packages/state/package.json b/packages/state/package.json index d3423af8..cd5d1d72 100644 --- a/packages/state/package.json +++ b/packages/state/package.json @@ -8,8 +8,9 @@ "build/**/*" ], "scripts": { - "test": "tsc && jest", - "build": "tsc" + "test": "echo \"Error: no test specified\" && exit 0", + "build": "tsc", + "dev": "tsc --watch" }, "np": { "publish": false, diff --git a/packages/state/src/state/wallet/actions.ts b/packages/state/src/state/wallet/actions.ts index 3f3cceb3..dad8ba91 100644 --- a/packages/state/src/state/wallet/actions.ts +++ b/packages/state/src/state/wallet/actions.ts @@ -11,7 +11,12 @@ import { SorobanTokenContract, } from "@phoenix-protocol/contracts"; import { usePersistStore } from "../store"; -import { constants, fetchTokenPrices } from "@phoenix-protocol/utils"; +import { + constants, + fetchTokenPrices, + Signer, + TradeAPi, +} from "@phoenix-protocol/utils"; import { LiquidityPoolInfo } from "@phoenix-protocol/contracts/build/phoenix-pair"; const getCategory = (name: string) => { @@ -21,6 +26,8 @@ const getCategory = (name: string) => { case "eurc": case "veur": case "vchf": + case "eurx": + case "gbpx": return "Stable"; default: @@ -60,9 +67,15 @@ export const createWalletActions = ( contractId: constants.FACTORY_ADDRESS, networkPassphrase: constants.NETWORK_PASSPHRASE, rpcUrl: constants.RPC_URL, + signTransaction: (tx: string) => new Signer().sign(tx), }); // Fetch all available tokens from chain - const allPoolsDetails = await factoryContract.query_all_pools_details(); + const allPoolsDetails = await factoryContract.query_all_pools_details({ + simulate: false, + }); + const _allPoolsDetails = await allPoolsDetails.simulate({ + restore: true, + }); // Parse results parsedResults = allPoolsDetails.result; @@ -102,6 +115,8 @@ export const createWalletActions = ( await Promise.all(allAssets); + const tradeAPI = new TradeAPi.API(constants.TRADING_API_URL); + const _tokens = getState() .tokens.filter( (token: Token) => @@ -126,7 +141,7 @@ export const createWalletActions = ( usdValue: Number( token?.symbol === "PHO" ? await fetchPho() - : await fetchTokenPrices(token?.symbol) + : await tradeAPI.getPrice(token?.id) ).toFixed(2), contractId: token?.id, }; diff --git a/packages/state/src/state/wallet/freighter.ts b/packages/state/src/state/wallet/freighter.ts index 76f0edd1..e2cd87b9 100644 --- a/packages/state/src/state/wallet/freighter.ts +++ b/packages/state/src/state/wallet/freighter.ts @@ -5,7 +5,7 @@ export function freighter(): Connector { return { id: "freighter", name: "Freighter", - iconUrl: "https://app.phoenix-hub.io/freighter.png", + iconUrl: "/freighter.svg", iconBackground: "#fff", installed: true, downloadUrls: { diff --git a/packages/strategies/.gitignore b/packages/strategies/.gitignore new file mode 100644 index 00000000..f4ba78f1 --- /dev/null +++ b/packages/strategies/.gitignore @@ -0,0 +1,7 @@ +/node_modules +package-lock.json +/build +/.rollup.cache +/module +/main +/types \ No newline at end of file diff --git a/packages/strategies/README.md b/packages/strategies/README.md new file mode 100644 index 00000000..6fa0643f --- /dev/null +++ b/packages/strategies/README.md @@ -0,0 +1,42 @@ +# Strategies Package + +The `Strategies` package serves as a strategy provider registry for the Phoenix DeFi ecosystem. It integrates with other packages in the Phoenix Frontend to provide modular and extensible strategy implementations. + +## Features + +- Strategy provider registry for Phoenix DeFi +- Integration with Phoenix contracts +- Debug utilities for strategy development and testing +- Type-safe implementation using TypeScript + +## Installation + +To install the Strategies package, follow these steps: + +1. Ensure that you have Node.js and yarn installed on your machine. +2. Navigate to the root directory of the Phoenix Frontend project. +3. Run `yarn install` to install all project dependencies. + +## Usage + +To use the Strategies package in your application: + +```typescript +import { registry } from "@phoenix-protocol/strategies"; + +// Access registered strategies +const strategies = registry.getStrategies(); + +// Or use specific strategy implementations +import { SomeStrategy } from "@phoenix-protocol/strategies/phoenix"; +``` + +## Development + +To work on the Strategies package: + +1. Make changes to the files in the `src` directory +2. Run `yarn build` or `yarn dev` to build the package +3. Test your changes by importing the package in other parts of the Phoenix Frontend + +The package uses TypeScript for type safety and provides debugging utilities to help with development and testing. diff --git a/packages/strategies/package.json b/packages/strategies/package.json new file mode 100644 index 00000000..5cd6a605 --- /dev/null +++ b/packages/strategies/package.json @@ -0,0 +1,20 @@ +{ + "name": "@phoenix-protocol/strategies", + "version": "0.1.0", + "description": "Strategy provider registry for Phoenix DeFi", + "main": "build/index.js", + "types": "build/index.d.ts", + "scripts": { + "build": "tsc", + "dev": "tsc --watch" + }, + "dependencies": { + "@phoenix-protocol/contracts": "*", + "@phoenix-protocol/state": "*", + "@phoenix-protocol/types": "*", + "@phoenix-protocol/utils": "*" + }, + "devDependencies": { + "typescript": "^5.1.3" + } +} diff --git a/packages/strategies/src/debug.ts b/packages/strategies/src/debug.ts new file mode 100644 index 00000000..9ec3651d --- /dev/null +++ b/packages/strategies/src/debug.ts @@ -0,0 +1,31 @@ +import { StrategyRegistry } from "./registry"; +import { PhoenixBoostProvider } from "./phoenix/provider"; + +async function debugRegistry() { + console.log("=== STRATEGY REGISTRY DEBUG ==="); + + // Initialize provider + const provider = new PhoenixBoostProvider(); + console.log("Provider created:", provider.id); + + // Register provider + StrategyRegistry.registerProvider(provider); + console.log("Provider registered"); + + // List providers + const providers = StrategyRegistry.getProviders(); + console.log( + "Providers:", + providers.map((p) => p.id) + ); + + // Get strategies + const strategies = await StrategyRegistry.getAllStrategies(); + console.log("Total strategies:", strategies.length); + strategies.forEach(async (strategy, index) => { + const meta = await strategy.getMetadata(); + console.log(`Strategy ${index + 1}:`, meta.id); + }); +} + +export { debugRegistry }; diff --git a/packages/strategies/src/index.ts b/packages/strategies/src/index.ts new file mode 100644 index 00000000..396ed238 --- /dev/null +++ b/packages/strategies/src/index.ts @@ -0,0 +1,18 @@ +// Core exports +export * from "./types"; +export { StrategyRegistry } from "./registry"; +export * from "./phoenix/provider"; +export { default as PhoenixBoostStrategy } from "./phoenix/strategies/pho-usdc.liquidity"; + +// Initialize registry with providers +import { StrategyRegistry } from "./registry"; +import { PhoenixBoostProvider } from "./phoenix/provider"; + +// Register all strategy providers +const setupRegistry = () => { + // Register the Phoenix provider + StrategyRegistry.registerProvider(new PhoenixBoostProvider()); +}; + +// Initialize the registry +setupRegistry(); diff --git a/packages/strategies/src/phoenix/provider.ts b/packages/strategies/src/phoenix/provider.ts new file mode 100644 index 00000000..06897a0f --- /dev/null +++ b/packages/strategies/src/phoenix/provider.ts @@ -0,0 +1,48 @@ +import { Strategy, StrategyProvider } from "../types"; +import PhoenixPhoUsdcStrategy from "./strategies/pho-usdc.liquidity"; +import PhoenixXlmPhoStrategy from "./strategies/xlm-pho.liquidity"; +import PhoenixXlmUsdcStrategy from "./strategies/xlm-usdc.liquidity"; + +export class PhoenixBoostProvider implements StrategyProvider { + id = "phoenix-boost"; + name = "Phoenix Boost"; + domain = "phoenix-hub.io"; + description = "Official staking strategies from Phoenix Protocol"; + icon = "/cryptoIcons/pho.svg"; + + private strategies: Strategy[] = []; + + constructor() { + // Initialize with the PHO-USDC strategy + this.strategies.push( + new PhoenixPhoUsdcStrategy(), + new PhoenixXlmPhoStrategy(), + new PhoenixXlmUsdcStrategy() + ); + console.log( + "Phoenix provider initialized with strategies:", + this.strategies.length + ); + } + + async getTVL(): Promise { + try { + const strategyTVLs = await Promise.all( + this.strategies.map(async (strategy) => { + const metadata = await strategy.getMetadata(); + return metadata.tvl; + }) + ); + + return strategyTVLs.reduce((total, current) => total + current, 0); + } catch (error) { + console.error("Error getting TVL for Phoenix provider:", error); + return 0; + } + } + + async getStrategies(): Promise { + console.log("Returning Phoenix strategies:", this.strategies.length); + return this.strategies; + } +} diff --git a/packages/strategies/src/phoenix/strategies/pho-usdc.liquidity.ts b/packages/strategies/src/phoenix/strategies/pho-usdc.liquidity.ts new file mode 100644 index 00000000..fd2eb4e6 --- /dev/null +++ b/packages/strategies/src/phoenix/strategies/pho-usdc.liquidity.ts @@ -0,0 +1,428 @@ +import { Strategy, StrategyMetadata } from "../../types"; +import { useAppStore, usePersistStore } from "@phoenix-protocol/state"; +import { + API, + constants, + fetchTokenPrices, + Signer, +} from "@phoenix-protocol/utils"; +import { + PhoenixPairContract, + PhoenixStakeContract, + fetchPho, // Ensure AssembledTransaction is available +} from "@phoenix-protocol/contracts"; + +import { AssembledTransaction } from "@stellar/stellar-sdk/lib/contract"; + +// Needed constants and types +const userWalletAddress = usePersistStore.getState().wallet; +const contractAddress = + "CD5XNKK3B6BEF2N7ULNHHGAMOKZ7P6456BFNIHRF4WNTEDKBRWAE7IAA"; +const contractType = "pair"; + +class PhoenixPhoUsdcStrategy implements Strategy { + private metadata: StrategyMetadata = { + id: "phoenix-provide-liquidity-pho-usdc", + providerId: "phoenix-pho-usdc", + name: "Provide Liquidity to PHO-USDC", + description: "Provide liquidity to the PHO-USDC pair and earn PHO rewards", + assets: [], + tvl: 0, + apr: 0, + rewardToken: { + name: "PHO", + icon: "/cryptoIcons/pho.svg", + amount: 0, + category: "phoenix", + usdValue: 0, + }, + unbondTime: 0, + category: "liquidity", + available: true, + contractAddress, + contractType, + }; + + private initialized: boolean = false; + private pairContract: PhoenixPairContract.Client | null = null; + private stakeContract: PhoenixStakeContract.Client | null = null; + private stakeContractAddress: string = ""; + private tokenA: any = null; + private tokenB: any = null; + private lpToken: any = null; + private userStake: number = 0; + private userRewards: any[] = []; + private userIndividualStakesDetailed: any[] = []; // Store raw stakes for unbonding + private lpTokenPrice: number = 0; + + constructor() { + // Initialize immediately + this.initialize(); + } + + // Async initialization method + private async initialize(): Promise { + try { + // Initialize contract clients + this.pairContract = new PhoenixPairContract.Client({ + contractId: contractAddress, + networkPassphrase: constants.NETWORK_PASSPHRASE, + rpcUrl: constants.RPC_URL, + publicKey: usePersistStore.getState().wallet.address, + signTransaction: (tx: string) => new Signer().sign(tx), + }); + + // Fetch real-time data for the strategy + await this.fetchPoolDetails(); + + // Mark as initialized + this.initialized = true; + console.log("PhoenixBoostStrategy initialized successfully"); + } catch (error) { + console.error("Failed to initialize PhoenixBoostStrategy:", error); + } + } + + private async fetchPoolDetails(): Promise { + try { + const store = useAppStore.getState(); + const storePersist = usePersistStore.getState(); + + // Fetch pool config and info from chain + const [pairConfig, pairInfo] = await Promise.all([ + this.pairContract?.query_config(), + this.pairContract?.query_pool_info(), + ]); + + if (pairConfig?.result && pairInfo?.result) { + // Set stake contract address and instantiate client + this.stakeContractAddress = pairConfig.result.stake_contract.toString(); + this.stakeContract = new PhoenixStakeContract.Client({ + contractId: this.stakeContractAddress, + networkPassphrase: constants.NETWORK_PASSPHRASE, + rpcUrl: constants.RPC_URL, + signTransaction: (tx: string) => new Signer().sign(tx), + publicKey: storePersist.wallet.address, + }); + + // Fetch token info + const [_tokenA, _tokenB, _lpToken] = await Promise.all([ + store.fetchTokenInfo(pairConfig.result.token_a), + store.fetchTokenInfo(pairConfig.result.token_b), + store.fetchTokenInfo(pairConfig.result.share_token, true), + ]); + + this.tokenA = _tokenA; + this.tokenB = _tokenB; + this.lpToken = _lpToken; + + // Fetch token prices and calculate TVL + const [priceA, priceB] = await Promise.all([ + API.getPrice(_tokenA?.symbol || ""), + API.getPrice(_tokenB?.symbol || ""), + ]); + + // Calculate pool TVL + const tvl = + (priceA * Number(pairInfo.result.asset_a.amount)) / + 10 ** Number(_tokenA?.decimals) + + (priceB * Number(pairInfo.result.asset_b.amount)) / + 10 ** Number(_tokenB?.decimals); + + this.metadata.tvl = tvl; + + // Calculate APR based on incentives + const stakingInfoA = await this.stakeContract.query_total_staked({ + simulate: false, + }); + const stakingInfo = await stakingInfoA.simulate({ restore: true }); + const totalStaked = Number(stakingInfo?.result); + + const ratioStaked = + totalStaked / Number(pairInfo.result.asset_lp_share.amount); + const valueStaked = tvl * ratioStaked; + + // Apply the same pool incentives logic + const poolIncentives = [ + { + // XLM / USDC + address: "CBHCRSVX3ZZ7EGTSYMKPEFGZNWRVCSESQR3UABET4MIW52N4EVU6BIZX", + amount: 12500, + }, + // XLM/PHO + { + address: "CBCZGGNOEUZG4CAAE7TGTQQHETZMKUT4OIPFHHPKEUX46U4KXBBZ3GLH", + amount: 25000, + }, + { + // PHO/USDC + address: "CD5XNKK3B6BEF2N7ULNHHGAMOKZ7P6456BFNIHRF4WNTEDKBRWAE7IAA", + amount: 18750, + }, + ]; + + const poolIncentive = poolIncentives.find( + (incentive) => incentive.address === contractAddress + )!; + + const phoprice = await fetchPho(); + const apr = + ((poolIncentive?.amount * phoprice) / valueStaked) * 100 * 6; + + this.metadata.apr = apr / 100; + + // Update reward token USD value + this.metadata.rewardToken.usdValue = phoprice; + + // Calculate LP token price for value conversion + this.lpTokenPrice = valueStaked / (totalStaked / 10 ** 7); + + // Set assets in metadata + this.metadata.assets = [ + { + name: _tokenA?.symbol!, + icon: `/cryptoIcons/${_tokenA?.symbol.toLowerCase()}.svg`, + usdValue: priceA, + amount: + Number(pairInfo.result.asset_a.amount) / + 10 ** Number(_tokenA?.decimals), + category: "phoenix", + }, + { + name: _tokenB?.symbol!, + icon: `/cryptoIcons/${_tokenB?.symbol.toLowerCase()}.svg`, + usdValue: priceB, + amount: + Number(pairInfo.result.asset_b.amount) / + 10 ** Number(_tokenB?.decimals), + category: "phoenix", + }, + ]; + + // If wallet connected, fetch user stake and rewards + if (storePersist.wallet.address) { + await this.fetchUserPosition(storePersist.wallet.address); + } + } + } catch (error) { + console.error("Error fetching pool details:", error); + } + } + + private async fetchUserPosition(walletAddress: string): Promise { + try { + if (!this.stakeContract) return; + + // Get user stakes + const stakesQuery = await this.stakeContract.query_staked( + { address: walletAddress }, + { simulate: false } + ); + + const stakesResult = await stakesQuery.simulate({ restore: true }); + this.userIndividualStakesDetailed = stakesResult?.result?.stakes || []; // Store raw stakes + + // Calculate total staked amount and populate userIndividualStakes for metadata + if (this.userIndividualStakesDetailed.length > 0) { + let totalLpStaked = BigInt(0); + this.metadata.userIndividualStakes = + this.userIndividualStakesDetailed.map((stake: any) => { + const lpAmountBigInt = BigInt(stake.stake); + totalLpStaked += lpAmountBigInt; + const lpAmountNumber = + Number(lpAmountBigInt) / 10 ** (this.lpToken?.decimals || 7); + return { + lpAmount: lpAmountBigInt, + timestamp: BigInt(stake.stake_timestamp), + displayAmount: `${lpAmountNumber.toFixed( + this.lpToken?.decimals || 7 + )} LP`, + displayDate: new Date( + Number(stake.stake_timestamp) * 1000 + ).toLocaleDateString(), + }; + }); + const totalLpStakedNumber = + Number(totalLpStaked) / 10 ** (this.lpToken?.decimals || 7); + this.userStake = this.lpTokenPrice * totalLpStakedNumber; + } else { + this.userStake = 0; + this.metadata.userIndividualStakes = []; + } + + // Fetch user rewards + const rewards = await this.stakeContract.query_withdrawable_rewards({ + user: walletAddress, + }); + + if (rewards?.result?.rewards) { + const store = useAppStore.getState(); + const rewardPromises = rewards.result.rewards.map( + async (reward: any) => { + const token = await store.fetchTokenInfo(reward.reward_address); + return { + name: token?.symbol.toUpperCase(), + icon: `/cryptoIcons/${token?.symbol.toLowerCase()}.svg`, + usdValue: await API.getPrice(token?.symbol || ""), + amount: + Number(reward.reward_amount.toString()) / + 10 ** token?.decimals!, + category: "phoenix", + }; + } + ); + this.userRewards = await Promise.all(rewardPromises); + } + } catch (error) { + console.error("Error fetching user position:", error); + } + } + + async getMetadata(): Promise { + // If not initialized yet, wait for initialization + if (!this.initialized) { + await this.waitForInitialization(); + } + return this.metadata; + } + + private async waitForInitialization(timeout = 5000): Promise { + const start = Date.now(); + while (!this.initialized && Date.now() - start < timeout) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + if (!this.initialized) { + console.warn( + "Strategy initialization timed out, returning potentially incomplete metadata" + ); + } + } + + async getUserStake(walletAddress: string): Promise { + if (walletAddress !== usePersistStore.getState().wallet.address) { + // If asking for a different wallet, fetch specific data + await this.fetchUserPosition(walletAddress); + } + return this.userStake; + } + + async hasUserJoined(walletAddress: string): Promise { + return (await this.getUserStake(walletAddress)) > 0; + } + + async getUserRewards(walletAddress: string): Promise { + if (walletAddress !== usePersistStore.getState().wallet.address) { + // If asking for a different wallet, fetch specific data + await this.fetchUserPosition(walletAddress); + } + + // Sum up all rewards + return this.userRewards.reduce( + (total: number, reward: any) => total + reward.amount, + 0 + ); + } + + // Update the bond method to handle multiple tokens for liquidity provision + async bond( + walletAddress: string, + amountA: number, + amountB?: number + ): Promise> { + if (amountB === undefined) { + throw new Error( + "Amount B is required for providing liquidity to this pair." + ); + } + // For liquidity pairs, we expect both amounts + return this.provideLiquidity(walletAddress, amountA, amountB); + } + + async unbond( + walletAddress: string, + params: { lpAmount: bigint; timestamp: bigint } + ): Promise> { + if (!this.stakeContract) { + throw new Error("Stake contract not initialized"); + } + + const assembledTx = this.removeLiquidity( + walletAddress, + Number(params.lpAmount), + { + lpAmount: params.lpAmount, + timestamp: params.timestamp, + } + ); + + return assembledTx; + } + + async claim(walletAddress: string): Promise> { + if (!this.stakeContract) { + throw new Error("Stake contract not initialized"); + } + const assembledTx = await this.stakeContract.withdraw_rewards( + { sender: walletAddress }, + { simulate: true } + ); + return assembledTx; + } + + // Helper method to provide liquidity directly through this strategy + async provideLiquidity( + walletAddress: string, + tokenAAmount: number, + tokenBAmount: number + ): Promise> { + if (!this.pairContract) { + throw new Error("Pair contract not initialized"); + } + const assembledTx = await this.pairContract.provide_liquidity( + { + sender: walletAddress, + desired_a: BigInt( + (tokenAAmount * 10 ** (this.tokenA?.decimals || 7)).toFixed(0) + ), + desired_b: BigInt( + (tokenBAmount * 10 ** (this.tokenB?.decimals || 7)).toFixed(0) + ), + min_a: undefined, + min_b: undefined, + custom_slippage_bps: undefined, + deadline: undefined, + auto_stake: true, + }, + { simulate: true } + ); + return assembledTx; + } + + // Helper method to remove liquidity + async removeLiquidity( + walletAddress: string, + lpAmount: number, + stakeBucket: { lpAmount: bigint; timestamp: bigint } + ): Promise> { + if (!this.pairContract) { + throw new Error("Pair contract not initialized"); + } + const assembledTx = await this.pairContract.withdraw_liquidity( + { + sender: walletAddress, + share_amount: BigInt(lpAmount), + min_a: BigInt(1), + min_b: BigInt(1), + deadline: undefined, + auto_unstake: { + stake_amount: stakeBucket.lpAmount, + stake_timestamp: stakeBucket.timestamp, + }, + }, + { simulate: true } + ); + return assembledTx; + } +} + +export default PhoenixPhoUsdcStrategy; diff --git a/packages/strategies/src/phoenix/strategies/xlm-pho.liquidity.ts b/packages/strategies/src/phoenix/strategies/xlm-pho.liquidity.ts new file mode 100644 index 00000000..0606d22d --- /dev/null +++ b/packages/strategies/src/phoenix/strategies/xlm-pho.liquidity.ts @@ -0,0 +1,432 @@ +import { Strategy, StrategyMetadata } from "../../types"; +import { useAppStore, usePersistStore } from "@phoenix-protocol/state"; +import { + API, + constants, + fetchTokenPrices, + Signer, +} from "@phoenix-protocol/utils"; +import { + PhoenixPairContract, + PhoenixStakeContract, + fetchPho, +} from "@phoenix-protocol/contracts"; +import { AssembledTransaction } from "@stellar/stellar-sdk/lib/contract"; + +// Needed constants and types +const contractAddress = + "CBCZGGNOEUZG4CAAE7TGTQQHETZMKUT4OIPFHHPKEUX46U4KXBBZ3GLH"; +const contractType = "pair"; + +class PhoenixXlmPhoStrategy implements Strategy { + private metadata: StrategyMetadata = { + id: "phoenix-provide-liquidity-xlm-pho", + providerId: "phoenix-xlm-pho", + name: "Provide Liquidity to XLM-PHO", + description: "Provide liquidity to the XLM-PHO pair and earn PHO rewards", + assets: [], + tvl: 0, + apr: 0, + rewardToken: { + name: "PHO", + icon: "/cryptoIcons/pho.svg", + amount: 0, + category: "phoenix", + usdValue: 0, + }, + unbondTime: 0, + category: "liquidity", + available: true, + contractAddress, + contractType, + }; + + private initialized: boolean = false; + private pairContract: PhoenixPairContract.Client | null = null; + private stakeContract: PhoenixStakeContract.Client | null = null; + private stakeContractAddress: string = ""; + private tokenA: any = null; + private tokenB: any = null; + private lpToken: any = null; + private userStake: number = 0; + private userRewards: any[] = []; + private userIndividualStakesDetailed: any[] = []; // Store raw stakes for unbonding + private lpTokenPrice: number = 0; + + constructor() { + // Initialize immediately + this.initialize(); + } + + // Async initialization method + private async initialize(): Promise { + try { + // Initialize contract clients + this.pairContract = new PhoenixPairContract.Client({ + contractId: contractAddress, + networkPassphrase: constants.NETWORK_PASSPHRASE, + rpcUrl: constants.RPC_URL, + publicKey: usePersistStore.getState().wallet.address, + signTransaction: (tx: string) => new Signer().sign(tx), + }); + + // Fetch real-time data for the strategy + await this.fetchPoolDetails(); + + // Mark as initialized + this.initialized = true; + console.log("PhoenixBoostStrategy initialized successfully"); + } catch (error) { + console.error("Failed to initialize PhoenixBoostStrategy:", error); + } + } + + private async fetchPoolDetails(): Promise { + try { + const store = useAppStore.getState(); + const storePersist = usePersistStore.getState(); + + // Fetch pool config and info from chain + const [pairConfig, pairInfo] = await Promise.all([ + this.pairContract?.query_config(), + this.pairContract?.query_pool_info(), + ]); + + if (pairConfig?.result && pairInfo?.result) { + // Set stake contract address and instantiate client + this.stakeContractAddress = pairConfig.result.stake_contract.toString(); + this.stakeContract = new PhoenixStakeContract.Client({ + contractId: this.stakeContractAddress, + networkPassphrase: constants.NETWORK_PASSPHRASE, + rpcUrl: constants.RPC_URL, + signTransaction: (tx: string) => new Signer().sign(tx), + publicKey: storePersist.wallet.address, + }); + + // Fetch token info + const [_tokenA, _tokenB, _lpToken] = await Promise.all([ + store.fetchTokenInfo(pairConfig.result.token_a), + store.fetchTokenInfo(pairConfig.result.token_b), + store.fetchTokenInfo(pairConfig.result.share_token, true), + ]); + + this.tokenA = _tokenA; + this.tokenB = _tokenB; + this.lpToken = _lpToken; + + // Fetch token prices and calculate TVL + const [priceA, priceB] = await Promise.all([ + API.getPrice(_tokenA?.symbol || ""), + API.getPrice(_tokenB?.symbol || ""), + ]); + + // Calculate pool TVL + const tvl = + (priceA * Number(pairInfo.result.asset_a.amount)) / + 10 ** Number(_tokenA?.decimals) + + (priceB * Number(pairInfo.result.asset_b.amount)) / + 10 ** Number(_tokenB?.decimals); + + this.metadata.tvl = tvl; + + // Calculate APR based on incentives + const stakingInfoA = await this.stakeContract.query_total_staked({ + simulate: false, + }); + const stakingInfo = await stakingInfoA.simulate({ restore: true }); + const totalStaked = Number(stakingInfo?.result); + + const ratioStaked = + totalStaked / Number(pairInfo.result.asset_lp_share.amount); + const valueStaked = tvl * ratioStaked; + + // Apply the same pool incentives logic + const poolIncentives = [ + { + // XLM / USDC + address: "CBHCRSVX3ZZ7EGTSYMKPEFGZNWRVCSESQR3UABET4MIW52N4EVU6BIZX", + amount: 12500, + }, + // XLM/PHO + { + address: "CBCZGGNOEUZG4CAAE7TGTQQHETZMKUT4OIPFHHPKEUX46U4KXBBZ3GLH", + amount: 25000, + }, + { + // PHO/USDC + address: "CD5XNKK3B6BEF2N7ULNHHGAMOKZ7P6456BFNIHRF4WNTEDKBRWAE7IAA", + amount: 18750, + }, + ]; + + const poolIncentive = poolIncentives.find( + (incentive) => incentive.address === contractAddress + )!; + + const phoprice = await fetchPho(); + const apr = + ((poolIncentive?.amount * phoprice) / valueStaked) * 100 * 6; + + this.metadata.apr = apr / 100; + + // Update reward token USD value + this.metadata.rewardToken.usdValue = phoprice; + + // Calculate LP token price for value conversion + this.lpTokenPrice = valueStaked / (totalStaked / 10 ** 7); + + // Set assets in metadata + this.metadata.assets = [ + { + name: _tokenA?.symbol!, + icon: `/cryptoIcons/${_tokenA?.symbol.toLowerCase()}.svg`, + usdValue: priceA, + amount: + Number(pairInfo.result.asset_a.amount) / + 10 ** Number(_tokenA?.decimals), + category: "phoenix", + }, + { + name: _tokenB?.symbol!, + icon: `/cryptoIcons/${_tokenB?.symbol.toLowerCase()}.svg`, + usdValue: priceB, + amount: + Number(pairInfo.result.asset_b.amount) / + 10 ** Number(_tokenB?.decimals), + category: "phoenix", + }, + ]; + + // If wallet connected, fetch user stake and rewards + if (storePersist.wallet.address) { + await this.fetchUserPosition(storePersist.wallet.address); + } + } + } catch (error) { + console.error("Error fetching pool details:", error); + } + } + + private async fetchUserPosition(walletAddress: string): Promise { + try { + if (!this.stakeContract) return; + + // Get user stakes + const stakesQuery = await this.stakeContract.query_staked( + { address: walletAddress }, + { simulate: false } + ); + + const stakesResult = await stakesQuery.simulate({ restore: true }); + this.userIndividualStakesDetailed = stakesResult?.result?.stakes || []; // Store raw stakes + + // Calculate total staked amount and populate userIndividualStakes for metadata + if (this.userIndividualStakesDetailed.length > 0) { + let totalLpStaked = BigInt(0); + this.metadata.userIndividualStakes = + this.userIndividualStakesDetailed.map((stake: any) => { + const lpAmountBigInt = BigInt(stake.stake); + totalLpStaked += lpAmountBigInt; + const lpAmountNumber = + Number(lpAmountBigInt) / 10 ** (this.lpToken?.decimals || 7); + return { + lpAmount: lpAmountBigInt, + timestamp: BigInt(stake.stake_timestamp), + displayAmount: `${lpAmountNumber.toFixed( + this.lpToken?.decimals || 7 + )} LP`, + displayDate: new Date( + Number(stake.stake_timestamp) * 1000 + ).toLocaleDateString(), + }; + }); + const totalLpStakedNumber = + Number(totalLpStaked) / 10 ** (this.lpToken?.decimals || 7); + this.userStake = this.lpTokenPrice * totalLpStakedNumber; + } else { + this.userStake = 0; + this.metadata.userIndividualStakes = []; + } + + // Fetch user rewards + const rewards = await this.stakeContract.query_withdrawable_rewards({ + user: walletAddress, + }); + + if (rewards?.result?.rewards) { + const store = useAppStore.getState(); + const rewardPromises = rewards.result.rewards.map( + async (reward: any) => { + const token = await store.fetchTokenInfo(reward.reward_address); + return { + name: token?.symbol.toUpperCase(), + icon: `/cryptoIcons/${token?.symbol.toLowerCase()}.svg`, + usdValue: await API.getPrice(token?.symbol || ""), + amount: + Number(reward.reward_amount.toString()) / + 10 ** token?.decimals!, + category: "phoenix", + }; + } + ); + + this.userRewards = await Promise.all(rewardPromises); + } + } catch (error) { + console.error("Error fetching user position:", error); + } + } + + async getMetadata(): Promise { + // If not initialized yet, wait for initialization + if (!this.initialized) { + await this.waitForInitialization(); + } + return this.metadata; + } + + private async waitForInitialization(timeout = 5000): Promise { + const start = Date.now(); + while (!this.initialized && Date.now() - start < timeout) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + if (!this.initialized) { + console.warn( + "Strategy initialization timed out, returning potentially incomplete metadata" + ); + } + } + + async getUserStake(walletAddress: string): Promise { + if (walletAddress !== usePersistStore.getState().wallet.address) { + // If asking for a different wallet, fetch specific data + await this.fetchUserPosition(walletAddress); + } + return this.userStake; + } + + async hasUserJoined(walletAddress: string): Promise { + return (await this.getUserStake(walletAddress)) > 0; + } + + async getUserRewards(walletAddress: string): Promise { + if (walletAddress !== usePersistStore.getState().wallet.address) { + // If asking for a different wallet, fetch specific data + await this.fetchUserPosition(walletAddress); + } + + // Sum up all rewards + return this.userRewards.reduce( + (total: number, reward: any) => total + reward.amount, + 0 + ); + } + + // Update the bond method to handle multiple tokens for liquidity provision + async bond( + walletAddress: string, + amountA: number, + amountB?: number + ): Promise> { + if (amountB === undefined) { + throw new Error( + "Amount B is required for providing liquidity to this pair." + ); + } + // For liquidity pairs, we expect both amounts + return this.provideLiquidity(walletAddress, amountA, amountB); + } + + async unbond( + walletAddress: string, + params: { lpAmount: bigint; timestamp: bigint } + ): Promise> { + if (!this.stakeContract) { + throw new Error("Stake contract not initialized"); + } + + const assembledTx = this.removeLiquidity( + walletAddress, + Number(params.lpAmount), + { + lpAmount: params.lpAmount, + timestamp: params.timestamp, + } + ); + + return assembledTx; + } + + async claim(walletAddress: string): Promise> { + if (!this.stakeContract) { + throw new Error("Stake contract not initialized"); + } + + const assembledTx = await this.stakeContract.withdraw_rewards( + { sender: walletAddress }, + { simulate: true } + ); + + return assembledTx; + } + + // Helper method to provide liquidity directly through this strategy + async provideLiquidity( + walletAddress: string, + tokenAAmount: number, + tokenBAmount: number + ): Promise> { + if (!this.pairContract) { + throw new Error("Pair contract not initialized"); + } + const assembledTx = await this.pairContract.provide_liquidity( + { + sender: walletAddress, + desired_a: BigInt( + (tokenAAmount * 10 ** (this.tokenA?.decimals || 7)).toFixed(0) + ), + desired_b: BigInt( + (tokenBAmount * 10 ** (this.tokenB?.decimals || 7)).toFixed(0) + ), + min_a: undefined, + min_b: undefined, + custom_slippage_bps: undefined, + deadline: undefined, + auto_stake: true, // Automatically stake LP tokens + }, + { simulate: true } + ); + + return assembledTx; + } + + // Helper method to remove liquidity + async removeLiquidity( + walletAddress: string, + lpAmount: number, + stakeBucket: { lpAmount: bigint; timestamp: bigint } + ): Promise> { + if (!this.pairContract) { + throw new Error("Pair contract not initialized"); + } + const assembledTx = await this.pairContract.withdraw_liquidity( + { + sender: walletAddress, + share_amount: BigInt( + (lpAmount * 10 ** (this.lpToken?.decimals || 7)).toFixed(0) + ), + min_a: BigInt(1), + min_b: BigInt(1), + deadline: undefined, + auto_unstake: { + stake_amount: stakeBucket.lpAmount, + stake_timestamp: stakeBucket.timestamp, + }, + }, + { simulate: true } + ); + return assembledTx; + } +} + +export default PhoenixXlmPhoStrategy; diff --git a/packages/strategies/src/phoenix/strategies/xlm-usdc.liquidity.ts b/packages/strategies/src/phoenix/strategies/xlm-usdc.liquidity.ts new file mode 100644 index 00000000..29a321dd --- /dev/null +++ b/packages/strategies/src/phoenix/strategies/xlm-usdc.liquidity.ts @@ -0,0 +1,427 @@ +import { Strategy, StrategyMetadata } from "../../types"; +import { useAppStore, usePersistStore } from "@phoenix-protocol/state"; +import { + API, + constants, + fetchTokenPrices, + Signer, +} from "@phoenix-protocol/utils"; +import { + PhoenixPairContract, + PhoenixStakeContract, + fetchPho, +} from "@phoenix-protocol/contracts"; +import { AssembledTransaction } from "@stellar/stellar-sdk/lib/contract"; + +// Needed constants and types +const contractAddress = + "CBHCRSVX3ZZ7EGTSYMKPEFGZNWRVCSESQR3UABET4MIW52N4EVU6BIZX"; +const contractType = "pair"; + +class PhoenixXlmUsdcStrategy implements Strategy { + private metadata: StrategyMetadata = { + id: "phoenix-provide-liquidity-xlm-usdc", + providerId: "phoenix-xlm-usdc", + name: "Provide Liquidity to XLM-USDC", + description: "Provide liquidity to the XLM-USDC pair and earn PHO rewards", + assets: [], + tvl: 0, + apr: 0, + rewardToken: { + name: "PHO", + icon: "/cryptoIcons/pho.svg", + amount: 0, + category: "phoenix", + usdValue: 0, + }, + unbondTime: 0, + category: "liquidity", + available: true, + contractAddress, + contractType, + }; + + private initialized: boolean = false; + private pairContract: PhoenixPairContract.Client | null = null; + private stakeContract: PhoenixStakeContract.Client | null = null; + private stakeContractAddress: string = ""; + private tokenA: any = null; + private tokenB: any = null; + private lpToken: any = null; + private userStake: number = 0; + private userRewards: any[] = []; + private userIndividualStakesDetailed: any[] = []; // Store raw stakes for unbonding + private lpTokenPrice: number = 0; + + constructor() { + // Initialize immediately + this.initialize(); + } + + // Async initialization method + private async initialize(): Promise { + try { + // Initialize contract clients + this.pairContract = new PhoenixPairContract.Client({ + contractId: contractAddress, + networkPassphrase: constants.NETWORK_PASSPHRASE, + rpcUrl: constants.RPC_URL, + publicKey: usePersistStore.getState().wallet.address, + signTransaction: (tx: string) => new Signer().sign(tx), + }); + + // Fetch real-time data for the strategy + await this.fetchPoolDetails(); + + // Mark as initialized + this.initialized = true; + console.log("PhoenixBoostStrategy initialized successfully"); + } catch (error) { + console.error("Failed to initialize PhoenixBoostStrategy:", error); + } + } + + private async fetchPoolDetails(): Promise { + try { + const store = useAppStore.getState(); + const storePersist = usePersistStore.getState(); + + // Fetch pool config and info from chain + const [pairConfig, pairInfo] = await Promise.all([ + this.pairContract?.query_config(), + this.pairContract?.query_pool_info(), + ]); + + if (pairConfig?.result && pairInfo?.result) { + // Set stake contract address and instantiate client + this.stakeContractAddress = pairConfig.result.stake_contract.toString(); + this.stakeContract = new PhoenixStakeContract.Client({ + contractId: this.stakeContractAddress, + networkPassphrase: constants.NETWORK_PASSPHRASE, + rpcUrl: constants.RPC_URL, + signTransaction: (tx: string) => new Signer().sign(tx), + publicKey: storePersist.wallet.address, + }); + + // Fetch token info + const [_tokenA, _tokenB, _lpToken] = await Promise.all([ + store.fetchTokenInfo(pairConfig.result.token_a), + store.fetchTokenInfo(pairConfig.result.token_b), + store.fetchTokenInfo(pairConfig.result.share_token, true), + ]); + + this.tokenA = _tokenA; + this.tokenB = _tokenB; + this.lpToken = _lpToken; + + // Fetch token prices and calculate TVL + const [priceA, priceB] = await Promise.all([ + API.getPrice(_tokenA?.symbol || ""), + API.getPrice(_tokenB?.symbol || ""), + ]); + + // Calculate pool TVL + const tvl = + (priceA * Number(pairInfo.result.asset_a.amount)) / + 10 ** Number(_tokenA?.decimals) + + (priceB * Number(pairInfo.result.asset_b.amount)) / + 10 ** Number(_tokenB?.decimals); + + this.metadata.tvl = tvl; + + // Calculate APR based on incentives + const stakingInfoA = await this.stakeContract.query_total_staked({ + simulate: false, + }); + const stakingInfo = await stakingInfoA.simulate({ restore: true }); + const totalStaked = Number(stakingInfo?.result); + + const ratioStaked = + totalStaked / Number(pairInfo.result.asset_lp_share.amount); + const valueStaked = tvl * ratioStaked; + + // Apply the same pool incentives logic + const poolIncentives = [ + { + // XLM / USDC + address: "CBHCRSVX3ZZ7EGTSYMKPEFGZNWRVCSESQR3UABET4MIW52N4EVU6BIZX", + amount: 12500, + }, + // XLM/PHO + { + address: "CBCZGGNOEUZG4CAAE7TGTQQHETZMKUT4OIPFHHPKEUX46U4KXBBZ3GLH", + amount: 25000, + }, + { + // PHO/USDC + address: "CD5XNKK3B6BEF2N7ULNHHGAMOKZ7P6456BFNIHRF4WNTEDKBRWAE7IAA", + amount: 18750, + }, + ]; + + const poolIncentive = poolIncentives.find( + (incentive) => incentive.address === contractAddress + )!; + + const phoprice = await fetchPho(); + const apr = + ((poolIncentive?.amount * phoprice) / valueStaked) * 100 * 6; + + this.metadata.apr = apr / 100; + + // Update reward token USD value + this.metadata.rewardToken.usdValue = phoprice; + + // Calculate LP token price for value conversion + this.lpTokenPrice = valueStaked / (totalStaked / 10 ** 7); + + // Set assets in metadata + this.metadata.assets = [ + { + name: _tokenA?.symbol!, + icon: `/cryptoIcons/${_tokenA?.symbol.toLowerCase()}.svg`, + usdValue: priceA, + amount: + Number(pairInfo.result.asset_a.amount) / + 10 ** Number(_tokenA?.decimals), + category: "phoenix", + }, + { + name: _tokenB?.symbol!, + icon: `/cryptoIcons/${_tokenB?.symbol.toLowerCase()}.svg`, + usdValue: priceB, + amount: + Number(pairInfo.result.asset_b.amount) / + 10 ** Number(_tokenB?.decimals), + category: "phoenix", + }, + ]; + + // If wallet connected, fetch user stake and rewards + if (storePersist.wallet.address) { + await this.fetchUserPosition(storePersist.wallet.address); + } + } + } catch (error) { + console.error("Error fetching pool details:", error); + } + } + + private async fetchUserPosition(walletAddress: string): Promise { + try { + if (!this.stakeContract) return; + + // Get user stakes + const stakesQuery = await this.stakeContract.query_staked( + { address: walletAddress }, + { simulate: false } + ); + + const stakesResult = await stakesQuery.simulate({ restore: true }); + this.userIndividualStakesDetailed = stakesResult?.result?.stakes || []; // Store raw stakes + + // Calculate total staked amount and populate userIndividualStakes for metadata + if (this.userIndividualStakesDetailed.length > 0) { + let totalLpStaked = BigInt(0); + this.metadata.userIndividualStakes = + this.userIndividualStakesDetailed.map((stake: any) => { + const lpAmountBigInt = BigInt(stake.stake); + totalLpStaked += lpAmountBigInt; + const lpAmountNumber = + Number(lpAmountBigInt) / 10 ** (this.lpToken?.decimals || 7); + return { + lpAmount: lpAmountBigInt, + timestamp: BigInt(stake.stake_timestamp), + displayAmount: `${lpAmountNumber.toFixed( + this.lpToken?.decimals || 7 + )} LP`, + displayDate: new Date( + Number(stake.stake_timestamp) * 1000 + ).toLocaleDateString(), + }; + }); + const totalLpStakedNumber = + Number(totalLpStaked) / 10 ** (this.lpToken?.decimals || 7); + this.userStake = this.lpTokenPrice * totalLpStakedNumber; + } else { + this.userStake = 0; + this.metadata.userIndividualStakes = []; + } + + // Fetch user rewards + const rewards = await this.stakeContract.query_withdrawable_rewards({ + user: walletAddress, + }); + + if (rewards?.result?.rewards) { + const store = useAppStore.getState(); + const rewardPromises = rewards.result.rewards.map( + async (reward: any) => { + const token = await store.fetchTokenInfo(reward.reward_address); + return { + name: token?.symbol.toUpperCase(), + icon: `/cryptoIcons/${token?.symbol.toLowerCase()}.svg`, + usdValue: await API.getPrice(token?.symbol || ""), + amount: + Number(reward.reward_amount.toString()) / + 10 ** token?.decimals!, + category: "phoenix", + }; + } + ); + + this.userRewards = await Promise.all(rewardPromises); + } + } catch (error) { + console.error("Error fetching user position:", error); + } + } + + async getMetadata(): Promise { + // If not initialized yet, wait for initialization + if (!this.initialized) { + await this.waitForInitialization(); + } + return this.metadata; + } + + private async waitForInitialization(timeout = 5000): Promise { + const start = Date.now(); + while (!this.initialized && Date.now() - start < timeout) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + if (!this.initialized) { + console.warn( + "Strategy initialization timed out, returning potentially incomplete metadata" + ); + } + } + + async getUserStake(walletAddress: string): Promise { + if (walletAddress !== usePersistStore.getState().wallet.address) { + // If asking for a different wallet, fetch specific data + await this.fetchUserPosition(walletAddress); + } + return this.userStake; + } + + async hasUserJoined(walletAddress: string): Promise { + return (await this.getUserStake(walletAddress)) > 0; + } + + async getUserRewards(walletAddress: string): Promise { + if (walletAddress !== usePersistStore.getState().wallet.address) { + // If asking for a different wallet, fetch specific data + await this.fetchUserPosition(walletAddress); + } + + // Sum up all rewards + return this.userRewards.reduce( + (total: number, reward: any) => total + reward.amount, + 0 + ); + } + + // Update the bond method to handle multiple tokens for liquidity provision + async bond( + walletAddress: string, + amountA: number, + amountB?: number + ): Promise> { + if (amountB === undefined) { + throw new Error( + "Amount B is required for providing liquidity to this pair." + ); + } + // For liquidity pairs, we expect both amounts + return this.provideLiquidity(walletAddress, amountA, amountB); + } + + async unbond( + walletAddress: string, + params: { lpAmount: bigint; timestamp: bigint } + ): Promise> { + if (!this.stakeContract) { + throw new Error("Stake contract not initialized"); + } + + const assembledTx = this.removeLiquidity( + walletAddress, + Number(params.lpAmount), + { + lpAmount: params.lpAmount, + timestamp: params.timestamp, + } + ); + + return assembledTx; + } + + async claim(walletAddress: string): Promise> { + if (!this.stakeContract) { + throw new Error("Stake contract not initialized"); + } + const assembledTx = await this.stakeContract.withdraw_rewards( + { sender: walletAddress }, + { simulate: true } + ); + return assembledTx; + } + + // Helper method to provide liquidity directly through this strategy + async provideLiquidity( + walletAddress: string, + tokenAAmount: number, + tokenBAmount: number + ): Promise> { + if (!this.pairContract) { + throw new Error("Pair contract not initialized"); + } + const assembledTx = await this.pairContract.provide_liquidity( + { + sender: walletAddress, + desired_a: BigInt( + (tokenAAmount * 10 ** (this.tokenA?.decimals || 7)).toFixed(0) + ), + desired_b: BigInt( + (tokenBAmount * 10 ** (this.tokenB?.decimals || 7)).toFixed(0) + ), + min_a: undefined, + min_b: undefined, + custom_slippage_bps: undefined, + deadline: undefined, + auto_stake: true, + }, + { simulate: true } + ); + return assembledTx; + } + + // Helper method to remove liquidity + async removeLiquidity( + walletAddress: string, + lpAmount: number, + stakeBucket: { lpAmount: bigint; timestamp: bigint } + ): Promise> { + if (!this.pairContract) { + throw new Error("Pair contract not initialized"); + } + const assembledTx = await this.pairContract.withdraw_liquidity( + { + sender: walletAddress, + share_amount: BigInt(lpAmount), + min_a: BigInt(1), + min_b: BigInt(1), + deadline: undefined, + auto_unstake: { + stake_amount: stakeBucket.lpAmount, + stake_timestamp: stakeBucket.timestamp, + }, + }, + { simulate: true } + ); + return assembledTx; + } +} + +export default PhoenixXlmUsdcStrategy; diff --git a/packages/strategies/src/registry.ts b/packages/strategies/src/registry.ts new file mode 100644 index 00000000..a3146305 --- /dev/null +++ b/packages/strategies/src/registry.ts @@ -0,0 +1,88 @@ +import { StrategyProvider, Strategy, StrategyMetadata } from "./types"; + +export class StrategyRegistry { + private static providers: Map = new Map(); + private static strategiesCache: Map = new Map(); + private static metadataCache: Map = new Map(); + + static registerProvider(provider: StrategyProvider): void { + if (this.providers.has(provider.id)) { + console.warn( + `Provider with ID ${provider.id} is already registered. Skipping.` + ); + return; + } + this.providers.set(provider.id, provider); + } + + static getProvider(id: string): StrategyProvider | undefined { + return this.providers.get(id); + } + + static getProviders(): StrategyProvider[] { + return Array.from(this.providers.values()); + } + + static async getProviderTVL(providerId: string): Promise { + const provider = this.providers.get(providerId); + if (!provider) return 0; + return provider.getTVL(); + } + + static async getTotalTVL(): Promise { + const allProviders = this.getProviders(); + const tvls = await Promise.all( + allProviders.map((provider) => provider.getTVL()) + ); + return tvls.reduce((total, current) => total + current, 0); + } + + static async getStrategiesByProvider( + providerId: string + ): Promise { + const provider = this.providers.get(providerId); + if (!provider) return []; + + if (!this.strategiesCache.has(providerId)) { + const strategies = await provider.getStrategies(); + this.strategiesCache.set(providerId, strategies); + } + + return this.strategiesCache.get(providerId) || []; + } + + static async getAllStrategies(): Promise { + const allProviders = this.getProviders(); + const strategiesArrays = await Promise.all( + allProviders.map((provider) => this.getStrategiesByProvider(provider.id)) + ); + return strategiesArrays.flat(); + } + + static async getUserStrategies(walletAddress: string): Promise { + if (!walletAddress) return []; + + const allStrategies = await this.getAllStrategies(); + const userStrategies = await Promise.all( + allStrategies.map(async (strategy) => { + const hasJoined = await strategy.hasUserJoined(walletAddress); + return hasJoined ? strategy : null; + }) + ); + + return userStrategies.filter(Boolean) as Strategy[]; + } + + static async getStrategyMetadata( + strategy: Strategy + ): Promise { + const metadata = await strategy.getMetadata(); + this.metadataCache.set(metadata.id, metadata); + return metadata; + } + + static clearCache(): void { + this.strategiesCache.clear(); + this.metadataCache.clear(); + } +} diff --git a/packages/strategies/src/types.ts b/packages/strategies/src/types.ts new file mode 100644 index 00000000..964a2bf4 --- /dev/null +++ b/packages/strategies/src/types.ts @@ -0,0 +1,82 @@ +import { Token as BaseToken } from "@phoenix-protocol/types"; +import { AssembledTransaction } from "@stellar/stellar-sdk/lib/contract"; + +// Extend the Token interface to include address +export interface Token extends BaseToken { + address?: string; // Add address field that's used in stories +} + +// Define Contract Types - Mirroring useContractTransaction +export type ContractType = + | "pair" + | "multihop" + | "stake" + | "factory" + | "vesting" + | "token"; + +export interface StrategyProvider { + id: string; + name: string; + domain: string; + description?: string; + icon?: string; + getTVL(): Promise; + getStrategies(): Promise; +} + +// Define the structure for an individual stake/bond +export interface IndividualStake { + lpAmount: bigint; // Raw LP token amount for contract interaction + timestamp: bigint; // Raw timestamp for contract interaction + displayAmount: string; // User-friendly display of the amount (e.g., "123.45 LP") + displayDate: string; // User-friendly display of the stake date + // Potentially other identifiers or display info if needed +} + +export interface StrategyMetadata { + id: string; + providerId: string; + name: string; + description: string; + assets: Token[]; + tvl: number; + apr: number; + rewardToken: Token; + unbondTime: number; // seconds + link?: string; + category: string; + icon?: string; + available: boolean; + userStake?: number; // Amount the user has staked + userRewards?: number; // Rewards available for claiming + hasJoined?: boolean; // Whether the user has joined this strategy + // Add contract details for transactions + contractAddress: string; + contractType: ContractType; + userIndividualStakes?: IndividualStake[]; // For strategies with multiple, distinct stakes + // UI state properties + isMobile?: boolean; // For UI rendering + userAssetMatch?: boolean; // Used in StrategiesTable filtering +} + +export interface Strategy { + getMetadata(): Promise; + getUserStake(walletAddress: string): Promise; + hasUserJoined(walletAddress: string): Promise; + getUserRewards(walletAddress: string): Promise; + + // Update bond signature to support multiple token amounts + bond( + walletAddress: string, + amountA: number, + amountB?: number + ): Promise>; + + // Updated unbond signature to handle specific stakes or general amounts + unbond( + walletAddress: string, + params: number | { lpAmount: bigint; timestamp: bigint } + ): Promise>; + claim(walletAddress: string): Promise>; +} diff --git a/packages/strategies/tsconfig.json b/packages/strategies/tsconfig.json new file mode 100644 index 00000000..77a07384 --- /dev/null +++ b/packages/strategies/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "build", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/types/README.md b/packages/types/README.md new file mode 100644 index 00000000..b5a62c7a --- /dev/null +++ b/packages/types/README.md @@ -0,0 +1,41 @@ +# Types Package + +The `Types` package contains TypeScript type definitions used throughout the Phoenix Frontend ecosystem. It provides a centralized location for shared types, interfaces, and type utilities that ensure type consistency across all packages. + +## Features + +- Shared TypeScript types and interfaces +- Integration with Material UI component types +- Stellar SDK type definitions +- Type utilities for common patterns + +## Installation + +To install the Types package, follow these steps: + +1. Ensure that you have Node.js and yarn installed on your machine. +2. Navigate to the root directory of the Phoenix Frontend project. +3. Run `yarn install` to install all project dependencies. + +## Usage + +To use the Types package in your application: + +```typescript +import { SomeType, SomeInterface } from "@phoenix-protocol/types"; + +// Use the imported types in your code +const someVariable: SomeType = { + // properties according to SomeType +}; +``` + +## Development + +To work on the Types package: + +1. Make changes to the files in the `src` directory +2. Run `yarn build` or `yarn dev` to build the package +3. Test your changes by importing the package in other parts of the Phoenix Frontend + +This package is a dependency for most other packages in the Phoenix Frontend ecosystem, so changes should be made carefully to avoid breaking existing functionality. diff --git a/packages/types/package.json b/packages/types/package.json index 049013c3..f959c765 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -5,8 +5,9 @@ "build/**/*" ], "scripts": { - "test": "tsc && jest", - "build": "tsc" + "test": "echo \"Error: no test specified\" && exit 0", + "build": "tsc", + "dev": "tsc --watch" }, "main": "build/index.js", "types": "build/index.d.ts", diff --git a/packages/types/src/ui/AssetInfoModal.ts b/packages/types/src/ui/AssetInfoModal.ts index 05a4fc66..cdd2f69c 100644 --- a/packages/types/src/ui/AssetInfoModal.ts +++ b/packages/types/src/ui/AssetInfoModal.ts @@ -1,7 +1,38 @@ +import { Pool } from "./Pools"; + export interface AssetInfoModalProps { open: boolean; onClose: () => void; asset: AssetInfo; + userBalance: number; + loading: boolean; + tradingVolume7d: { + date?: { + day?: number; + month?: number; + year: number; + }; + time?: { + hour: number; + date?: { + day: number; + month: number; + year: number; + }; + }; + week?: { + week: number; + year: number; + }; + month?: { + month: number; + year: number; + }; + tokenAVolume: string; + tokenBVolume: string; + usdVolume: number; + }[]; + pools: Pool[]; } export interface AssetInfo { diff --git a/packages/types/src/ui/SlippageSettings.ts b/packages/types/src/ui/SlippageSettings.ts index 4f30f19e..2df47d66 100644 --- a/packages/types/src/ui/SlippageSettings.ts +++ b/packages/types/src/ui/SlippageSettings.ts @@ -1,6 +1,6 @@ export interface SlippageOptionsProps { - options: string[]; + options: number[]; selectedOption: number; onClose: () => void; - onChange: (option: string) => void; + onChange: (option: number) => void; } diff --git a/packages/ui/.storybook/babel.config.js b/packages/ui/.storybook/babel.config.js new file mode 100644 index 00000000..0862c0a5 --- /dev/null +++ b/packages/ui/.storybook/babel.config.js @@ -0,0 +1,12 @@ +module.exports = { + presets: [ + '@babel/preset-env', + ['@babel/preset-react', { runtime: 'automatic' }], + '@babel/preset-typescript', + ], + plugins: [ + '@babel/plugin-transform-runtime', + ['@babel/plugin-proposal-class-properties', { loose: true }], + '@babel/plugin-proposal-object-rest-spread' + ], +}; diff --git a/packages/ui/.storybook/main.ts b/packages/ui/.storybook/main.ts index 90052ff7..78f16d7f 100644 --- a/packages/ui/.storybook/main.ts +++ b/packages/ui/.storybook/main.ts @@ -1,38 +1,46 @@ import type { StorybookConfig } from "@storybook/react-webpack5"; +import { join, dirname } from "path"; +import * as path from 'path'; const config: StorybookConfig = { - stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"], + stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], addons: [ "@storybook/addon-links", "@storybook/addon-essentials", "@storybook/addon-interactions", - "@storybook/addon-styling", + "@storybook/addon-viewport", ], + // Simplify TypeScript options + typescript: { + check: false, + reactDocgen: false, // Disable docgen to avoid processing issues + }, framework: { name: "@storybook/react-webpack5", - options: {}, + options: { + builder: { + useSWC: false, + } + }, }, docs: { autodocs: "tag", }, - staticDirs: ["../public"], // Static asset folder configuration + staticDirs: ["../public"], + // Use external webpack config webpackFinal: async (config) => { - // Ensure TypeScript loader is properly configured - config.module?.rules?.push({ - test: /\.tsx?$/, - use: [ - { - loader: "ts-loader", - options: { - transpileOnly: true, - }, - }, - ], - exclude: /node_modules/, - }); - - return config; + const customWebpackConfig = require('./webpack.config'); + return customWebpackConfig({ config }); + }, + core: { + disableTelemetry: true, + // Ensure csf-plugin doesn't interfere with processing + disableWhatsNewNotifications: true, }, }; export default config; + +function getAbsolutePath(value: string): any { + return dirname(require.resolve(join(value, "package.json"))); +} diff --git a/packages/ui/.storybook/preview.ts b/packages/ui/.storybook/preview.ts index 66c96b70..5fe49d9e 100644 --- a/packages/ui/.storybook/preview.ts +++ b/packages/ui/.storybook/preview.ts @@ -1,9 +1,9 @@ import type { Preview } from "@storybook/react"; -import { withThemeFromJSXProvider } from "@storybook/addon-styling"; -import { CssBaseline } from "@mui/material"; -import { ThemeProvider } from "../src"; +// Import theme directly without MUI components to avoid emotion issues at this level import theme from "../src/Theme"; import { INITIAL_VIEWPORTS } from "@storybook/addon-viewport"; +import React from "react"; +import { withMuiTheme } from "./withMuiTheme.decorator"; const customViewports = { iPhone12: { @@ -72,18 +72,13 @@ const preview: Preview = { date: /Date$/, }, }, + backgrounds: { + default: "dark", + }, }, + tags: ["autodocs"], + // Just use the decorator we created separately + decorators: [withMuiTheme], }; -export const decorators = [ - withThemeFromJSXProvider({ - themes: { - dark: theme, - }, - defaultTheme: "dark", - Provider: ThemeProvider, - GlobalStyles: CssBaseline, - }), -]; - export default preview; diff --git a/packages/ui/.storybook/preview.tsx b/packages/ui/.storybook/preview.tsx new file mode 100644 index 00000000..18a7e94e --- /dev/null +++ b/packages/ui/.storybook/preview.tsx @@ -0,0 +1,80 @@ +import type { Preview } from "@storybook/react"; +import { INITIAL_VIEWPORTS } from "@storybook/addon-viewport"; +import { withMuiTheme } from "./withMuiTheme.decorator"; +import React from "react"; + +const customViewports = { + iPhone12: { + name: "iPhone 12", + styles: { + width: "390px", + height: "844px", + }, + }, + iPhone12Pro: { + name: "iPhone 12 Pro", + styles: { + width: "390px", + height: "844px", + }, + }, + iPhone12ProMax: { + name: "iPhone 12 Pro Max", + styles: { + width: "428px", + height: "926px", + }, + }, + iPhone12Mini: { + name: "iPhone 12 Mini", + styles: { + width: "360px", + height: "780px", + }, + }, + kindleFireHD: { + name: "Kindle Fire HD", + styles: { + width: "533px", + height: "801px", + }, + }, + desktopFullHd: { + name: "Desktop Full HD", + styles: { + width: "1920px", + height: "1080px", + }, + }, + desktop4k: { + name: "Desktop 4K", + styles: { + width: "3840px", + height: "2160px", + }, + }, +}; + +const preview: Preview = { + parameters: { + viewport: { + viewports: { + ...INITIAL_VIEWPORTS, + ...customViewports, + }, + }, + actions: { argTypesRegex: "^on[A-Z].*" }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + backgrounds: { + default: "dark", + }, + }, + decorators: [withMuiTheme], +}; + +export default preview; diff --git a/packages/ui/.storybook/setupFile.js b/packages/ui/.storybook/setupFile.js new file mode 100644 index 00000000..a3a6f99b --- /dev/null +++ b/packages/ui/.storybook/setupFile.js @@ -0,0 +1,15 @@ +// This file sets up global mocks or imports for use in Storybook + +// Safely provide emotion modules if we're in a browser environment +if (typeof window !== 'undefined') { + try { + window.emotion = { + react: require('@emotion/react'), + styled: require('@emotion/styled') + }; + } catch (error) { + console.warn('Could not load emotion packages:', error); + } +} + +// You can add other global setup code here if needed diff --git a/packages/ui/.storybook/tsconfig.json b/packages/ui/.storybook/tsconfig.json new file mode 100644 index 00000000..7300377a --- /dev/null +++ b/packages/ui/.storybook/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "strict": false, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": [ + "../src/**/*", + "./**/*" + ], + "exclude": [ + "../node_modules" + ] +} diff --git a/packages/ui/.storybook/webpack.config.js b/packages/ui/.storybook/webpack.config.js new file mode 100644 index 00000000..045bbd72 --- /dev/null +++ b/packages/ui/.storybook/webpack.config.js @@ -0,0 +1,58 @@ +const path = require('path'); +const fs = require('fs'); + +module.exports = ({ config }) => { + // Find the absolute path to the workspace root + const workspaceRoot = path.resolve(__dirname, '../../../'); + + // Add packages to resolve.modules + config.resolve.modules = [ + ...(config.resolve.modules || []), + path.resolve(__dirname, '../node_modules'), + path.resolve(workspaceRoot, 'node_modules'), + ]; + + // Set alias for emotion packages + config.resolve.alias = { + ...config.resolve.alias, + '@emotion/react': path.resolve(workspaceRoot, 'node_modules/@emotion/react'), + '@emotion/styled': path.resolve(workspaceRoot, 'node_modules/@emotion/styled'), + }; + + // Make sure TypeScript extensions are handled + config.resolve.extensions = [ + ...(config.resolve.extensions || []), + '.ts', '.tsx', '.js', '.jsx', '.mjs' + ]; + + // Clear out any conflicting rules for .ts/.tsx files + config.module.rules = config.module.rules.filter(rule => { + if (!rule.test) return true; + const test = rule.test.toString(); + return !(test.includes('ts') || test.includes('tsx')); + }); + + // Add unified rules for all JavaScript and TypeScript files + config.module.rules.push({ + test: /\.(jsx?|tsx?)$/, + exclude: /node_modules/, + use: [ + { + loader: require.resolve('babel-loader'), + options: { + presets: [ + require.resolve('@babel/preset-env'), + require.resolve('@babel/preset-react'), + require.resolve('@babel/preset-typescript') + ], + plugins: [ + require.resolve('@babel/plugin-transform-runtime'), + [require.resolve('@babel/plugin-proposal-class-properties'), { loose: true }] + ] + } + } + ] + }); + + return config; +}; diff --git a/packages/ui/.storybook/withMuiTheme.decorator.tsx b/packages/ui/.storybook/withMuiTheme.decorator.tsx new file mode 100644 index 00000000..6a84d559 --- /dev/null +++ b/packages/ui/.storybook/withMuiTheme.decorator.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { CssBaseline, ThemeProvider } from "@mui/material"; +import theme from "../src/Theme"; + +export const withMuiTheme = (StoryFn: React.FC) => { + return ( + + + + + ); +}; diff --git a/packages/ui/README.md b/packages/ui/README.md index a0fcee1f..e9d88208 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -1,6 +1,15 @@ # UI Package -The UI package is a collection of reusable components and styles based on Material-UI (MUI). It provides a comprehensive set of UI elements that follow the Material Design guidelines, enabling developers to create visually appealing and consistent user interfaces. +The UI package is a collection of reusable components and styles based on Material-UI (MUI) v6. It provides a comprehensive set of UI elements that follow the Material Design guidelines, enabling developers to create visually appealing and consistent user interfaces for the Phoenix Protocol application. + +## Features + +- Built on Material UI v6 with Next.js 15 integration +- Responsive components for both desktop and mobile views +- Custom theme with dark mode support +- Animation support via Framer Motion +- Storybook integration for component development and documentation +- Type-safe component props using TypeScript ## Installation @@ -9,26 +18,50 @@ To install the UI package, follow these steps: 1. Ensure that you have Node.js and yarn installed on your machine. 2. Navigate to the root directory of the Phoenix Frontend project. 3. Run `yarn install` to install all project dependencies. -4. Navigate to the `/packages/ui` directory. ## Usage To use the UI package in your application, follow these steps: -1. Import the desired components from the UI package into your project. -2. Utilize the imported components within your application's codebase. +1. Import the desired components from the UI package into your project: + + ```typescript + import { Button, Card, TextField } from "@phoenix-protocol/ui"; + ``` + +2. Utilize the imported components within your application's codebase: + + ```typescript + function MyComponent() { + return ( + + + + + ); + } + ``` + 3. Customize the components as needed, utilizing the available props and styling options. -4. Build and run your application to see the UI components in action. ## Storybook -The UI package includes a Storybook setup, which provides a development environment for designing, documenting, and testing the UI components. To launch the Storybook, run the following command from the `/packages/UI` directory: - -`yarn storybook` +The UI package includes a Storybook setup, which provides a development environment for designing, documenting, and testing the UI components. To launch the Storybook, run the following command from the project root: +```bash +yarn storybook +``` This will start the Storybook server, and you can access it in your browser at [http://localhost:6006](http://localhost:6006). ## Customization -The UI package provides various options for customization. You can modify the appearance and behavior of the components by adjusting the props, applying custom styles, or extending the existing components to create new ones. Refer to the component's documentation and Material-UI's guidelines for detailed instructions on customization. +The UI package provides various options for customization. You can modify the appearance and behavior of the components by adjusting the props, applying custom styles, or extending the existing components to create new ones. Refer to the component's documentation in Storybook and Material-UI's guidelines for detailed instructions on customization. + +## Development + +To develop or extend the UI package: + +1. Make changes to the files in the `src` directory +2. Run `yarn build:ui` or `yarn dev` from the project root to build the package +3. View your changes in Storybook with `yarn storybook` diff --git a/packages/ui/doctor-storybook.log b/packages/ui/doctor-storybook.log new file mode 100644 index 00000000..cc0d2526 --- /dev/null +++ b/packages/ui/doctor-storybook.log @@ -0,0 +1,21 @@ +🩺 The doctor is checking the health of your Storybook.. +╭ Incompatible packages found ────────────────────────────────────────╮ +│ │ +│ The following packages are incompatible with Storybook 8.6.9 as │ +│ they depend on different major versions of Storybook packages: │ +│ - @storybook/addon-styling@1.3.7 │ +│ │ +│ │ +│ Please consider updating your packages or contacting the │ +│ maintainers for compatibility details. │ +│ For more on Storybook 8 compatibility, see the linked GitHub │ +│ issue: │ +│ https://github.com/storybookjs/storybook/issues/26031 │ +│ │ +╰─────────────────────────────────────────────────────────────────────╯ + +You can always recheck the health of your project by running: +npx storybook doctor + +Full logs are available in /Users/milan/workspace/phoenix/frontend/packages/ui/doctor-storybook.log + diff --git a/packages/ui/package.json b/packages/ui/package.json index d1515ab3..ab2ca2ab 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -6,12 +6,13 @@ "module": "module/index.js", "typings": "types/index.d.ts", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "echo \"Error: no test specified\" && exit 0", "storybook": "storybook dev -p 6006", "build:storybook": "storybook build", "build:main": "cross-env BABEL_ENV=production babel src --out-dir main --delete-dir-on-start --extensions \".tsx,.ts,.js\"", "build:module": "cross-env MODULE=true babel src --out-dir module --delete-dir-on-start --extensions \".tsx,.ts,.js\"", "build": "yarn build:module && yarn build:main && tsc --emitDeclarationOnly", + "dev": "concurrently \"tsc --emitDeclarationOnly --watch\" \"cross-env MODULE=true babel src --out-dir module --extensions \\\".tsx,.ts,.js\\\" --watch\"", "prepublishOnly": "yarn build" }, "np": { @@ -37,21 +38,24 @@ "@babel/preset-env": "7.19.1", "@babel/preset-react": "7.18.6", "@babel/preset-typescript": "7.16.7", + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", "@phoenix-protocol/state": "workspace:^", + "@phoenix-protocol/strategies": "workspace:^", "@phoenix-protocol/types": "workspace:^", "@rollup/plugin-commonjs": "^25.0.0", "@rollup/plugin-node-resolve": "^15.0.2", "@rollup/plugin-typescript": "^11.1.1", - "@storybook/addon-actions": "v8.5.0-alpha.9", - "@storybook/addon-essentials": "v8.5.0-alpha.9", - "@storybook/addon-interactions": "v8.5.0-alpha.9", - "@storybook/addon-links": "v8.5.0-alpha.9", - "@storybook/addon-styling": "^1.3.0", - "@storybook/blocks": "v8.5.0-alpha.9", - "@storybook/preset-react-webpack": "8.5.0-alpha.9", - "@storybook/react": "v8.5.0-alpha.9", - "@storybook/react-webpack5": "v8.5.0-alpha.9", - "@storybook/testing-library": "^0.0.14-next.2", + "@storybook/addon-actions": "^8.6.9", + "@storybook/addon-essentials": "^8.6.9", + "@storybook/addon-interactions": "^8.6.9", + "@storybook/addon-links": "^8.6.9", + "@storybook/addon-viewport": "^8.6.9", + "@storybook/blocks": "^8.6.9", + "@storybook/preset-react-webpack": "^8.6.9", + "@storybook/react": "^8.6.9", + "@storybook/react-webpack5": "^8.6.9", + "@storybook/testing-library": "^0.2.2", "@types/react": "^18.0.27", "babel-loader": "^8.2.2", "postcss": "^8.4.21", @@ -62,7 +66,7 @@ "rollup-plugin-dts": "^5.3.0", "rollup-plugin-peer-deps-external": "^2.2.4", "rollup-plugin-postcss": "^4.0.2", - "storybook": "v8.5.0-alpha.9", + "storybook": "^8.6.9", "ts-loader": "^9.5.1", "typescript": "^5.0.4" }, @@ -78,8 +82,6 @@ "@mui/utils": "6.1.7" }, "dependencies": { - "@emotion/react": "^11.11.1", - "@emotion/styled": "^11.11.0", "@fontsource/inter": "^5.0.3", "@fontsource/poppins": "^5.0.3", "@fontsource/roboto": "^5.0.3", diff --git a/packages/ui/public/3d.png b/packages/ui/public/3d.png new file mode 100644 index 00000000..cfe3fcb1 Binary files /dev/null and b/packages/ui/public/3d.png differ diff --git a/packages/ui/public/earnIcon.svg b/packages/ui/public/earnIcon.svg new file mode 100644 index 00000000..fed1fa0b --- /dev/null +++ b/packages/ui/public/earnIcon.svg @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/ui/public/earnIconActive.svg b/packages/ui/public/earnIconActive.svg new file mode 100644 index 00000000..f74f5483 --- /dev/null +++ b/packages/ui/public/earnIconActive.svg @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/ui/public/freighter.svg b/packages/ui/public/freighter.svg new file mode 100644 index 00000000..3899d4cc --- /dev/null +++ b/packages/ui/public/freighter.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/packages/ui/public/plants.png b/packages/ui/public/plants.png new file mode 100644 index 00000000..e8d752dd Binary files /dev/null and b/packages/ui/public/plants.png differ diff --git a/packages/ui/public/saving.png b/packages/ui/public/saving.png new file mode 100644 index 00000000..f723902f Binary files /dev/null and b/packages/ui/public/saving.png differ diff --git a/packages/ui/public/wallet-1.png b/packages/ui/public/wallet-1.png new file mode 100644 index 00000000..967549bb Binary files /dev/null and b/packages/ui/public/wallet-1.png differ diff --git a/packages/ui/public/wallet-2.png b/packages/ui/public/wallet-2.png new file mode 100644 index 00000000..0000a192 Binary files /dev/null and b/packages/ui/public/wallet-2.png differ diff --git a/packages/ui/public/wallet-3.png b/packages/ui/public/wallet-3.png new file mode 100644 index 00000000..3b341208 Binary files /dev/null and b/packages/ui/public/wallet-3.png differ diff --git a/packages/ui/src/AnchorServices/AnchorServices.stories.tsx b/packages/ui/src/AnchorServices/AnchorServices.stories.tsx index b20e3c88..ab1da82f 100644 --- a/packages/ui/src/AnchorServices/AnchorServices.stories.tsx +++ b/packages/ui/src/AnchorServices/AnchorServices.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import { AnchorServices } from "./AnchorServices"; import { Anchor } from "@phoenix-protocol/types"; +import React from "react"; // Default metadata of the story https://storybook.js.org/docs/react/api/csf#default-export const meta: Meta = { diff --git a/packages/ui/src/AppBar/AppBar.stories.tsx b/packages/ui/src/AppBar/AppBar.stories.tsx index 2aa33e77..17d1ab21 100644 --- a/packages/ui/src/AppBar/AppBar.stories.tsx +++ b/packages/ui/src/AppBar/AppBar.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { AppBar } from "./AppBar"; +import React from "react"; // Default metadata of the story https://storybook.js.org/docs/react/api/csf#default-export const meta: Meta = { diff --git a/packages/ui/src/AppBar/AppBar.tsx b/packages/ui/src/AppBar/AppBar.tsx index 57c1289a..2c7ab864 100644 --- a/packages/ui/src/AppBar/AppBar.tsx +++ b/packages/ui/src/AppBar/AppBar.tsx @@ -27,13 +27,18 @@ const BalanceChip = ({ balance }: { balance: number }) => ( sx={{ fontSize: "0.8125rem", lineHeight: "1.125rem", - opacity: 0.6, + opacity: 1, + color: "var(--neutral-300, #D4D4D4)", }} > {balance} XLM } - sx={{ padding: "0.75rem!important" }} + sx={{ + padding: "0.75rem!important", + background: "var(--neutral-800, #262626)", + borderColor: "var(--neutral-700, #404040)", + }} variant="outlined" /> ); @@ -57,16 +62,19 @@ const OptionMenu = ({ return ( - - - + {walletAddress && ( + + + + )} @@ -91,19 +100,35 @@ const OptionMenu = ({ handleClose(); }} > - + Wallet Address - - + + {walletAddress.slice(0, 15)} ... - + { disconnectWallet(); handleClose(); @@ -137,12 +162,12 @@ const AppBar = ({ background: largerThenMd ? "transparent" : "linear-gradient(180deg, #1A1C20 0%, #0E1011 100%)", - position: {xs: "fixed", md: "absolute"}, + position: { xs: "fixed", md: "absolute" }, top: 0, left: 0, width: "100%", p: "0.8rem 0.3rem", - zIndex: 1 + zIndex: 1, }} > - + - {walletAddress && balance >= 0 ? ( - <> - - - - ) : ( + {walletAddress && balance >= 0 && } + + {!walletAddress || balance < 0 ? ( - )} + ) : null} {!largerThenMd && ( diff --git a/packages/ui/src/Button/Button.tsx b/packages/ui/src/Button/Button.tsx index 8285dc60..aadb77d8 100644 --- a/packages/ui/src/Button/Button.tsx +++ b/packages/ui/src/Button/Button.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Button as MuiButton } from "@mui/material"; -import Colors from "../Theme/colors"; import { ButtonProps } from "@phoenix-protocol/types"; +import { colors, borderRadius, typography, shadows } from "../Theme/styleConstants"; const Button = ({ type = "primary", @@ -9,26 +9,42 @@ const Button = ({ label, ...props }: ButtonProps) => { + // Define consistent styling based on our style constants const styles = { wrapper: { display: "inline-block", - background: "linear-gradient(180deg, #E2391B 0%, #E29E1B 100%)", - padding: "1px", // This is the border witdh - borderRadius: "16px", + background: colors.primary.gradient, + padding: "1px", + borderRadius: borderRadius.lg, width: props.fullWidth ? "100%" : "auto", }, button: { - background: type === "primary" ? Colors.primary : Colors.background, + background: + type === "primary" + ? colors.primary.gradient + : colors.gradients.card, border: "none", - padding: - size === "medium" ? "18px 40px 18px 40px" : "12px 40px 12px 40px", - borderRadius: "15px", // Slighter less then the wrapper to show the border - fontSize: "14px", - fontWeight: "700", + padding: size === "medium" ? "14px 32px" : "10px 24px", + borderRadius: borderRadius.lg, + fontSize: typography.fontSize.sm, + fontWeight: typography.fontWeights.bold, + fontFamily: typography.fontFamily, lineHeight: "20px", textTransform: "none", - color: Colors.backgroundLight, + color: "#FFFFFF", width: "100%", + boxShadow: + type === "primary" ? shadows.elevated : "none", + transition: "all 0.2s ease-in-out", + "&:hover": { + transform: type === "primary" ? "translateY(-2px)" : "none", + boxShadow: + type === "primary" ? shadows.card : "none", + }, + "&:disabled": { + background: colors.neutral[700], + color: colors.neutral[400], + }, }, }; @@ -39,10 +55,9 @@ const Button = ({ ) || {}), }} {...otherProps} > diff --git a/packages/ui/src/Common/CardContainer.stories.tsx b/packages/ui/src/Common/CardContainer.stories.tsx new file mode 100644 index 00000000..09f14d45 --- /dev/null +++ b/packages/ui/src/Common/CardContainer.stories.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { StoryFn, Meta } from "@storybook/react"; +import { CardContainer } from "./CardContainer"; +import { Box, Typography } from "@mui/material"; + +export default { + title: "Common/CardContainer", + component: CardContainer, + parameters: { + layout: "centered", + }, +} as Meta; + +const Template: StoryFn = (args) => ( + + + +); + +export const Default = Template.bind({}); +Default.args = { + children: ( + + + Card Title + + + This is a standard card with default styling. It can contain any content. + + + ), +}; + +export const Highlighted = Template.bind({}); +Highlighted.args = { + highlighted: true, + children: ( + + + Highlighted Card + + + This card has special highlight styling to make it stand out. + + + ), +}; + +export const NoPadding = Template.bind({}); +NoPadding.args = { + noPadding: true, + children: ( + + + No Padding Card + + + This card has no built-in padding, allowing for custom content layouts. + + + ), +}; + +export const CustomStyling = Template.bind({}); +CustomStyling.args = { + sx: { + maxWidth: "400px", + background: "linear-gradient(135deg, #262626 0%, #171717 100%)", + boxShadow: "0 8px 16px rgba(0, 0, 0, 0.2)", + }, + children: ( + + + Custom Styled Card + + + This card has custom styles applied via the sx prop, demonstrating how the component can be extended. + + + ), +}; diff --git a/packages/ui/src/Common/CardContainer.tsx b/packages/ui/src/Common/CardContainer.tsx new file mode 100644 index 00000000..62ae9733 --- /dev/null +++ b/packages/ui/src/Common/CardContainer.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { Box, BoxProps } from "@mui/material"; +import { cardStyles } from "../Theme/styleConstants"; + +interface CardContainerProps extends BoxProps { + highlighted?: boolean; + noPadding?: boolean; + children: React.ReactNode; +} + +export const CardContainer = ({ + highlighted = false, + noPadding = false, + children, + sx = {}, + ...props +}: CardContainerProps) => { + return ( + + {children} + + ); +}; diff --git a/packages/ui/src/Common/CustomDropdown.tsx b/packages/ui/src/Common/CustomDropdown.tsx new file mode 100644 index 00000000..8aa59572 --- /dev/null +++ b/packages/ui/src/Common/CustomDropdown.tsx @@ -0,0 +1,221 @@ +import React, { useState, useRef, useMemo } from "react"; +import { + Box, + Typography, + TextField, + Paper, + MenuList, + MenuItem, + Popper, +} from "@mui/material"; + +type Pool = { + tokenA: { icon: string; symbol: string }; + tokenB: { icon: string; symbol: string }; + contractAddress: string; +}; + +interface CustomDropdownProps { + pools: Pool[]; + selectedPoolForVolume: string | undefined; + setSelectedPoolForVolume: (pool: string | undefined) => void; +} + +const CustomDropdown = ({ + pools, + selectedPoolForVolume, + setSelectedPoolForVolume, +}: CustomDropdownProps) => { + const [open, setOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const anchorRef = useRef(null); + const searchRef = useRef(null); + + const filteredPools = useMemo(() => { + if (!searchTerm) return pools; + return pools.filter( + (pool) => + pool.tokenA.symbol.toLowerCase().includes(searchTerm.toLowerCase()) || + pool.tokenB.symbol.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }, [pools, searchTerm]); + + const handleToggle = () => { + setOpen((prevOpen) => !prevOpen); + }; + + const handleClose = (event: any) => { + if ( + anchorRef.current && + anchorRef.current.contains(event.target as HTMLElement) + ) { + return; + } + if (searchRef.current?.contains(event.target)) { + return; // keep it open if the click is inside the search + } + + setOpen(false); + }; + + const handleMenuItemClick = ( + event: React.MouseEvent, + poolAddress: string | undefined + ) => { + setSelectedPoolForVolume(poolAddress); + setOpen(false); + }; + + return ( +
+ + + {selectedPoolForVolume === undefined || + selectedPoolForVolume === "All" + ? "All Pools" + : pools.find((p) => p.contractAddress === selectedPoolForVolume) + ?.tokenA.symbol + + " / " + + pools.find((p) => p.contractAddress === selectedPoolForVolume) + ?.tokenB.symbol} + + + + + e.stopPropagation()} + style={{ + pointerEvents: "none", + paddingTop: 0, + paddingBottom: "0.5rem", + }} + > + { + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + }} + > + setSearchTerm(e.target.value)} + InputProps={{ + disableUnderline: true, + style: { color: "var(--neutral-300, #D4D4D4)" }, + }} + sx={{ + "& .MuiInputBase-input": { + padding: 0, + }, + }} + /> + + + handleMenuItemClick(event, undefined)} + sx={{ + textAlign: "center", + color: "var(--neutral-300, #D4D4D4)", + }} + > + All + + {filteredPools.map((pool) => ( + + handleMenuItemClick(event, pool.contractAddress) + } + > + + {pool.tokenA.symbol} + + {pool.tokenA.symbol} + + / + {pool.tokenB.symbol} + + {pool.tokenB.symbol} + + + + ))} + + + + +
+ ); +}; + +export { CustomDropdown }; diff --git a/packages/ui/src/Common/CustomTooltip.stories.tsx b/packages/ui/src/Common/CustomTooltip.stories.tsx new file mode 100644 index 00000000..c188c2b5 --- /dev/null +++ b/packages/ui/src/Common/CustomTooltip.stories.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { StoryFn, Meta } from "@storybook/react"; +import { CustomTooltip } from "./CustomTooltip"; +import { Box } from "@mui/material"; + +export default { + title: "Common/CustomTooltip", + component: CustomTooltip, + parameters: { + layout: "centered", + }, +} as Meta; + +const Template: StoryFn = (args) => ( + + + +); + +export const Default = Template.bind({}); +Default.args = { + active: true, + payload: [ + { name: "Volume", value: 1234567 }, + { name: "Transactions", value: 456 }, + ], + label: "January 1, 2023", +}; + +export const SingleValue = Template.bind({}); +SingleValue.args = { + active: true, + payload: [ + { name: "Price", value: 0.0485 }, + ], + label: "Today", +}; + +export const MultipleValues = Template.bind({}); +MultipleValues.args = { + active: true, + payload: [ + { name: "Open", value: 0.0445 }, + { name: "Close", value: 0.0485 }, + { name: "High", value: 0.0492 }, + { name: "Low", value: 0.0438 }, + ], + label: "October 15, 2023", +}; diff --git a/packages/ui/src/Common/CustomTooltip.tsx b/packages/ui/src/Common/CustomTooltip.tsx new file mode 100644 index 00000000..5bc994d3 --- /dev/null +++ b/packages/ui/src/Common/CustomTooltip.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { Box, Typography } from "@mui/material"; +import { commonStyles, colors, typography } from "../Theme/styleConstants"; + +interface CustomTooltipProps { + active?: boolean; + payload?: any[]; + label?: string; +} + +export const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => { + if (!active || !payload || !payload.length) { + return null; + } + + return ( + + {label && ( + + {label} + + )} + + {payload.map((entry, index) => ( + + + {entry.name}: + + + {entry.value.toLocaleString()} + + + ))} + + ); +}; diff --git a/packages/ui/src/Common/SearchInput.stories.tsx b/packages/ui/src/Common/SearchInput.stories.tsx new file mode 100644 index 00000000..543636c8 --- /dev/null +++ b/packages/ui/src/Common/SearchInput.stories.tsx @@ -0,0 +1,43 @@ +import React, { useState } from "react"; +import { StoryFn, Meta } from "@storybook/react"; +import { SearchInput } from "./SearchInput"; +import { Box } from "@mui/material"; + +export default { + title: "Common/SearchInput", + component: SearchInput, + parameters: { + layout: "centered", + }, +} as Meta; + +const Template: StoryFn = (args) => { + const [value, setValue] = useState(args.value || ""); + return ( + + setValue(e.target.value)} + /> + + ); +}; + +export const Default = Template.bind({}); +Default.args = { + placeholder: "Search", + value: "", +}; + +export const WithValue = Template.bind({}); +WithValue.args = { + placeholder: "Search", + value: "Token", +}; + +export const CustomPlaceholder = Template.bind({}); +CustomPlaceholder.args = { + placeholder: "Search by name or address", + value: "", +}; diff --git a/packages/ui/src/Common/SearchInput.tsx b/packages/ui/src/Common/SearchInput.tsx new file mode 100644 index 00000000..7256844e --- /dev/null +++ b/packages/ui/src/Common/SearchInput.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { Box, Input, InputProps } from "@mui/material"; +import { colors, borderRadius, typography, spacing } from "../Theme/styleConstants"; + +interface SearchInputProps extends Omit { + value: string; + onChange: (e: React.ChangeEvent) => void; + placeholder?: string; +} + +export const SearchInput = ({ + value, + onChange, + placeholder = "Search", + sx = {}, + ...props +}: SearchInputProps) => { + return ( + + } + {...props} + /> + ); +}; diff --git a/packages/ui/src/Common/Toast/Toast.stories.tsx b/packages/ui/src/Common/Toast/Toast.stories.tsx new file mode 100644 index 00000000..bb0b53c8 --- /dev/null +++ b/packages/ui/src/Common/Toast/Toast.stories.tsx @@ -0,0 +1,247 @@ +import React from "react"; +import { StoryFn, Meta } from "@storybook/react"; +import { Button, Box, Stack } from "@mui/material"; +import { Toast, ToastProps } from "./Toast"; +import { ToastContainer } from "./ToastContainer"; +import { ToastProvider, useToast } from "./useToast"; + +export default { + title: "Common/Toast", + component: Toast, + parameters: { + layout: "centered", + }, +} as Meta; + +// Individual Toast Story +const SingleToastTemplate: StoryFn = (args) => ( + + + +); + +export const Success = SingleToastTemplate.bind({}); +Success.args = { + id: "1", + type: "success", + title: "Success", + message: "Operation completed successfully!", + onClose: () => console.log("Toast closed"), +}; + +export const Error = SingleToastTemplate.bind({}); +Error.args = { + id: "2", + type: "error", + title: "Error", + message: "Something went wrong. Please try again.", + onClose: () => console.log("Toast closed"), + // Use a plain object instead of Error constructor for storybook compatibility + error: { + message: "Detailed error message", + stack: + "Error: Detailed error message\n at Function.execute (http://localhost:6006/main.iframe.bundle.js:1234:15)\n at onClick (http://localhost:6006/main.iframe.bundle.js:5678:10)", + }, +}; + +export const Warning = SingleToastTemplate.bind({}); +Warning.args = { + id: "3", + type: "warning", + title: "Warning", + message: "This action may have side effects.", + onClose: () => console.log("Toast closed"), +}; + +export const Info = SingleToastTemplate.bind({}); +Info.args = { + id: "4", + type: "info", + title: "Information", + message: "Your transaction is being processed.", + onClose: () => console.log("Toast closed"), +}; + +export const Loading = SingleToastTemplate.bind({}); +Loading.args = { + id: "5", + type: "loading", + title: "Processing", + message: "Please wait while we process your request...", + onClose: () => console.log("Toast closed"), +}; + +export const SuccessWithTransaction = SingleToastTemplate.bind({}); +SuccessWithTransaction.args = { + id: "6", + type: "success", + title: "Transaction Complete", + message: "Your transaction has been processed successfully.", + onClose: () => console.log("Toast closed"), + transactionId: + "3389e9febc1f45efdae5866971360fedb2c53880453d83e8941b7dec5ac8981e", +}; + +// Toast Container with multiple toasts +const ToastContainerExample = () => { + const [toasts, setToasts] = React.useState([ + { + id: "1", + type: "success", + title: "Transaction Complete", + message: "Your transaction has been processed successfully.", + onClose: (id) => + setToasts((prev) => prev.filter((toast) => toast.id !== id)), + }, + { + id: "2", + type: "error", + title: "Connection Error", + message: "Failed to connect to the server. Please check your network.", + onClose: (id) => + setToasts((prev) => prev.filter((toast) => toast.id !== id)), + }, + ]); + + const onClose = (id: string) => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)); + }; + + return ( + + + + ); +}; + +export const ToastContainerStory = () => ; +ToastContainerStory.storyName = "Toast Container"; + +// Toast Provider with interactive demo +const ToastDemo = () => { + const { success, error, warning, info, loading, removeAll, addAsyncToast } = + useToast(); + + const simulateAsyncOperation = () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + success: true, + transactionId: + "3389e9febc1f45efdae5866971360fedb2c53880453d83e8941b7dec5ac8981e", + }); + }, 3000); + }); + }; + + const simulateAsyncError = () => { + return new Promise((_, reject) => { + setTimeout(() => { + // Create an error-like object instead of using the Error constructor + reject({ + message: "Failed to complete the operation", + stack: + "Error: Failed to complete the operation\n at simulateAsyncError\n at onClick\n at handleClick", + }); + }, 3000); + }); + }; + + return ( + + + + + + + + + + + + + ); +}; + +export const ToastSystem = () => ( + + + +); +ToastSystem.storyName = "Toast System"; diff --git a/packages/ui/src/Common/Toast/Toast.tsx b/packages/ui/src/Common/Toast/Toast.tsx new file mode 100644 index 00000000..78b75466 --- /dev/null +++ b/packages/ui/src/Common/Toast/Toast.tsx @@ -0,0 +1,283 @@ +import React, { useState } from "react"; +import { + Box, + IconButton, + Typography, + CircularProgress, + Link, + Collapse, +} from "@mui/material"; +import { motion } from "framer-motion"; +import CloseIcon from "@mui/icons-material/Close"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import ErrorIcon from "@mui/icons-material/Error"; +import InfoIcon from "@mui/icons-material/Info"; +import WarningIcon from "@mui/icons-material/Warning"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import ExpandLessIcon from "@mui/icons-material/ExpandLess"; +import LaunchIcon from "@mui/icons-material/Launch"; +import { + colors, + typography, + spacing, + borderRadius, + shadows, +} from "../../Theme/styleConstants"; + +export type ToastType = "success" | "error" | "info" | "warning" | "loading"; + +export interface ToastProps { + id: string; + type: ToastType; + message: string; + title?: string; + onClose: (id: string) => void; + autoHideDuration?: number; + transactionId?: string; + error?: Error | string | { message: string; stack?: string }; // For collapsible error details +} + +const getToastIcon = (type: ToastType) => { + switch (type) { + case "success": + return ; + case "error": + return ; + case "warning": + return ; + case "info": + return ; + case "loading": + return ; + default: + return ; + } +}; + +const getToastColor = (type: ToastType) => { + switch (type) { + case "success": + return colors.success.main; + case "error": + return colors.error.main; + case "warning": + return colors.warning.main; + case "loading": + return colors.info.main; + case "info": + default: + return colors.primary.main; + } +}; + +// Helper function to get Explorer URL for transaction +const getExplorerUrl = (transactionId: string) => { + return `https://stellar.expert/explorer/public/tx/${transactionId}`; +}; + +export const Toast = ({ + id, + type, + title, + message, + onClose, + autoHideDuration = 5000, + transactionId, + error, +}: ToastProps) => { + const [expanded, setExpanded] = useState(false); + + React.useEffect(() => { + // Don't auto-hide loading toasts + if (autoHideDuration && type !== "loading") { + const timer = setTimeout(() => { + onClose(id); + }, autoHideDuration); + + return () => { + clearTimeout(timer); + }; + } + }, [id, onClose, autoHideDuration, type]); + + // Format error details for display + const errorMessage = React.useMemo(() => { + if (!error) return ""; + if (typeof error === "string") return error; + + // Handle Error objects and plain objects with message/stack properties + const errorObj = error as any; + return errorObj.stack || errorObj.message || String(error); + }, [error]); + + const hasErrorDetails = type === "error" && errorMessage; + + return ( + + + + + {type === "loading" ? ( + + ) : ( + getToastIcon(type) + )} + + + + {title && ( + + {title} + + )} + + + {message} + + + {/* Transaction Link */} + {transactionId && ( + + View Transaction + + + )} + + {/* Error expand/collapse button */} + {hasErrorDetails && ( + setExpanded(!expanded)} + sx={{ + display: "flex", + alignItems: "center", + mt: 1, + cursor: "pointer", + fontSize: typography.fontSize.xs, + color: colors.primary.main, + }} + > + + {expanded ? "Hide Details" : "Show Details"} + + {expanded ? ( + + ) : ( + + )} + + )} + + + {/* Only show close button for non-loading toasts */} + {type !== "loading" && ( + onClose(id)} + sx={{ + padding: "4px", + color: colors.neutral[400], + "&:hover": { + color: colors.neutral[200], + backgroundColor: "transparent", + }, + }} + > + + + )} + + + {/* Collapsible Error Details */} + {hasErrorDetails && ( + + + {errorMessage} + + + )} + + + ); +}; diff --git a/packages/ui/src/Common/Toast/ToastContainer.tsx b/packages/ui/src/Common/Toast/ToastContainer.tsx new file mode 100644 index 00000000..b2a9a70c --- /dev/null +++ b/packages/ui/src/Common/Toast/ToastContainer.tsx @@ -0,0 +1,85 @@ +import React, { useContext } from "react"; +import { Box } from "@mui/material"; +import { AnimatePresence } from "framer-motion"; +import { Toast, ToastProps } from "./Toast"; +import { spacing } from "../../Theme/styleConstants"; +import { useToast } from "./useToast"; + +interface ToastContainerProps { + toasts?: ToastProps[]; + position?: + | "top-right" + | "top-left" + | "bottom-right" + | "bottom-left" + | "top-center" + | "bottom-center"; + onClose?: (id: string) => void; +} + +export const ToastContainer = ({ + toasts: externalToasts, + position = "bottom-right", + onClose: externalOnClose, +}: ToastContainerProps) => { + // Get toasts from context if not provided externally + const toastContext = useToast(); + const toasts = externalToasts || toastContext.toasts; + const onClose = externalOnClose || toastContext.removeToast; + + // Define position styles + const getPositionStyle = () => { + switch (position) { + case "top-right": + return { top: spacing.md, right: spacing.md }; + case "top-left": + return { top: spacing.md, left: spacing.md }; + case "bottom-left": + return { bottom: spacing.md, left: spacing.md }; + case "top-center": + return { top: spacing.md, left: "50%", transform: "translateX(-50%)" }; + case "bottom-center": + return { + bottom: spacing.md, + left: "50%", + transform: "translateX(-50%)", + }; + case "bottom-right": + default: + return { bottom: spacing.md, right: spacing.md }; + } + }; + + return ( + + + {toasts.map((toast) => ( + + ))} + + + ); +}; diff --git a/packages/ui/src/Common/Toast/index.ts b/packages/ui/src/Common/Toast/index.ts new file mode 100644 index 00000000..b623da7b --- /dev/null +++ b/packages/ui/src/Common/Toast/index.ts @@ -0,0 +1,3 @@ +export * from './Toast'; +export * from './ToastContainer'; +export * from './useToast'; diff --git a/packages/ui/src/Common/Toast/useToast.tsx b/packages/ui/src/Common/Toast/useToast.tsx new file mode 100644 index 00000000..2dbd4d04 --- /dev/null +++ b/packages/ui/src/Common/Toast/useToast.tsx @@ -0,0 +1,177 @@ +import React, { useState, useCallback, useContext, createContext } from "react"; +import { v4 as uuidv4 } from "uuid"; +import { ToastProps, ToastType } from "./Toast"; + +interface ToastContextType { + toasts: ToastProps[]; + addToast: (options: Omit) => string; + removeToast: (id: string) => void; + removeAll: () => void; + success: ( + message: string, + options?: Partial> + ) => string; + error: ( + message: string, + options?: Partial> + ) => string; + warning: ( + message: string, + options?: Partial> + ) => string; + info: ( + message: string, + options?: Partial> + ) => string; + loading: ( + message: string, + options?: Partial> + ) => string; + addAsyncToast: ( + promise: Promise, + loadingMessage: string, + options?: Partial> + ) => Promise; + updateToast: ( + id: string, + options: Partial> + ) => void; +} + +const ToastContext = createContext(undefined); + +export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [toasts, setToasts] = useState([]); + + const removeToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)); + }, []); + + const addToast = useCallback( + (options: Omit) => { + const id = uuidv4(); + setToasts((prev) => [...prev, { ...options, id, onClose: removeToast }]); + return id; + }, + [removeToast] + ); + + const updateToast = useCallback( + (id: string, options: Partial>) => { + setToasts((prev) => + prev.map((toast) => + toast.id === id ? { ...toast, ...options } : toast + ) + ); + }, + [] + ); + + const removeAll = useCallback(() => { + setToasts([]); + }, []); + + const createToastWithType = useCallback( + (type: ToastType) => + ( + message: string, + options?: Partial< + Omit + > + ) => { + return addToast({ message, type, ...options }); + }, + [addToast] + ); + + // Add async toast handling + const addAsyncToast = useCallback( + async ( + promise: Promise, + loadingMessage: string, + options?: Partial> + ) => { + const toastId = addToast({ + message: loadingMessage, + type: "loading", + ...options, + }); + + try { + const result = await promise; + + // Extract transaction ID if available in the result + const transactionId = result?.transactionId || undefined; + + updateToast(toastId, { + type: "success", + message: options?.title + ? `${options.title} succeeded` + : "Operation completed successfully!", + transactionId, + }); + return result; + } catch (error) { + // Create a serializable error object + const errorMessage = + error instanceof Error + ? error.message + : typeof error === "object" && error !== null && "message" in error + ? error.message + : "Operation failed"; + + const errorObj = + error instanceof Error + ? { message: error.message, stack: error.stack } + : typeof error === "object" && error !== null + ? error + : { message: String(error) }; + + updateToast(toastId, { + type: "error", + message: errorMessage as string, + error: errorObj as + | string + | Error + | { message: string; stack?: string | undefined } + | undefined, // Store a serializable error object + }); + throw error; + } finally { + // Auto remove the toast after 5 seconds + setTimeout(() => { + removeToast(toastId); + }, 5000); + } + }, + [addToast, updateToast, removeToast] + ); + + const value = { + toasts, + addToast, + removeToast, + removeAll, + updateToast, + addAsyncToast, + success: createToastWithType("success"), + error: createToastWithType("error"), + warning: createToastWithType("warning"), + info: createToastWithType("info"), + loading: createToastWithType("loading"), + }; + + return ( + {children} + ); +}; + +export const useToast = (): ToastContextType => { + const context = useContext(ToastContext); + if (context === undefined) { + throw new Error("useToast must be used within a ToastProvider"); + } + return context; +}; diff --git a/packages/ui/src/ConnectWallet/Carousel.tsx b/packages/ui/src/ConnectWallet/Carousel.tsx index e8ed19b7..bdcd7dd5 100644 --- a/packages/ui/src/ConnectWallet/Carousel.tsx +++ b/packages/ui/src/ConnectWallet/Carousel.tsx @@ -1,122 +1,212 @@ import React, { useState } from "react"; import { Box, Typography, IconButton } from "@mui/material"; +import { motion, AnimatePresence } from "framer-motion"; +import { colors, typography, spacing, borderRadius } from "../Theme/styleConstants"; import { ArrowBackIos, ArrowForwardIos } from "@mui/icons-material"; -import { motion } from "framer-motion"; -import { display } from "@mui/system"; interface CarouselItem { - image: string; - text: string; title: string; + content: string; + image: string; } interface CarouselProps { items: CarouselItem[]; } -export const Carousel = ({ items }: CarouselProps) => { +const CarouselComponent = ({ items }: CarouselProps) => { const [currentIndex, setCurrentIndex] = useState(0); + const [direction, setDirection] = useState(0); + + const handleNext = () => { + setDirection(1); + setCurrentIndex((prevIndex) => (prevIndex + 1) % items.length); + }; const handlePrev = () => { - setCurrentIndex((prevIndex) => - prevIndex === 0 ? items.length - 1 : prevIndex - 1 - ); + setDirection(-1); + setCurrentIndex((prevIndex) => (prevIndex - 1 + items.length) % items.length); }; - const handleNext = () => { - setCurrentIndex((prevIndex) => - prevIndex === items.length - 1 ? 0 : prevIndex + 1 - ); + const handleDotClick = (index: number) => { + setDirection(index > currentIndex ? 1 : -1); + setCurrentIndex(index); + }; + + const variants = { + enter: (direction: number) => ({ + x: direction > 0 ? 200 : -200, + opacity: 0, + }), + center: { + x: 0, + opacity: 1, + }, + exit: (direction: number) => ({ + x: direction < 0 ? 200 : -200, + opacity: 0, + }), }; return ( - - - - + + + + + + {/* If the image source exists, use it, otherwise display a placeholder */} + {items[currentIndex].image ? ( + { + // Fallback if image fails to load + e.currentTarget.src = "/placeholder-wallet.svg"; + }} + /> + ) : ( + + + ? + + + )} + + {items[currentIndex].title} + + + {items[currentIndex].content} + + + + + + + {/* Navigation dots */} - + {items.map((_, index) => ( - - {items[currentIndex].title} - - handleDotClick(index)} sx={{ - fontSize: 14, - opacity: 0.4, - marginBottom: "1rem", - textAlign: "center", + width: 10, + height: 10, + borderRadius: "50%", + backgroundColor: index === currentIndex ? colors.primary.main : colors.neutral[700], + cursor: "pointer", + transition: "all 0.3s ease", + "&:hover": { + backgroundColor: index === currentIndex ? colors.primary.main : colors.neutral[600], + }, }} - > - {items[currentIndex].text} - - + /> + ))} + + {/* Navigation arrows */} + + + - + - - {items.map((_, index) => ( - - ))} - ); }; + +export default CarouselComponent; diff --git a/packages/ui/src/ConnectWallet/ConnectWallet.tsx b/packages/ui/src/ConnectWallet/ConnectWallet.tsx index 2446e586..a8b024d3 100644 --- a/packages/ui/src/ConnectWallet/ConnectWallet.tsx +++ b/packages/ui/src/ConnectWallet/ConnectWallet.tsx @@ -4,18 +4,27 @@ import { ConnectWalletProps, OptionComponentProps, } from "@phoenix-protocol/types"; -import { Box, Modal, Typography, Grid, Skeleton } from "@mui/material"; -import { motion } from "framer-motion"; -import { Button as PhoenixButton } from "../Button/Button"; -import Colors from "../Theme/colors"; -import { Carousel } from "./Carousel"; // Correct the import path +import { + Box, + Modal, + Typography, + Grid, + useMediaQuery, + useTheme, +} from "@mui/material"; +import { motion, AnimatePresence } from "framer-motion"; +import { Button } from "../Button/Button"; +import { + colors, + typography, + spacing, + borderRadius, +} from "../Theme/styleConstants"; +import CarouselComponent from "./Carousel"; +import CloseIcon from "@mui/icons-material/Close"; /** - * OptionComponent - * Renders an individual wallet option with hover effects and selection state. - * - * @param {OptionComponentProps} props - Contains wallet connector, click handler, and selection status. - * @returns {JSX.Element} The wallet option component. + * OptionComponent for displaying individual wallet options */ const OptionComponent = ({ connector, @@ -23,80 +32,187 @@ const OptionComponent = ({ selected, allowed, }: OptionComponentProps & { allowed: boolean }) => { - const hoverStyles = { - background: - "linear-gradient(137deg, rgba(226, 73, 26, 0.20) 0%, rgba(226, 27, 27, 0.20) 17.08%, rgba(226, 73, 26, 0.20) 42.71%, rgba(226, 170, 27, 0.20) 100%)", - cursor: "pointer", - border: "2px solid #E2621B", - transition: "all 0.2s ease-in-out", - }; - - const baseStyles = { - display: "flex", - flexDirection: { xs: "column", md: "row" }, - padding: { md: "1.125rem 1.5rem", xs: "1rem" }, - alignItems: "center", - gap: "0.25rem", - background: - "linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.03) 100%)", - border: "2px solid transparent", - width: { xs: "auto", md: "100%" }, - marginTop: "1.25rem", - borderRadius: "8px", - transition: "all 0.2s ease-in-out", - opacity: allowed ? 1 : 0.5, - marginRight: { xs: "1rem", md: 0 }, - "&:hover": hoverStyles, - }; - return ( - + - {connector.name} - - {connector.name} - - {!allowed && ( - Not installed + {connector.name} - )} + {!allowed && ( + + Not installed + + )} + ); }; /** - * ConnectWallet - * Modal to display wallet connection options and handle the connection process. - * - * @param {ConnectWalletProps} props - Props for managing modal state, connectors, and the connect function. - * @returns {JSX.Element} The ConnectWallet modal component. + * Loading screen when connecting to a wallet + */ +const WalletConnectingScreen = ({ connector, onBack }) => ( + + + + + Opening {connector?.name} + + + Please confirm the connection request in the {connector?.name} app + + + + +); + +/** + * Information slides about crypto wallets + */ +const InfoSlides = () => { + const items = [ + { + title: "What are wallets?", + content: + "Wallets are used to send, receive, and access all your digital assets like PHO and XLM.", + image: "/wallet-3.png", + }, + { + title: "No accounts. No passwords.", + content: + "Use your wallet to sign into many different platforms. No unique accounts or passwords.", + image: "/wallet-2.png", + }, + { + title: "Your wallet, your keys.", + content: + "Your wallet is your key to the Stellar network. Keep it safe and secure. Phoenix Protocol never has access to your funds.", + image: "/wallet-1.png", + }, + ]; + + return ( + + + + ); +}; + +/** + * Main ConnectWallet component */ const ConnectWallet = ({ open, @@ -104,6 +220,9 @@ const ConnectWallet = ({ connectors, connect, }: ConnectWalletProps): JSX.Element => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("md")); + const [loading, setLoading] = useState(false); const [selected, setSelected] = useState(undefined); const [allowedConnectors, setAllowedConnectors] = useState([]); @@ -112,307 +231,277 @@ const ConnectWallet = ({ ); const [loadingConnectors, setLoadingConnectors] = useState(true); + // Check which connectors are allowed (installed) useEffect(() => { + let isMounted = true; + const checkConnectors = async () => { + if (!isMounted) return; + setLoadingConnectors(true); const allowed: Connector[] = []; const disallowed: Connector[] = []; + for (const connector of connectors) { - const isAllowed = await connector.isConnected(); - if (isAllowed) { - allowed.push(connector); - } else { + if (!isMounted) break; + + try { + const isAllowed = await connector.isConnected(); + if (isAllowed) { + allowed.push(connector); + } else { + disallowed.push(connector); + } + } catch (error) { disallowed.push(connector); } } - setAllowedConnectors(allowed); - setDisallowedConnectors(disallowed); - setLoadingConnectors(false); + + if (isMounted) { + setAllowedConnectors(allowed); + setDisallowedConnectors(disallowed); + setLoadingConnectors(false); + } }; - checkConnectors(); - }, [connectors]); - /** - * Handles wallet connection. - * Initiates the connection process for the selected connector. - * - * @param {Connector} connector - The wallet connector to connect with. - */ + if (open) { + // Use a stable reference to the connectors array + const connectorsCopy = [...connectors]; + checkConnectors(); + } + + return () => { + isMounted = false; + }; + }, [open]); // Remove connectors from dependency array to prevent re-renders + + // Handle connecting to a wallet const handleConnect = useCallback( async (connector: Connector) => { setLoading(true); + setSelected(connector); + try { await connect(connector); + // Close the modal after successful connection + setOpen(false); } catch (error) { - console.log("Wallet connection failed:", error); - } finally { + console.error("Wallet connection failed:", error); setLoading(false); - setOpen(false); } }, [connect, setOpen] ); - /** - * Handles the "Back" button action during the loading state. - */ + // Handle going back from the loading screen const handleBack = useCallback(() => { setLoading(false); setSelected(undefined); }, []); - const modalStyle = { - position: "absolute" as "absolute", - alignItems: { xs: "center", md: "flex-start" }, - top: { md: "50%", xs: "0" }, - left: "50%", - transform: { md: "translate(-50%, -50%)", xs: "translate(-50%, 0)" }, - width: { xs: "96vh", md: 800 }, - maxWidth: "calc(100vw - 16px)", - background: "linear-gradient(180deg, #292B2C 0%, #1F2123 100%)", - borderRadius: "16px", - flexDirection: { xs: "row", md: "column" }, - minHeight: "50vh", - maxHeight: { md: "530px", xs: "100vh" }, - }; + // Handle closing the modal + const handleClose = useCallback(() => { + setOpen(false); - const carouselItems = [ - { - image: "/pho-wallets.png", - title: "What are wallets?", - text: "Wallets are used to send, receive, and access all your digital assets like PHO and XLM.", - }, - { - image: "/pho-wallets.png", - title: "No accounts. No passwords.", - text: "Use your wallet to sign into many different platforms. No unique accounts or passwords.", - }, - { - image: "/pho-wallets.png", - title: "Your wallet, your keys.", - text: "Your wallet is your key to the Stellar network. Keep it safe and secure. Phoenix Protocol never has access to your funds.", - }, - ]; - - const skeletonStyles = { - display: "flex", - padding: { md: "1.125rem 1.5rem", xs: "1rem" }, - alignItems: "center", - gap: "0.25rem", - width: "100%", - marginTop: "1.25rem", - borderRadius: "8px", - }; + // Reset state after animation + setTimeout(() => { + setLoading(false); + setSelected(undefined); + // Reset connector states to prevent stale data + setAllowedConnectors([]); + setDisallowedConnectors([]); + setLoadingConnectors(true); + }, 300); + }, [setOpen]); return ( setOpen(false)} - aria-labelledby="connectwallet-modal" - aria-describedby="connect your wallet to the app" + onClose={handleClose} + aria-labelledby="connect-wallet-modal" + aria-describedby="select a wallet to connect to the app" > - - - + - Connect Wallet - - {!loadingConnectors ? ( - <> - - Start by connecting with one of the wallets below. - - + {/* Left Side - Wallet Selection */} + - {allowedConnectors.map((connector) => ( - { - setSelected(connector); - handleConnect(connector); - }} - allowed={true} - /> - ))} - {disallowedConnectors.map((connector) => ( - setSelected(connector)} - allowed={false} - /> - ))} - - - ) : ( - - - - - - )} - - - {loading ? ( - - - Loading Wallet - - Opening {selected?.name} - - - Please confirm in the {selected?.name} app - - - Back - - - - ) : ( - <> - setOpen(false)} > - Close + + Connect Wallet + + + + + + - - + Start by connecting with one of the wallets below. +
+ + {loadingConnectors ? ( + // Loading skeleton + [...Array(4)].map((_, index) => ( + + )) + ) : ( + // Wallet options + + {allowedConnectors.map((connector) => ( + handleConnect(connector)} + allowed={true} + /> + ))} + {disallowedConnectors.map((connector) => ( + {}} + allowed={false} + /> + ))} + + )} + + + {/* Right Side - Loading or Info */} + - - Getting Started - - - - - )} - - + + {loading ? ( + + ) : ( + + + + )} + + + + + + ); }; diff --git a/packages/ui/src/ConnectWallet/index.ts b/packages/ui/src/ConnectWallet/index.ts new file mode 100644 index 00000000..59af7d91 --- /dev/null +++ b/packages/ui/src/ConnectWallet/index.ts @@ -0,0 +1 @@ +export * from './ConnectWallet'; diff --git a/packages/ui/src/Dashboard/AssetInfoModal/AssetInfoModal.stories.tsx b/packages/ui/src/Dashboard/AssetInfoModal/AssetInfoModal.stories.tsx index 337b1479..10745a0e 100644 --- a/packages/ui/src/Dashboard/AssetInfoModal/AssetInfoModal.stories.tsx +++ b/packages/ui/src/Dashboard/AssetInfoModal/AssetInfoModal.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { AssetInfoModal } from "./AssetInfoModal"; +import React from "react"; // Default metadata of the story https://storybook.js.org/docs/react/api/csf#default-export const meta: Meta = { @@ -18,42 +19,342 @@ export const Primary: Story = { open: true, asset: { asset: "PHO-GAX5TXB5RYJNLBUR477PEXM4X75APK2PGMTN6KEFQSESGWFXEAKFSXJO-1", - supply: 2000897775221742, - traded_amount: 6831936130, - payments_amount: 2330868044789, + supply: 1999999999396679, + traded_amount: 4676470585841, + payments_amount: 1603536744556300, created: 1715112013, - trustlines: [178, 178, 87], - payments: 2336, + trustlines: [778, 778, 387], + payments: 31107, domain: "app.phoenix-hub.io", + price7d: [ + [1743085960000, 0.4588211242415678], + [1743138284000, 0.45440554107613923], + [1743138284000, 0.4541021770839637], + [1743141663000, 0.4522770860874685], + [1743141663000, 0.4517545026412518], + [1743148140000, 0.4504794609610309], + [1743148140000, 0.4496975722820581], + [1743159874000, 0.4486984217435167], + [1743159874000, 0.4487893571304989], + [1743170975000, 0.4473196140525047], + [1743170975000, 0.4475531523371983], + [1743181108000, 0.4475531523371983], + [1743181162000, 0.4475531523371983], + [1743190464000, 0.4475531523371983], + [1743190881000, 0.4475531523371983], + [1743190910000, 0.4475531523371983], + [1743191293000, 0.4475531523371983], + [1743191964000, 0.4475531523371983], + [1743192478000, 0.4475531523371983], + [1743193039000, 0.4475531523371983], + [1743193444000, 0.4475531523371983], + [1743212252000, 0.4475531523371983], + [1743217009000, 0.4490103640309041], + [1743217009000, 0.44371903601610674], + [1743217383000, 0.4427173717849195], + [1743217383000, 0.4496861283458413], + [1743232304000, 0.440772156398865], + [1743232304000, 0.4406536924168718], + [1743234011000, 0.43988739518320846], + [1743234574000, 0.4389411046343642], + [1743234574000, 0.4386045593174841], + [1743241505000, 0.43784303711923817], + [1743241505000, 0.4380228752497139], + [1743246206000, 0.4380228752497139], + [1743249134000, 0.4380228752497139], + [1743257524000, 0.4380228752497139], + [1743291521000, 0.4380228752497139], + [1743341099000, 0.43802287524971395], + [1743341156000, 0.43802287524971395], + [1743341237000, 0.4380228752497139], + [1743354692000, 0.4380228752497139], + [1743372432000, 0.43375530701469767], + [1743372432000, 0.43334549751463436], + [1743372537000, 0.43188755747392515], + [1743372537000, 0.4324094533383021], + [1743374084000, 0.4324094533383021], + [1743381259000, 0.43132602644580337], + [1743381259000, 0.43066708975280954], + [1743396635000, 0.43132602644580337], + [1743405591000, 0.42834270041682915], + [1743405591000, 0.42943905958599493], + [1743405990000, 0.42857553014205124], + [1743405990000, 0.42892628399495364], + [1743407554000, 0.427700631919887], + [1743407554000, 0.4269435967040356], + [1743473644000, 0.427700631919887], + [1743480085000, 0.427700631919887], + [1743484922000, 0.427700631919887], + [1743485007000, 0.427700631919887], + [1743498929000, 0.427700631919887], + [1743499467000, 0.427700631919887], + [1743499530000, 0.427700631919887], + [1743499579000, 0.427700631919887], + [1743499619000, 0.427700631919887], + [1743499719000, 0.427700631919887], + [1743499736000, 0.427700631919887], + [1743499753000, 0.427700631919887], + [1743499777000, 0.427700631919887], + [1743501905000, 0.427700631919887], + [1743502007000, 0.427700631919887], + [1743504824000, 0.42481723525264914], + [1743504824000, 0.42533863040297626], + [1743504906000, 0.42481723525264914], + [1743505168000, 0.42515116790320806], + [1743505168000, 0.4237340692761448], + [1743508006000, 0.4237340692761448], + [1743508012000, 0.4243119315452311], + [1743508012000, 0.4229090503753681], + [1743508012000, 0.4222948467034411], + [1743512657000, 0.4222948467034411], + [1743512699000, 0.4222948467034411], + [1743512728000, 0.42229484670344114], + [1743512750000, 0.4222948467034411], + [1743516257000, 0.42086539857962185], + [1743516257000, 0.4234304584539419], + [1743516664000, 0.4198384060297612], + [1743516664000, 0.4241193335953195], + [1743517300000, 0.418839740573239], + [1743517300000, 0.42479305879823587], + [1743517380000, 0.418839740573239], + [1743518138000, 0.41883974057323897], + [1743518196000, 0.418839740573239], + [1743519859000, 0.41883974057323897], + [1743538152000, 0.418839740573239], + [1743541192000, 0.418839740573239], + [1743541260000, 0.418839740573239], + [1743541887000, 0.418839740573239], + [1743549205000, 0.41796309332289594], + [1743549650000, 0.41969094897558906], + [1743550436000, 0.418839740573239], + [1743556565000, 0.418839740573239], + [1743556616000, 0.418839740573239], + [1743556993000, 0.418839740573239], + [1743557280000, 0.41883974057323897], + [1743557400000, 0.418839740573239], + [1743557590000, 0.4188397405732391], + [1743557643000, 0.418839740573239], + [1743558668000, 0.4188397405732391], + [1743559603000, 0.41714861734736974], + [1743559603000, 0.4169852629345386], + [1743565576000, 0.41714861734736974], + [1743576529000, 0.4140740458876916], + [1743576529000, 0.41360460495862517], + [1743577581000, 0.41269498260012083], + [1743577581000, 0.4132819533005161], + [1743579268000, 0.4132819533005161], + [1743586373000, 0.4132819533005161], + [1743586947000, 0.4132819533005161], + [1743592638000, 0.41968389645268017], + [1743595242000, 0.4132819533005161], + [1743596739000, 0.4132819533005161], + [1743597183000, 0.4132819533005161], + [1743631930000, 0.4103410172920976], + [1743631930000, 0.4106175862252827], + [1743633248000, 0.4054340532544669], + [1743633248000, 0.40488003101492587], + [1743633754000, 0.40495469411249907], + [1743633754000, 0.40464524271860924], + [1743634926000, 0.40434426978453464], + [1743634926000, 0.40303395386645186], + [1743640242000, 0.4142650585446133], + [1743640261000, 0.41212991796483245], + [1743642941000, 0.4154868867922161], + [1743648245000, 0.4142650585446133], + [1743648746000, 0.4142650585446133], + [1743683329000, 0.4090945255751361], + [1743683329000, 0.40875021757938745], + ], + volume7d: 13196194700, rating: { - age: 0, - trades: 4, - payments: 4, - trustlines: 3, + age: 6, + trades: 7, + payments: 5, + trustlines: 4, volume7d: 7, interop: 4, - liquidity: 1, - average: 3.3, + liquidity: 2, + average: 5, }, - price7d: [ - [1715126400, 4.918545635483871], - [1715212800, 1.493419405263158], - [1715299200, 1.7245551875], - [1715385600, 2.519457714285714], - [1715472000, 52.90474066666667], - [1715558400, 2.6065395625], - [1715644800, 2.2936092857142856], - ], - volume7d: 43761816818, tomlInfo: { code: "PHO", issuer: "GAX5TXB5RYJNLBUR477PEXM4X75APK2PGMTN6KEFQSESGWFXEAKFSXJO", - image: "/cryptoIcons/pho.svg", + image: + "https://stellar.myfilebase.com/ipfs/QmPqH2eYHpEcMNSgXYhxagt5LvNntm38wKMUUcGwzzG9qt", decimals: 7, orgName: "Phoenix Protocol Group", - orgLogo: "/cryptoIcons/pho.svg", + orgLogo: + "https://stellar.myfilebase.com/ipfs/QmPqH2eYHpEcMNSgXYhxagt5LvNntm38wKMUUcGwzzG9qt", }, + // @ts-ignore + score: 5.969612342362173, paging_token: 1, }, + tradingVolume7d: [ + { + date: { + day: 27, + month: 3, + year: 2025, + }, + tokenAVolume: "83019829", + tokenBVolume: "131460888", + usdVolume: 3.8091251276122713, + }, + { + date: { + day: 28, + month: 3, + year: 2025, + }, + tokenAVolume: "8343233715", + tokenBVolume: "7138146024", + usdVolume: 310.78726893678584, + }, + { + date: { + day: 29, + month: 3, + year: 2025, + }, + tokenAVolume: "10121051852", + tokenBVolume: "9358239140", + usdVolume: 372.6046178365335, + }, + { + date: { + day: 30, + month: 3, + year: 2025, + }, + tokenAVolume: "5038132716", + tokenBVolume: "5553772553", + usdVolume: 193.87095834153993, + }, + { + date: { + day: 31, + month: 3, + year: 2025, + }, + tokenAVolume: "5368555413", + tokenBVolume: "3391688215", + usdVolume: 179.6781933331256, + }, + { + date: { + day: 1, + month: 4, + year: 2025, + }, + tokenAVolume: "33800044637", + tokenBVolume: "37549025192", + usdVolume: 1230.206597583481, + }, + { + date: { + day: 2, + month: 4, + year: 2025, + }, + tokenAVolume: "11115758214", + tokenBVolume: "8806695209", + usdVolume: 377.9764250510451, + }, + { + date: { + day: 3, + month: 4, + year: 2025, + }, + tokenAVolume: "7482465348", + tokenBVolume: "7419093994", + usdVolume: 313.94371234458447, + }, + ], + userBalance: 0, + pools: [ + { + tokens: [ + { + name: "XLM", + icon: "/cryptoIcons/xlm.svg", + amount: 101269.5131775, + category: "", + usdValue: 0, + // @ts-ignore + address: "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", + }, + { + name: "PHO", + icon: "/cryptoIcons/pho.svg", + amount: 64151.8168754, + category: "", + usdValue: 0, + // @ts-ignore + address: "CBZ7M5B3Y4WWBZ5XK5UZCAFOEZ23KSSZXYECYX3IXM6E2JOLQC52DK32", + }, + ], + // @ts-ignore + tvl: 55456.95296774006, + maxApr: "139.62", + userLiquidity: 0, + poolAddress: "CBCZGGNOEUZG4CAAE7TGTQQHETZMKUT4OIPFHHPKEUX46U4KXBBZ3GLH", + }, + { + tokens: [ + { + name: "PHO", + icon: "/cryptoIcons/pho.svg", + amount: 42983.8944, + category: "", + usdValue: 0, + // @ts-ignore + + address: "CBZ7M5B3Y4WWBZ5XK5UZCAFOEZ23KSSZXYECYX3IXM6E2JOLQC52DK32", + }, + { + name: "USDC", + icon: "/cryptoIcons/usdc.svg", + amount: 17745.4097827, + category: "", + usdValue: 0, + // @ts-ignore + address: "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", + }, + ], + // @ts-ignore + + tvl: 37567.585156540576, + maxApr: "152.17", + userLiquidity: 0, + poolAddress: "CD5XNKK3B6BEF2N7ULNHHGAMOKZ7P6456BFNIHRF4WNTEDKBRWAE7IAA", + }, + ], + loading: false, + }, +}; + +export const NoUserBalance: Story = { + args: { + ...Primary.args, + userBalance: 0, + }, +}; + +export const NoPools: Story = { + args: { + ...Primary.args, + pools: [], + }, +}; + +export const NoRating: Story = { + args: { + ...Primary.args, + asset: { + ...Primary.args.asset, + rating: null, + }, }, }; diff --git a/packages/ui/src/Dashboard/AssetInfoModal/AssetInfoModal.tsx b/packages/ui/src/Dashboard/AssetInfoModal/AssetInfoModal.tsx index f65bf1f0..48f9bb16 100644 --- a/packages/ui/src/Dashboard/AssetInfoModal/AssetInfoModal.tsx +++ b/packages/ui/src/Dashboard/AssetInfoModal/AssetInfoModal.tsx @@ -1,220 +1,1219 @@ -import React, { useCallback } from "react"; +import React, { useState, useCallback } from "react"; import { Box, Typography, Modal as MuiModal, Avatar, - Table, - TableBody, - TableRow, - TableCell, + Tabs, + Tab, + Grid, IconButton, + useMediaQuery, + useTheme, + Tooltip, + CircularProgress, } from "@mui/material"; +import { motion, AnimatePresence } from "framer-motion"; import CloseIcon from "@mui/icons-material/Close"; -import Colors from "../../Theme/colors"; -import { AssetInfoModalProps } from "@phoenix-protocol/types"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import { AssetInfoModalProps, PoolsFilter } from "@phoenix-protocol/types"; +import { + colors, + typography, + spacing, + borderRadius, + shadows, +} from "../../Theme/styleConstants"; +import { + XAxis, + YAxis, + Tooltip as RechartsTooltip, + ResponsiveContainer, + AreaChart, + Area, + BarChart, + Bar, +} from "recharts"; +import { CardContainer } from "../../Common/CardContainer"; +import { TradingVolume } from "@phoenix-protocol/utils/build/trade_api"; +import { PoolItem } from "../../Pools/Pools"; +import { formatCurrencyStatic } from "@phoenix-protocol/utils"; + +// Interface for a tab panel +interface TabPanelProps { + children?: React.ReactNode; + value: number; + index: number; +} + +// Define chart data type +interface ChartDataPoint { + date: string; + value?: number; + volume?: number; +} + +// Helper function to determine color based on rating +const getRatingColor = (rating: number): string => { + if (rating >= 4) return colors.success.main; + if (rating >= 2.5) return colors.warning.main; + return colors.error.main; +}; + +// Helper function to format large numbers +const formatNumber = (num: number): string => { + if (num >= 1000000) return (num / 1000000).toFixed(1) + "M"; + if (num >= 1000) return (num / 1000).toFixed(1) + "K"; + return num.toFixed(1); +}; + +// Calculate total volume +const calculateTotalVolume = (volumes: TradingVolume[]): number => { + return volumes.reduce((total, volume) => { + return total + Number(volume.usdVolume); + }, 0); +}; + +// Helper function to shorten addresses +const shortenAddress = (address: string): string => { + if (!address) return ""; + return `${address.slice(0, 8)}...${address.slice(-8)}`; +}; + +// Data transformations for charts +const prepareVolumeChartData = ( + data: TradingVolume[] | undefined +): ChartDataPoint[] => { + if (!data || !Array.isArray(data)) return []; + + // Map to date and volume minding the date and time object format + return data.map((item) => { + const date = item.date + ? `${item.date.year}-${String(item.date.month).padStart(2, "0")}-${String( + item.date.day + ).padStart(2, "0")}` + : ""; + + return { + date, + volume: Number(item.usdVolume), + }; + }); +}; + +// Data transformations for charts +const preparePriceChartData = ( + data: [number, number][] | undefined +): ChartDataPoint[] => { + if (!data || !Array.isArray(data)) return []; + + return data.map((item) => ({ + date: new Date(item[0]).toLocaleDateString(), + value: item[1], + })); +}; + +// Tab Panel component +const TabPanel = (props: TabPanelProps) => { + const { children, value, index, ...other } = props; + + return ( + + ); +}; + +const AssetInfoModal = ({ + open, + onClose, + asset, + tradingVolume7d, + userBalance = 0, + pools = [], + loading = false, +}: AssetInfoModalProps) => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("md")); + const [tabValue, setTabValue] = useState(0); -const AssetInfoModal = ({ open, onClose, asset }: AssetInfoModalProps) => { + const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { + setTabValue(newValue); + }; + + const handleCopyToClipboard = useCallback((text: string) => { + navigator.clipboard.writeText(text); + }, []); + + // Chart data + const volumeData = prepareVolumeChartData( + (tradingVolume7d as TradingVolume[]) || [] + ); + + const priceData = preparePriceChartData(asset.price7d || []); + + // Modal styles using the app's style constants - consistent with CardContainer const style = { position: "absolute" as "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)", - width: "min(512px, 90%)", - maxWidth: "100vw", - background: "#1F1F1F", - borderRadius: "16px", - boxShadow: "0px 4px 24px rgba(0, 0, 0, 0.6)", + width: { xs: "95%", sm: "90%", md: "80%", lg: "70%" }, + maxWidth: "1000px", + height: { xs: "90vh", md: "80vh", lg: "75vh" }, + background: colors.neutral[900], + border: `1px solid ${colors.neutral[700]}`, + borderRadius: borderRadius.xl, + boxShadow: shadows.card, display: "flex", flexDirection: "column" as "column", - padding: "24px", overflow: "hidden", }; - const handleCopyToClipboard = useCallback((text) => { - navigator.clipboard.writeText(text); - }, []); - - return ( - - - {/* Background Icon */} - {asset.tomlInfo.image && ( - - )} - - {/* Close Button */} - - - + // Chart height constant to ensure consistency - reduced for better space utilization + const chartHeight = 240; + const chartCardHeight = 300; - {/* Header Section */} + // Custom tooltip component for better styling - simplified + const CustomTooltip = ({ active, payload, label }: any) => { + if (active && payload && payload.length) { + return ( - Asset Information + {label} - - - {/* Asset Details */} - - - + - Quick facts about - - {asset.tomlInfo.image && ( - - - - )} + /> - {asset.tomlInfo.code}: + {payload[0].dataKey === "value" ? "Price: " : "Volume: "} + + {payload[0].dataKey === "value" + ? `$${payload[0].value.toFixed(6)}` + : `${formatNumber(payload[0].value)} USDC`} + - - - - - Domain - - + ); + } + return null; + }; + + return ( + + + + + {loading ? ( + + + - {asset.domain} - - - {asset.supply && ( - - - Total Supply - - + + ) : ( + <> + {/* Header */} + + - {(Number(asset.supply) / 10 ** 7).toFixed()} {asset.tomlInfo.image && ( )} - {asset.tomlInfo.code} - - - )} - - - First Transaction - - - {new Date(asset.created * 1000).toLocaleString("en-US", { - timeZone: "UTC", - dateStyle: "medium", - timeStyle: "medium", - }) + " UTC"} - - - - - Trustlines - - - {asset.trustlines[2]} funded / {asset.trustlines[0]} total - - - - - Total Payments - - + + {asset.tomlInfo.code} + {asset.domain && ( + + + + {asset.domain} + + + + )} + + + {userBalance > 0 && ( + + + Balance: {formatNumber(userBalance)}{" "} + {asset.tomlInfo.code} + + )} + + + + + + + + + {/* Content Area */} + - {new Intl.NumberFormat("en-US").format(asset.payments)} - - - -
-
-
+ {/* Tabs */} + + + + + + + + + {/* Loading State or Tab Content */} + {loading ? ( + + + + Loading asset data... + + + ) : ( + + {/* Tab content with scrollable area */} + {/* Overview Tab */} + + + + {/* Quick Stats Row */} + + + + + + Total Supply + + + {asset.supply + ? formatNumber( + Number(asset.supply) / 10 ** 7 + ) + : "N/A"} + + + + + + + + Payments + + + {formatNumber(asset.payments)} + + + + + + + + Trustlines + + + {asset.trustlines[2]} /{" "} + {asset.trustlines[0]} + + + funded / total + + + + + + + + Created + + + {new Date( + asset.created * 1000 + ).toLocaleDateString()} + + + + + + + {/* Issuer Information */} + + + + + Issuer Information + + + stellar.expert + + + + + + + {asset.tomlInfo.issuer} + + + + handleCopyToClipboard( + asset.tomlInfo.issuer + ) + } + sx={{ + color: colors.neutral[400], + "&:hover": { + color: colors.primary.main, + background: `${colors.primary.main}20`, + }, + transition: "all 0.2s ease-in-out", + }} + > + + + + + + + + + + + + + {/* Charts Tab */} + + + {/* Price Chart */} + + + + + Price (USDC) - 7 Days + + + + 7D + + + + + {priceData.length > 0 ? ( + + + + + + + + + + + + } + cursor={{ + stroke: colors.neutral[600], + strokeDasharray: "3 3", + }} + /> + + + + + ) : ( + + + No price data available + + + )} + + + + {/* Volume Chart */} + + + + + Volume - 7 Days + + + + Total:{" "} + {formatNumber( + calculateTotalVolume( + tradingVolume7d as TradingVolume[] + ) + )}{" "} + USDC + + + + + {volumeData.length > 0 ? ( + + + + + + + + + + + + } + cursor={{ + fill: colors.neutral[800], + opacity: 0.3, + }} + /> + + + + + ) : ( + + + No volume data available + + + )} + + + + + + {/* Pools Tab */} + + {pools.length > 0 ? ( + + {pools.map((pool, index) => ( + { + window.location.href = `/pools/${pool.poolAddress}`; + }} + onAddLiquidityClick={() => {}} + /> + ))} + + ) : ( + + + + 💧 + + + + No Liquidity Pools + + + No liquidity pools are currently available for + this asset. Check back later or explore other + assets. + + + )} + + + )} +
+ + )} + + +
); }; diff --git a/packages/ui/src/Dashboard/CryptoCTA/CryptoCTA.stories.tsx b/packages/ui/src/Dashboard/CryptoCTA/CryptoCTA.stories.tsx index 49a42d7c..3dd5d58b 100644 --- a/packages/ui/src/Dashboard/CryptoCTA/CryptoCTA.stories.tsx +++ b/packages/ui/src/Dashboard/CryptoCTA/CryptoCTA.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import CryptoCTA from "./CryptoCTA"; import { Grid } from "@mui/material"; +import React from "react"; // Default metadata of the story https://storybook.js.org/docs/react/api/csf#default-export const meta: Meta = { diff --git a/packages/ui/src/Dashboard/CryptoCTA/CryptoCTA.tsx b/packages/ui/src/Dashboard/CryptoCTA/CryptoCTA.tsx index ab44d678..97235ddc 100644 --- a/packages/ui/src/Dashboard/CryptoCTA/CryptoCTA.tsx +++ b/packages/ui/src/Dashboard/CryptoCTA/CryptoCTA.tsx @@ -7,12 +7,12 @@ const CryptoCTA = ({ onClick }: CryptoCTAProps) => { return ( { Need More Crypto? - + You can easily deposit now! + + + + {/* Type Filter */} + + + Type + + + + + {/* Platform Filter */} + + + Platform + + + + + {/* Instant Unbond Toggle */} + + + } + label={ + + Instant unbond only + + } + sx={{ ml: 0, mr: 0 }} + /> + + + + + + + ); +}; diff --git a/packages/ui/src/Earn/Modals/BondModal.stories.tsx b/packages/ui/src/Earn/Modals/BondModal.stories.tsx new file mode 100644 index 00000000..7fe4f82c --- /dev/null +++ b/packages/ui/src/Earn/Modals/BondModal.stories.tsx @@ -0,0 +1,185 @@ +import React, { useState } from "react"; +import { Meta, StoryObj } from "@storybook/react"; +import { BondModal } from "./BondModal"; +import { Button } from "../../Button/Button"; // Import Button to trigger modal +import { StrategyMetadata, ContractType } from "@phoenix-protocol/strategies"; +import { Token } from "@phoenix-protocol/types"; + +const meta: Meta = { + title: "Earn/Modals/BondModal", + component: BondModal, + parameters: { + layout: "centered", // Center the button that opens the modal + }, + argTypes: { + open: { control: "boolean" }, + onClose: { action: "closed" }, + onConfirm: { action: "confirmed" }, + strategy: { control: "object" }, + }, +}; + +export default meta; + +type Story = StoryObj; + +const mockStrategy: StrategyMetadata = { + id: "mock-strategy-1", + providerId: "mock-provider", + name: "Mock Yield Strategy", + description: "A mock strategy for testing", + assets: [ + { + name: "XLM", + icon: "/cryptoIcons/xlm.svg", + amount: 0, + category: "native", + usdValue: 0.11, + }, + ], + tvl: 100000, + apr: 0.05, + rewardToken: { + name: "PHO", + icon: "/cryptoIcons/pho.svg", + amount: 0, + category: "phoenix", + usdValue: 0.02, + }, + unbondTime: 0, + category: "yield", + available: true, + contractAddress: "MOCK_CONTRACT_ADDRESS", + contractType: "stake" as ContractType, + userStake: 0, + userRewards: 0, + hasJoined: false, +}; + +const mockLPStrategy: StrategyMetadata = { + ...mockStrategy, + id: "mock-lp-strategy", + name: "XLM-USDC Liquidity Pool", + description: "Provide liquidity to earn PHO rewards", + assets: [ + { + name: "XLM", + icon: "/cryptoIcons/xlm.svg", + amount: 0, + category: "native", + usdValue: 0.11, + }, + { + name: "USDC", + icon: "/cryptoIcons/usdc.svg", + amount: 0, + category: "token", + usdValue: 1.0, + }, + ], + category: "farming", + contractType: "pair" as ContractType, +}; + +const mockTripleAssetStrategy: StrategyMetadata = { + ...mockStrategy, + id: "mock-triple-asset-strategy", + name: "Triple Asset Pool", + description: "Provide liquidity to a stable pool", + assets: [ + { + name: "USDC", + icon: "/cryptoIcons/usdc.svg", + amount: 0, + category: "token", + usdValue: 1.0, + }, + { + name: "USDT", + icon: "/cryptoIcons/usdt.svg", + amount: 0, + category: "token", + usdValue: 0.999, + }, + { + name: "DAI", + icon: "/cryptoIcons/dai.svg", + amount: 0, + category: "token", + usdValue: 0.998, + }, + ], + category: "farming", + contractType: "pair" as ContractType, // Using "pair" for triple asset pool too +}; + +const ModalWrapper = (args) => { + const [open, setOpen] = useState(args.open || false); + + const handleOpen = () => setOpen(true); + const handleClose = () => { + setOpen(false); + args.onClose(); // Call the action + }; + const handleConfirm = (tokenAmounts) => { + args.onConfirm(tokenAmounts); // Call the action + console.log("Token amounts:", tokenAmounts); + setOpen(false); // Close modal on confirm + }; + + return ( + <> + + + + ); +}; + +export const Default: Story = { + render: ModalWrapper, + args: { + // Args for the wrapper, not the modal directly + strategy: mockStrategy, + // open: false, // Initial state is closed + }, +}; + +export const PreOpened: Story = { + render: ModalWrapper, + args: { + strategy: mockStrategy, + open: true, // Start with the modal open + }, +}; + +export const LiquidityPair: Story = { + render: ModalWrapper, + args: { + strategy: mockLPStrategy, + }, +}; + +export const PreOpenedLiquidityPair: Story = { + render: ModalWrapper, + args: { + strategy: mockLPStrategy, + open: true, + }, +}; + +export const TripleAssetPool: Story = { + render: ModalWrapper, + args: { + strategy: mockTripleAssetStrategy, + }, +}; diff --git a/packages/ui/src/Earn/Modals/BondModal.tsx b/packages/ui/src/Earn/Modals/BondModal.tsx new file mode 100644 index 00000000..808a6ee2 --- /dev/null +++ b/packages/ui/src/Earn/Modals/BondModal.tsx @@ -0,0 +1,503 @@ +import React, { useState, useEffect, useMemo, useCallback } from "react"; +import { Box, Modal, Typography, useMediaQuery, useTheme } from "@mui/material"; +import { motion } from "framer-motion"; +import { Button } from "../../Button/Button"; +import { TokenBox } from "../../Swap"; // Import TokenBox from Swap components +import { + colors, + typography, + spacing, + borderRadius, +} from "../../Theme/styleConstants"; +import { StrategyMetadata } from "@phoenix-protocol/strategies"; +import CloseIcon from "@mui/icons-material/Close"; +import { Token, StateToken } from "@phoenix-protocol/types"; +import { useAppStore } from "@phoenix-protocol/state"; + +interface BondModalProps { + open: boolean; + onClose: () => void; + strategy: StrategyMetadata | null; + onConfirm: (amounts: { token: Token; amount: number }[]) => void; +} + +export const BondModal = ({ + open, + onClose, + strategy, + onConfirm, +}: BondModalProps) => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("md")); + const appStore = useAppStore(); + + // Track amounts for all assets with an object keyed by token index + const [amounts, setAmounts] = useState<{ [key: number]: string }>({}); + const [error, setError] = useState(""); + // Track user token balances with actual wallet amounts + const [userTokenBalances, setUserTokenBalances] = useState<{ + [key: string]: Token; + }>({}); + + const isPairStrategy = + strategy?.contractType === "pair" && strategy.assets.length > 1; + + // Reset state when modal opens or strategy changes + useEffect(() => { + if (open && strategy) { + const initialAmounts = {}; + strategy.assets.forEach((_, index) => { + initialAmounts[index] = ""; + }); + setAmounts(initialAmounts); + setError(""); + } + }, [open, strategy]); + + // Fetch user token balances when modal opens + useEffect(() => { + const fetchUserBalances = async () => { + if (open && strategy?.assets) { + const balances: { [key: string]: Token } = {}; + + const allAssets = await appStore.getAllTokens(); + // For each asset in the strategy, fetch the user's actual token balance + + strategy.assets.forEach((asset) => { + const userToken = allAssets.find( + (token) => token.name === asset.name + ); + if (userToken) { + balances[asset.name] = userToken; + } + }); + + setUserTokenBalances(balances); + console.log(balances); + } + }; + + fetchUserBalances(); + }, [open, strategy]); + + // Create tokens with user balances for display + const tokensWithUserBalances = useMemo(() => { + if (!strategy?.assets) return []; + + return strategy.assets.map((asset) => { + const userToken = userTokenBalances[asset.name]; + + if (userToken) { + // Convert StateToken balance to displayable amount + const userBalance = Number(userToken.amount); + + // Create a Token object with user's actual balance + const tokenWithUserBalance: Token = { + ...asset, + amount: userBalance, // Use actual user balance instead of pool amount + }; + + return tokenWithUserBalance; + } + + // Fallback to original asset if user balance not found, but set amount to 0 + return { + ...asset, + amount: 0, + }; + }); + }, [strategy?.assets, userTokenBalances]); + + // Calculate token ratios for pair strategies + const tokenRatios = useMemo(() => { + if (!isPairStrategy || !strategy?.assets || strategy.assets.length <= 1) { + return {}; + } + + // Calculate ratios relative to the first token + const ratios = {}; + const baseValue = strategy.assets[0].usdValue || 1; + + strategy.assets.forEach((asset, index) => { + if (index === 0) { + ratios[index] = 1; // Base asset has ratio 1 + } else { + // Calculate how many of this token equals 1 of the base token + ratios[index] = strategy.assets[1].amount / strategy.assets[0].amount; + } + }); + + return ratios; + }, [isPairStrategy, strategy?.assets]); + + // Handle amount changes and maintain ratios for pair strategies + const handleAmountChange = useCallback( + (changedIndex: number, newValue: string) => { + const updatedAmounts = { ...amounts }; + updatedAmounts[changedIndex] = newValue; + + if ( + isPairStrategy && + strategy?.assets && + Object.keys(tokenRatios).length > 0 + ) { + const numValue = parseFloat(newValue); + + if (!isNaN(numValue) && numValue > 0) { + strategy.assets.forEach((_asset, tokenIdx) => { + if (tokenIdx !== changedIndex) { + // Calculate N_j = N_k * (ratios_j / ratios_k) + // N_k is numValue (amount of asset k, at changedIndex) + // N_j is amount of asset j (at tokenIdx) + // ratios_k is tokenRatios[changedIndex] + // ratios_j is tokenRatios[tokenIdx] + if ( + tokenRatios[changedIndex] != null && + tokenRatios[changedIndex] !== 0 && + tokenRatios[tokenIdx] != null + ) { + const relativeRatio = + tokenRatios[tokenIdx] / tokenRatios[changedIndex]; + updatedAmounts[tokenIdx] = (numValue * relativeRatio).toFixed( + 6 + ); + } else { + // Fallback if ratios are not available or division by zero would occur + updatedAmounts[tokenIdx] = ""; + } + } + }); + } else if (newValue === "" || numValue === 0) { + // If the input is cleared or set to 0, clear other fields in a pair strategy + strategy.assets.forEach((_asset, tokenIdx) => { + if (tokenIdx !== changedIndex) { + updatedAmounts[tokenIdx] = ""; + } + }); + } + } + + setAmounts(updatedAmounts); + setError(""); // Clear error when user types + }, + [amounts, isPairStrategy, strategy?.assets, tokenRatios] + ); + + const handleConfirm = () => { + // Validate all required amounts are provided + const tokenAmounts = + strategy?.assets.map((token, index) => { + const amount = amounts[index]; + const numericAmount = parseFloat(amount); + return { + token, + amount: isNaN(numericAmount) ? 0 : numericAmount, + }; + }) || []; + + // Check if any required amount is missing or invalid + const invalidAmount = tokenAmounts.some( + ({ amount }, index) => amount <= 0 && (isPairStrategy || index === 0) // For single asset, only first token required + ); + + if (invalidAmount) { + setError("Please enter valid positive amounts for all required tokens."); + return; + } + + // Validate that user has sufficient balance for each token + const insufficientBalance = tokenAmounts.some(({ amount }, index) => { + const tokenWithBalance = tokensWithUserBalances[index]; + return tokenWithBalance && amount > tokenWithBalance.amount; + }); + + if (insufficientBalance) { + setError("Insufficient balance for one or more tokens."); + return; + } + + // Call the onConfirm handler with the token amounts + onConfirm(tokenAmounts); + + // Reset state + const resetAmounts = {}; + strategy?.assets.forEach((_, index) => { + resetAmounts[index] = ""; + }); + setAmounts(resetAmounts); + setError(""); + // Reload the page + window.location.reload(); + onClose(); + }; + + const handleClose = () => { + // Reset state + const resetAmounts = {}; + strategy?.assets.forEach((_, index) => { + resetAmounts[index] = ""; + }); + setAmounts(resetAmounts); + setError(""); + onClose(); + }; + + // Check if the form is valid for submission + const isFormValid = useMemo(() => { + if (!strategy) return false; + + // For pair strategies, all assets must have valid amounts + if (isPairStrategy) { + return strategy.assets.every((_, index) => { + const amount = parseFloat(amounts[index] || "0"); + return !isNaN(amount) && amount > 0; + }); + } + // For single asset strategies, only the first asset needs a valid amount + else { + const primaryAmount = parseFloat(amounts[0] || "0"); + return !isNaN(primaryAmount) && primaryAmount > 0; + } + }, [strategy, amounts, isPairStrategy]); + + if (!strategy) return null; + + return ( + + + {/* Subtle gradient overlay */} + + + + + + {isPairStrategy ? "Provide Liquidity" : "Bond to"} {strategy.name} + + + + + + + + + + {isPairStrategy + ? `Enter the amount of tokens you want to provide as liquidity to earn rewards.` + : `Enter the amount of ${ + strategy.assets[0]?.name || "tokens" + } you want to bond to start earning.`} + + + {/* Token inputs for all assets */} + {tokensWithUserBalances.map((tokenWithBalance, index) => ( + 0 ? spacing.md : 0, + mb: spacing.sm, + }} + > + + handleAmountChange(index, value)} + token={tokenWithBalance} + hideDropdownButton + /> + + + ))} + + {isPairStrategy && ( + + + 💡 The ratio of tokens will be automatically maintained for + optimal liquidity provision. + + + )} + + {error && ( + + + + {error} + + + + )} + + + + + + ); +}; diff --git a/packages/ui/src/Earn/Modals/ClaimAllModal.stories.tsx b/packages/ui/src/Earn/Modals/ClaimAllModal.stories.tsx new file mode 100644 index 00000000..0b48f4fa --- /dev/null +++ b/packages/ui/src/Earn/Modals/ClaimAllModal.stories.tsx @@ -0,0 +1,205 @@ +import React, { useState, useEffect } from "react"; +import { Meta, StoryObj } from "@storybook/react"; +import { action } from "@storybook/addon-actions"; +import { Box, Button } from "@mui/material"; +import { + Strategy, + StrategyMetadata, + Token, + ContractType, + IndividualStake, +} from "@phoenix-protocol/strategies"; +import { AssembledTransaction } from "@stellar/stellar-sdk/lib/contract"; +import { ClaimAllModal } from "./ClaimAllModal"; // Assuming ClaimAllModalProps is part of this or defined below + +// Replicating ClaimAllModalProps for clarity if not exported directly +interface ClaimAllModalProps { + open: boolean; + onClose: () => void; + claimableStrategies: { + strategy: Strategy; + metadata: StrategyMetadata; + rewards: number; + }[]; + onClaimStrategy: ( + strategy: Strategy, + metadata: StrategyMetadata + ) => Promise; +} + +const mockToken = (name: string, symbol: string): Token => ({ + name, + icon: `/cryptoIcons/${symbol.toLowerCase()}.svg`, + amount: 0, + usdValue: Math.random() * 100, + category: "mock", + address: `C${"X".repeat(55)}`, +}); + +const mockStrategyMetadata = ( + id: string, + name: string, + rewards: number +): StrategyMetadata => ({ + id, + providerId: `mock-provider-${id}`, + name, + description: `This is a mock description for ${name}. It involves staking and earning rewards.`, + assets: [mockToken("Token A", "TKA"), mockToken("Token B", "TKB")], + tvl: Math.random() * 1000000, + apr: Math.random() * 0.25, + rewardToken: mockToken("Reward Token", "RWD"), + unbondTime: 86400 * 7, // 7 days + category: "Liquidity Provision", + available: true, + contractAddress: `CA${id.toUpperCase()}${"X".repeat(50 - id.length)}`, + contractType: "stake" as ContractType, + userStake: rewards > 0 ? Math.random() * 10000 : 0, + userRewards: rewards, + hasJoined: rewards > 0, + userIndividualStakes: [], // Set to empty array to avoid BigInt serialization in Storybook +}); + +const mockStrategy = (metadata: StrategyMetadata): Strategy => ({ + getMetadata: async () => metadata, + getUserStake: async () => metadata.userStake || 0, + hasUserJoined: async () => metadata.hasJoined || false, + getUserRewards: async () => metadata.userRewards || 0, + bond: async () => ({} as unknown as AssembledTransaction), + unbond: async () => ({} as unknown as AssembledTransaction), + claim: async () => ({} as unknown as AssembledTransaction), +}); + +const mockOnClaimStrategy = ( + _strategy: Strategy, + metadata: StrategyMetadata +): Promise => { + action("onClaimStrategy")(metadata.name); + return new Promise((resolve, reject) => { + setTimeout(() => { + if (metadata.name.includes("Fails")) { + action("onClaimStrategy - Error")(metadata.name); + reject(new Error(`Simulated claim failure for ${metadata.name}`)); + } else { + action("onClaimStrategy - Success")(metadata.name); + resolve(); + } + }, 1500 + Math.random() * 1000); + }); +}; + +const meta: Meta = { + title: "Earn/Modals/ClaimAllModal", + component: ClaimAllModal, + argTypes: { + open: { control: "boolean" }, + onClose: { action: "onClose" }, + claimableStrategies: { control: "object" }, + onClaimStrategy: { action: "onClaimStrategy" }, + }, + parameters: { + layout: "centered", + }, + decorators: [ + (Story, context) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [isOpen, setIsOpen] = useState(context.args.open); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + setIsOpen(context.args.open); + }, [context.args.open]); + + const handleOpen = () => setIsOpen(true); + const handleClose = () => { + setIsOpen(false); + action("onClose")(); // Call the Storybook action + }; + + return ( + + {!isOpen && ( + + )} + + + ); + }, + ], +}; + +export default meta; + +type Story = StoryObj; + +const strategyMetas = [ + mockStrategyMetadata("s1", "Alpha Liquidity Pool", 125.5), + mockStrategyMetadata("s2", "Beta Staking (Sometimes Fails)", 75.2), + mockStrategyMetadata("s3", "Gamma Yield Farm", 200.0), + mockStrategyMetadata("s4", "Delta Vault", 50.75), +]; + +const mockClaimableStrategiesData = strategyMetas + .filter((meta) => meta.userRewards && meta.userRewards > 0) + .map((meta) => ({ + strategy: mockStrategy(meta), + metadata: meta, + rewards: meta.userRewards || 0, + })); + +export const Default: Story = { + args: { + open: false, + claimableStrategies: mockClaimableStrategiesData, + onClaimStrategy: mockOnClaimStrategy, + onClose: action("onClose"), + }, +}; + +export const WithFailingStrategy: Story = { + args: { + ...Default.args, + claimableStrategies: [ + { + strategy: mockStrategy( + mockStrategyMetadata("s1-fail", "Stable Yield", 100) + ), + metadata: mockStrategyMetadata("s1-fail", "Stable Yield", 100), + rewards: 100, + }, + { + strategy: mockStrategy( + mockStrategyMetadata("s2-fail", "Risky Bet (Fails)", 50) + ), + metadata: mockStrategyMetadata("s2-fail", "Risky Bet (Fails)", 50), + rewards: 50, + }, + { + strategy: mockStrategy( + mockStrategyMetadata("s3-fail", "Safe Bet", 120) + ), + metadata: mockStrategyMetadata("s3-fail", "Safe Bet", 120), + rewards: 120, + }, + ], + }, +}; + +export const Empty: Story = { + args: { + ...Default.args, + claimableStrategies: [], + }, +}; + +export const SingleItem: Story = { + args: { + ...Default.args, + claimableStrategies: + mockClaimableStrategiesData.length > 0 + ? [mockClaimableStrategiesData[0]] + : [], + }, +}; diff --git a/packages/ui/src/Earn/Modals/ClaimAllModal.tsx b/packages/ui/src/Earn/Modals/ClaimAllModal.tsx new file mode 100644 index 00000000..ad08bbc3 --- /dev/null +++ b/packages/ui/src/Earn/Modals/ClaimAllModal.tsx @@ -0,0 +1,644 @@ +import React, { useEffect, useState, useCallback, useRef } from "react"; +import { + Modal, + Box, + Typography, + CircularProgress, + List, + ListItem, + ListItemIcon, + ListItemText, + useTheme, + useMediaQuery, + IconButton, + Paper, + LinearProgress, + Alert, +} from "@mui/material"; +import { + CheckCircleOutline, + ErrorOutline, + HourglassEmpty, + Close as CloseIcon, + PlayCircleOutline as ClaimIcon, +} from "@mui/icons-material"; +import { motion } from "framer-motion"; +import { Strategy, StrategyMetadata } from "@phoenix-protocol/strategies"; +import { formatCurrencyStatic } from "@phoenix-protocol/utils"; +import { Button } from "../../Button/Button"; +import { + colors, + typography, + spacing, + borderRadius, +} from "../../Theme/styleConstants"; + +type ClaimStatus = "pending" | "claiming" | "success" | "error"; + +interface ClaimableStrategyItem { + strategy: Strategy; + metadata: StrategyMetadata; + rewards: number; + status: ClaimStatus; + error?: string; +} + +interface ClaimAllModalProps { + open: boolean; + onClose: () => void; + claimableStrategies: { + strategy: Strategy; + metadata: StrategyMetadata; + rewards: number; + }[]; + onClaimStrategy: ( + strategy: Strategy, + metadata: StrategyMetadata + ) => Promise; // Function to execute the claim transaction +} + +export const ClaimAllModal = ({ + open, + onClose, + claimableStrategies, + onClaimStrategy, +}: ClaimAllModalProps) => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("md")); + + const [items, setItems] = useState([]); + const itemsRef = useRef([]); + + const [currentIndex, setCurrentIndex] = useState(-1); + const [isClaiming, setIsClaiming] = useState(false); + const [isComplete, setIsComplete] = useState(false); + + useEffect(() => { + if (open) { + // Only re-initialize fully if not in an active claim cycle AND not in a "completed" state. + // A "completed" state should persist until the modal is closed or a new claim cycle is started. + if (!isClaiming && !isComplete) { + const initialItems = claimableStrategies.map((s) => ({ + ...s, + status: "pending" as ClaimStatus, + error: undefined, + })); + setItems(initialItems); + itemsRef.current = initialItems; + setCurrentIndex(-1); + // isClaiming is false, isComplete is false, so no need to set them here. + // If opening for the first time or after a full close and reopen, isComplete would be false. + } + } else { + // When modal is closed, ensure all states are reset for the next opening. + // This helps if the modal is closed mid-operation or in a completed state. + setIsClaiming(false); + setIsComplete(false); + setCurrentIndex(-1); + // setItems([]); // Optionally clear items, or let the open condition repopulate. + // itemsRef.current = []; + } + }, [open, claimableStrategies]); // Removed isClaiming and isComplete from dependencies + + const handleCloseModal = () => { + setIsClaiming(false); // Stop any ongoing claiming process + onClose(); + }; + + useEffect(() => { + if (!isClaiming || currentIndex < 0) { + return; + } + + const currentCycleItems = itemsRef.current; // Use the ref for the logical list + + if (currentIndex >= currentCycleItems.length) { + if (currentCycleItems.length > 0 || currentIndex > 0) { + setIsComplete(true); + } + setIsClaiming(false); // All items processed or list was empty + return; + } + + const itemToProcess = currentCycleItems[currentIndex]; + let mounted = true; + + if (itemToProcess && itemToProcess.status === "pending") { + const doClaim = async (indexBeingProcessed: number) => { + // Update UI state to 'claiming' for the item at indexBeingProcessed + setItems((prevUIItems) => + prevUIItems.map((uiItem, idx) => { + // Ensure we are matching by a stable ID if prevUIItems could be from a different source than itemsRef.current + // However, with the revised first useEffect, prevUIItems should be consistent with itemsRef for statuses. + if (uiItem.metadata.id === itemToProcess.metadata.id) { + // Match by ID + return { ...uiItem, status: "claiming" }; + } + return uiItem; + }) + ); + // Update ref status to 'claiming' + if (itemsRef.current[indexBeingProcessed]) { + // Check bounds + itemsRef.current[indexBeingProcessed].status = "claiming"; + } + + let finalStatus: ClaimStatus; + let finalError: string | undefined; + + try { + await onClaimStrategy(itemToProcess.strategy, itemToProcess.metadata); + finalStatus = "success"; + } catch (error: any) { + finalStatus = "error"; + finalError = error?.message || "An unknown error occurred"; + } + + if (mounted) { + // Update UI state with final status for the item at indexBeingProcessed + setItems((prevUIItems) => + prevUIItems.map((uiItem, idx) => { + if (uiItem.metadata.id === itemToProcess.metadata.id) { + // Match by ID + return { ...uiItem, status: finalStatus, error: finalError }; + } + return uiItem; + }) + ); + // Update ref with final status + if (itemsRef.current[indexBeingProcessed]) { + // Check bounds + itemsRef.current[indexBeingProcessed].status = finalStatus; + itemsRef.current[indexBeingProcessed].error = finalError; + } + + // Always move to next item after processing current one + // The useEffect dependency on isClaiming will handle stopping if needed + setCurrentIndex((prev) => prev + 1); + } + }; + doClaim(currentIndex); + } else if ( + itemToProcess && + itemToProcess.status !== "claiming" && + isClaiming + ) { + // If item is not 'pending' (e.g. already success/error from a previous attempt in this cycle, or bad state) + // and not currently 'claiming', skip to next. + setCurrentIndex((prev) => prev + 1); + } + + return () => { + mounted = false; + }; + }, [isClaiming, currentIndex, onClaimStrategy]); + + const startClaiming = useCallback(() => { + // This re-initializes itemsRef.current and items state for a *new* claiming cycle + const freshCycleItems = claimableStrategies.map((s) => ({ + ...s, + status: "pending" as ClaimStatus, + error: undefined, + })); + itemsRef.current = freshCycleItems; + setItems(freshCycleItems); // Also update UI state to reflect the fresh list + + if (itemsRef.current.length === 0) { + setIsComplete(true); + setIsClaiming(false); // Ensure isClaiming is false if nothing to claim + return; + } + // Do not proceed if already claiming from a previous click. + // This check might be redundant if button is disabled, but good for safety. + if (isClaiming) return; + + setIsComplete(false); + setCurrentIndex(0); // Start from the first item + setIsClaiming(true); // This will trigger the processing useEffect + }, [claimableStrategies, isClaiming]); // isClaiming is a dependency for the safety check + + const totalRewards = items.reduce((sum, item) => sum + item.rewards, 0); + const claimedCount = items.filter((item) => item.status === "success").length; + const errorCount = items.filter((item) => item.status === "error").length; + const progress = + items.length > 0 ? ((claimedCount + errorCount) / items.length) * 100 : 0; + + const renderStatusIcon = (status: ClaimStatus) => { + switch (status) { + case "pending": + return ; + case "claiming": + return ( + + ); + case "success": + return ; + case "error": + return ; + default: + return null; + } + }; + + return ( + + + + + + {isComplete + ? "Claiming Complete" + : isClaiming + ? currentIndex >= 0 && currentIndex < itemsRef.current.length // Use itemsRef for name consistency during cycle + ? `Claiming: ${ + itemsRef.current[currentIndex]?.metadata.name || // Get name from ref + `Item ${currentIndex + 1}` + }` + : "Claiming Rewards..." + : "Claim All Rewards"} + + + + + + + + + + + Total Rewards to Claim:{" "} + + {formatCurrencyStatic.format(totalRewards)} + + + {(isClaiming || isComplete) && ( + + + + + {claimedCount + errorCount} / {items.length} processed + + + + )} + + + + + {items.map((item, index) => ( + + + + {renderStatusIcon(item.status)} + + + {item.metadata.name} + + } + secondary={ + + Rewards: {formatCurrencyStatic.format(item.rewards)} + {item.status === "error" && + item.error && + ` - Error: ${item.error}`} + + } + /> + + + ))} + + + + {!isClaiming && !isComplete && items.length > 0 && ( + + + + )} + {isClaiming && !isComplete && ( + + + Please confirm each transaction in your wallet. + + + )} + {isComplete && ( + + + + Successfully claimed: {claimedCount} strategy(s). + + {errorCount > 0 && ( + + Failed to claim: {errorCount} strategy(s). + + )} + + + + + + )} + {items.length === 0 && !isClaiming && !isComplete && ( + + + 💰 No claimable rewards available. + + + )} + + + + ); +}; diff --git a/packages/ui/src/Earn/Modals/UnbondModal.stories.tsx b/packages/ui/src/Earn/Modals/UnbondModal.stories.tsx new file mode 100644 index 00000000..e088d96a --- /dev/null +++ b/packages/ui/src/Earn/Modals/UnbondModal.stories.tsx @@ -0,0 +1,215 @@ +import React, { useState } from "react"; +import { Meta, StoryObj } from "@storybook/react"; +import { UnbondModal } from "./UnbondModal"; +import { Button } from "../../Button/Button"; +import { + StrategyMetadata, + ContractType, + IndividualStake, +} from "@phoenix-protocol/strategies"; + +const meta: Meta = { + title: "Earn/Modals/UnbondModal", + component: UnbondModal, + parameters: { + layout: "centered", + }, + argTypes: { + open: { control: "boolean" }, + onClose: { action: "closed" }, + onConfirm: { action: "confirmed" }, + strategy: { control: "object" }, + maxAmount: { control: "number" }, + }, +}; + +export default meta; + +type Story = StoryObj; + +const mockStrategyAssets = [ + { + name: "XLM", + icon: "/cryptoIcons/xlm.svg", + amount: 0, + category: "native", + usdValue: 0.11, + }, +]; + +const mockLPAssets = [ + { + name: "XLM", + icon: "/cryptoIcons/xlm.svg", + amount: 0, + category: "native", + usdValue: 0.11, + }, + { + name: "USDC", + icon: "/cryptoIcons/usdc.svg", + amount: 0, + category: "token", + usdValue: 1.0, + }, +]; + +const mockStrategy: StrategyMetadata = { + id: "mock-strategy-1", + providerId: "mock-provider", + name: "Mock Yield Strategy", + description: "A mock strategy for testing", + assets: mockStrategyAssets, + tvl: 100000, + apr: 0.05, + rewardToken: { + name: "PHO", + icon: "/cryptoIcons/pho.svg", + amount: 0, + category: "phoenix", + usdValue: 0.02, + }, + unbondTime: 0, // Instant + category: "yield", + available: true, + contractAddress: "MOCK_CONTRACT_ADDRESS", + contractType: "stake" as ContractType, + userStake: 1000, // Example stake + userRewards: 10, + hasJoined: true, +}; + +const mockStrategyWithLock: StrategyMetadata = { + ...mockStrategy, + id: "mock-strategy-lock", + name: "Mock Locked Strategy", + unbondTime: 604800, // 7 days + userStake: 500, +}; + +const mockLPStrategyWithIndividualStakes: StrategyMetadata = { + id: "mock-lp-strategy-individual", + providerId: "mock-provider", + name: "Mock LP Strategy", + description: "LP strategy with individual stakes", + assets: mockLPAssets, + tvl: 250000, + apr: 0.12, + rewardToken: { + name: "PHO", + icon: "/cryptoIcons/pho.svg", + amount: 0, + category: "phoenix", + usdValue: 0.02, + }, + unbondTime: 0, // Instant for LP example + category: "liquidity", + available: true, + contractAddress: "MOCK_LP_CONTRACT_ADDRESS", + contractType: "pair" as ContractType, + userStake: 1500, // Total USD value of all stakes + userRewards: 25, + hasJoined: true, + userIndividualStakes: [ + { + lpAmount: BigInt("1000000000"), + timestamp: BigInt(Math.floor(new Date("2023-01-15").getTime() / 1000)), + displayAmount: "100.00 LP", + displayDate: "2023-01-15", + }, + { + lpAmount: BigInt("500000000"), + timestamp: BigInt(Math.floor(new Date("2023-02-20").getTime() / 1000)), + displayAmount: "50.00 LP", + displayDate: "2023-02-20", + }, + { + lpAmount: BigInt("2000000000"), + timestamp: BigInt(Math.floor(new Date("2023-03-10").getTime() / 1000)), + displayAmount: "200.00 LP", + displayDate: "2023-03-10", + }, + ], +}; + +const ModalWrapper = (args) => { + const [open, setOpen] = useState(args.open || false); + + const handleOpen = () => setOpen(true); + const handleClose = () => { + setOpen(false); + args.onClose(); + }; + const handleConfirm = ( + params: number | { lpAmount: bigint; timestamp: bigint } + ) => { + args.onConfirm(params); + // Do not close modal here, let the parent (EarnPage) handle it after transaction. + // setOpen(false); + }; + + return ( + <> + + + + ); +}; + +export const Default: Story = { + render: ModalWrapper, + args: { + strategy: mockStrategy, + maxAmount: 1000, + }, +}; + +export const WithLockPeriod: Story = { + render: ModalWrapper, + args: { + strategy: mockStrategyWithLock, + maxAmount: 500, + }, +}; + +export const PreOpened: Story = { + render: ModalWrapper, + args: { + strategy: mockStrategy, + maxAmount: 1000, + open: true, + }, +}; + +export const ZeroMaxAmount: Story = { + render: ModalWrapper, + args: { + strategy: mockStrategy, + maxAmount: 0, + }, +}; + +export const LPStrategyWithIndividualStakes: Story = { + render: ModalWrapper, + args: { + strategy: mockLPStrategyWithIndividualStakes, + maxAmount: mockLPStrategyWithIndividualStakes.userStake, // Total stake value + // open: false, // Default to closed + }, +}; + +export const PreOpenedLPStrategyWithIndividualStakes: Story = { + render: ModalWrapper, + args: { + strategy: mockLPStrategyWithIndividualStakes, + maxAmount: mockLPStrategyWithIndividualStakes.userStake, + open: true, + }, +}; diff --git a/packages/ui/src/Earn/Modals/UnbondModal.tsx b/packages/ui/src/Earn/Modals/UnbondModal.tsx new file mode 100644 index 00000000..0687bb97 --- /dev/null +++ b/packages/ui/src/Earn/Modals/UnbondModal.tsx @@ -0,0 +1,509 @@ +import React, { useState, useEffect } from "react"; +import { + Box, + Modal, + Typography, + TextField, + useMediaQuery, + useTheme, + Link, + List, + ListItem, + ListItemText, + IconButton, + Divider, +} from "@mui/material"; +import { motion } from "framer-motion"; +import { Button } from "../../Button/Button"; +import { + colors, + typography, + spacing, + borderRadius, +} from "../../Theme/styleConstants"; +import { + StrategyMetadata, + IndividualStake, +} from "@phoenix-protocol/strategies"; +import CloseIcon from "@mui/icons-material/Close"; +import { formatCurrencyStatic } from "@phoenix-protocol/utils"; // Assuming you have this utility + +interface UnbondModalProps { + open: boolean; + onClose: () => void; + strategy: StrategyMetadata | null; + maxAmount: number; // User's current total stake in this strategy (USD value) + onConfirm: (params: number | { lpAmount: bigint; timestamp: bigint }) => void; +} + +export const UnbondModal = ({ + open, + onClose, + strategy, + maxAmount, + onConfirm, +}: UnbondModalProps) => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("md")); + const [amount, setAmount] = useState(""); + const [error, setError] = useState(""); + + useEffect(() => { + // Reset state when modal opens or strategy changes + if (open) { + setAmount(""); + setError(""); + } + }, [open, strategy]); + + const handleAmountChange = (event: React.ChangeEvent) => { + const value = event.target.value; + if (/^\d*\.?\d*$/.test(value)) { + setAmount(value); + const numericValue = parseFloat(value); + if (numericValue > maxAmount) { + setError(`Maximum unbond amount is ${maxAmount}`); + } else { + setError(""); + } + } + }; + + const handleSetMax = () => { + setAmount(maxAmount.toString()); + setError(""); // Clear error when setting max + }; + + const handleConfirmAmount = () => { + const numericAmount = parseFloat(amount); + if (isNaN(numericAmount) || numericAmount <= 0) { + setError("Please enter a valid positive amount."); + return; + } + if (numericAmount > maxAmount) { + setError(`Maximum unbond amount is ${maxAmount}`); + return; + } + onConfirm(numericAmount); + onClose(); // Close after confirmation + }; + + const handleConfirmSpecificStake = (stake: IndividualStake) => { + onConfirm({ lpAmount: stake.lpAmount, timestamp: stake.timestamp }); + + // Reload page + window.location.reload(); + // Optionally close modal immediately or wait for parent component to do so after transaction + // onClose(); + }; + + const handleClose = () => { + setAmount(""); + setError(""); + onClose(); + }; + + if (!strategy) return null; + + const showIndividualStakes = + strategy.contractType === "pair" && + strategy.userIndividualStakes && + strategy.userIndividualStakes.length > 0; + + return ( + + + + + + Unbond from {strategy.name} + + + + + + + + + {showIndividualStakes ? ( + <> + + Select a stake to unbond its full amount. + + {strategy.unbondTime > 0 && ( + + + ⏱️ Unbonding period: approximately{" "} + {Math.ceil(strategy.unbondTime / 86400)} days + + + )} + + {strategy.userIndividualStakes!.map((stake, index) => ( + + + + + + } + sx={{ + paddingRight: "100px", + borderRadius: borderRadius.sm, + mb: 1, + "&:hover": { + background: "rgba(249, 115, 22, 0.1)", + }, + }} + > + + + + {index < strategy.userIndividualStakes!.length - 1 && ( + + )} + + ))} + + + ) : ( + <> + + Enter the amount you want to unbond. + + + Available to unbond:{" "} + + {formatCurrencyStatic.format(maxAmount)}{" "} + {strategy.assets.map((a) => a.name).join(" / ")} + + + + {strategy.unbondTime > 0 && ( + + + ⏱️ Unbonding period: approximately{" "} + {Math.ceil(strategy.unbondTime / 86400)} days + + + )} + + + + + + + + + + )} + + + + ); +}; diff --git a/packages/ui/src/Earn/StrategiesTable/StrategiesTable.stories.tsx b/packages/ui/src/Earn/StrategiesTable/StrategiesTable.stories.tsx new file mode 100644 index 00000000..c9a1a859 --- /dev/null +++ b/packages/ui/src/Earn/StrategiesTable/StrategiesTable.stories.tsx @@ -0,0 +1,239 @@ +import React from "react"; +import { Meta, StoryObj } from "@storybook/react"; +import { StrategiesTable } from "./StrategiesTable"; +import { Grid } from "@mui/material"; +import { action } from "@storybook/addon-actions"; +import { StrategyMetadata, ContractType } from "@phoenix-protocol/strategies"; + +const meta: Meta = { + title: "Earn/StrategiesTable", + decorators: [ + (Story) => ( + + + + + + ), + ], + argTypes: { + title: { control: "text" }, + strategies: { control: "object" }, + showFilters: { control: "boolean" }, + isLoading: { control: "boolean" }, + onViewDetails: { action: "viewDetails" }, + onBondClick: { action: "bondClicked" }, + onUnbondClick: { action: "unbondClicked" }, + emptyStateMessage: { control: "text" }, + }, +}; + +export default meta; + +type Story = StoryObj; + +const Template = (args: any) => ; + +export const Default: Story = { + render: Template, + args: { + title: "Discover Strategies", + strategies: [ + { + id: "stellar-yield-strategy", + assets: [ + { + name: "XLM", + icon: "/cryptoIcons/xlm.svg", + amount: 0, + category: "native", + usdValue: 0.11, + }, + ], + name: "Stellar Yield", + description: "Stake XLM to earn PHO rewards", + tvl: 123456, + apr: 0.05, + rewardToken: { + name: "PHO", + icon: "/cryptoIcons/pho.svg", + amount: 0, + category: "phoenix", + usdValue: 0.02, + }, + unbondTime: 0, + isMobile: false, + link: "/earn/stellar-yield-strategy", + category: "yield", + providerId: "stellar", + available: true, + contractAddress: "MOCK_CONTRACT_ADDRESS", + contractType: "stake" as ContractType, + }, + { + id: "phoenix-boost-strategy", + isMobile: false, + assets: [ + { + name: "XLM", + icon: "/cryptoIcons/xlm.svg", + amount: 0, + category: "native", + usdValue: 0.11, + }, + ], + name: "Phoenix Boost", + description: "Stake XLM to earn PHO rewards at a boosted rate", + tvl: 789012, + apr: 0.1, + rewardToken: { + name: "PHO", + icon: "/cryptoIcons/pho.svg", + amount: 0, + category: "phoenix", + usdValue: 0.02, + }, + unbondTime: 604800, + link: "/earn/phoenix-boost-strategy", + category: "staking", + providerId: "phoenix", + available: true, + contractAddress: "MOCK_CONTRACT_ADDRESS", + contractType: "stake" as ContractType, + }, + ], + showFilters: true, + isLoading: false, + onViewDetails: action("viewDetails"), + onBondClick: action("bondClicked"), + onUnbondClick: action("unbondClicked"), + }, +}; + +export const WithUserData: Story = { + render: Template, + args: { + title: "Your Strategies", + strategies: [ + { + id: "stellar-yield-strategy", + assets: [ + { + name: "XLM", + icon: "/cryptoIcons/xlm.svg", + amount: 0, + category: "native", + usdValue: 0.11, + }, + ], + name: "Stellar Yield", + description: "Stake XLM to earn PHO rewards", + tvl: 123456, + apr: 0.05, + rewardToken: { + name: "PHO", + address: "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", + icon: "/cryptoIcons/pho.svg", + amount: 0, + category: "phoenix", + usdValue: 0.02, + }, + unbondTime: 0, + isMobile: false, + link: "/earn/stellar-yield-strategy", + category: "yield", + providerId: "stellar", + hasJoined: true, + userStake: 1000, + userRewards: 25.5, + available: true, + contractAddress: "MOCK_CONTRACT_ADDRESS", + contractType: "stake" as ContractType, + }, + { + id: "phoenix-boost-strategy", + isMobile: false, + assets: [ + { + name: "XLM", + address: "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", + icon: "/cryptoIcons/xlm.svg", + amount: 0, + category: "native", + usdValue: 0.11, + }, + ], + name: "Phoenix Boost", + description: "Stake XLM to earn PHO rewards at a boosted rate", + tvl: 789012, + apr: 0.1, + rewardToken: { + name: "PHO", + address: "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", + icon: "/cryptoIcons/pho.svg", + amount: 0, + category: "phoenix", + usdValue: 0.02, + }, + unbondTime: 604800, + link: "/earn/phoenix-boost-strategy", + category: "staking", + providerId: "phoenix", + hasJoined: true, + userStake: 5000, + userRewards: 75.25, + available: true, + contractAddress: "MOCK_CONTRACT_ADDRESS", + contractType: "stake" as ContractType, + }, + ], + showFilters: false, + isLoading: false, + onViewDetails: action("viewDetails"), + onBondClick: action("bondClicked"), + onUnbondClick: action("unbondClicked"), + }, +}; + +export const Loading: Story = { + render: Template, + args: { + title: "Discover Strategies", + strategies: [], + showFilters: true, + isLoading: true, + onViewDetails: action("viewDetails"), + onBondClick: action("bondClicked"), + onUnbondClick: action("unbondClicked"), + }, +}; + +export const Empty: Story = { + render: Template, + args: { + title: "Your Strategies", + strategies: [], + showFilters: false, + isLoading: false, + onViewDetails: action("viewDetails"), + onBondClick: action("bondClicked"), + onUnbondClick: action("unbondClicked"), + emptyStateMessage: + "You haven't joined any strategies yet. Discover strategies to start earning!", + }, +}; + +export const EmptyDiscover: Story = { + render: Template, + args: { + title: "Discover Strategies", + strategies: [], + showFilters: true, + isLoading: false, + onViewDetails: action("viewDetails"), + onBondClick: action("bondClicked"), + onUnbondClick: action("unbondClicked"), + emptyStateMessage: + "No new strategies to discover at the moment. Check back later!", + }, +}; diff --git a/packages/ui/src/Earn/StrategiesTable/StrategiesTable.tsx b/packages/ui/src/Earn/StrategiesTable/StrategiesTable.tsx new file mode 100644 index 00000000..22bb8c35 --- /dev/null +++ b/packages/ui/src/Earn/StrategiesTable/StrategiesTable.tsx @@ -0,0 +1,441 @@ +import React, { useState, useMemo } from "react"; +import { + Box, + Grid, + Typography, + useMediaQuery, + useTheme, + CircularProgress, + TableSortLabel, +} from "@mui/material"; +import { motion } from "framer-motion"; +import { FilterBar } from "../FilterBar/FilterBar"; +import StrategyEntry from "./StrategyEntry"; +import { StrategyMetadata } from "@phoenix-protocol/strategies"; +import { + colors, + typography, + spacing, + borderRadius, +} from "../../Theme/styleConstants"; + +export interface StrategiesTableProps { + title: string; + strategies: StrategyMetadata[]; + showFilters?: boolean; + isLoading?: boolean; + onViewDetails?: (id: string) => void; + onBondClick: (strategy: StrategyMetadata) => void; + onUnbondClick: (strategy: StrategyMetadata) => void; + emptyStateMessage?: string; +} + +type SortField = "tvl" | "apr" | null; +type SortDirection = "asc" | "desc"; + +export const StrategiesTable = ({ + title, + strategies, + showFilters = true, + isLoading = false, + onViewDetails = () => {}, + onBondClick, + onUnbondClick, + emptyStateMessage = "No strategies match your criteria.", // Default message +}: StrategiesTableProps) => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("md")); + + // Filter states + const [assetsFilter, setAssetsFilter] = useState< + "Your assets" | "All Assets" + >("All Assets"); + const [typeFilter, setTypeFilter] = useState("All"); + const [platformFilter, setPlatformFilter] = useState("All"); + const [instantUnbondOnly, setInstantUnbondOnly] = useState(false); + + // Sort states + const [sortField, setSortField] = useState(null); + const [sortDirection, setSortDirection] = useState("desc"); + + const handleSort = (field: SortField) => { + if (sortField === field) { + // Toggle direction + setSortDirection(sortDirection === "asc" ? "desc" : "asc"); + } else { + // New field, default to descending + setSortField(field); + setSortDirection("desc"); + } + }; + + const types = ["LP Staking", "Single Asset Staking", "Farming"]; + const platforms = ["Phoenix", "External"]; + + // Apply filters and sorting + const filteredStrategies = useMemo(() => { + if (isLoading || !strategies.length) return []; + + let filtered = [...strategies]; + + // Asset filter (would need to be implemented based on user assets) + if (assetsFilter === "Your assets") { + // Check for userAssetMatch with correct null/undefined handling + filtered = filtered.filter((s) => s.hasJoined !== false); + } + + // Type filter + if (typeFilter !== "All") { + filtered = filtered.filter( + (s) => s.category === typeFilter.toLowerCase() + ); + } + + // Platform filter + if (platformFilter !== "All") { + filtered = filtered.filter( + (s) => s.providerId === platformFilter.toLowerCase() + ); + } + + // Instant unbond only + if (instantUnbondOnly) { + filtered = filtered.filter((s) => s.unbondTime === 0); + } + + // Apply sorting + if (sortField) { + filtered.sort((a, b) => { + const valueA = a[sortField] || 0; + const valueB = b[sortField] || 0; + + if (sortDirection === "asc") { + return valueA - valueB; + } else { + return valueB - valueA; + } + }); + } + + return filtered; + }, [ + strategies, + assetsFilter, + typeFilter, + platformFilter, + instantUnbondOnly, + sortField, + sortDirection, + isLoading, + ]); + + // Helper function for table headers + const renderSortableHeader = ( + field: SortField, + label: string, + width: number + ) => ( + + handleSort(field)} + sx={{ + color: colors.neutral[300], + fontSize: typography.fontSize.xs, + fontWeight: typography.fontWeights.bold, + textTransform: "uppercase", + "& .MuiTableSortLabel-icon": { + color: `${colors.neutral[300]} !important`, + }, + }} + > + {label} + + + ); + + return ( + + + {showFilters && ( + setInstantUnbondOnly(value)} + types={types} + platforms={platforms} + /> + )} + + {/* Table header - only show on desktop */} + {!isMobile && ( + + + {/* Assets Column - 2/12 width */} + + + Assets + + + + {/* Strategy Name Column - 2/12 width */} + + + Strategy + + + + {/* TVL Column - sortable, 1/12 width */} + {renderSortableHeader("tvl", "TVL", 1)} + + {/* APR Column - sortable, 1/12 width */} + {renderSortableHeader("apr", "APR", 1)} + + {/* Reward Token Column - 1/12 or 2/12 width */} + + + Reward + + + + {/* Your Stake Column - only show if any strategy has user joined */} + {hasAnyUserJoined(strategies) && ( + + + Your Stake + + + )} + + {/* Claimable Rewards Column - only show if any strategy has user joined */} + {hasAnyUserJoined(strategies) && ( + + + Claimable + + + )} + + {/* Unbond Time Column - only show if no strategy has user joined */} + {!hasAnyUserJoined(strategies) && ( + + + Unbond Time + + + )} + + {/* Action Column - Adjusted to md={2} */} + + + Action + + + + + )} + + {/* Mobile title for strategies - only show on mobile */} + {isMobile && ( + + {filteredStrategies.length} strategies found + + )} + + {/* Loading state */} + {isLoading ? ( + + + + + Loading strategies... + + + + ) : filteredStrategies.length > 0 ? ( + + {filteredStrategies.map((strategy, index) => ( + + + + ))} + + ) : ( + + + + 📊 + + + + No strategies found + + + {emptyStateMessage} + + + )} + + + ); +}; + +// Helper function to check if any strategy has the user joined +const hasAnyUserJoined = (strategies: StrategyMetadata[]): boolean => { + return strategies.some((strategy) => strategy.hasJoined); +}; diff --git a/packages/ui/src/Earn/StrategiesTable/StrategyEntry.tsx b/packages/ui/src/Earn/StrategiesTable/StrategyEntry.tsx new file mode 100644 index 00000000..531e7d41 --- /dev/null +++ b/packages/ui/src/Earn/StrategiesTable/StrategyEntry.tsx @@ -0,0 +1,574 @@ +import React from "react"; +import { Box, Grid, Typography, Tooltip, useTheme, Chip } from "@mui/material"; +import { motion } from "framer-motion"; +import { formatCurrencyStatic } from "@phoenix-protocol/utils"; +import { + colors, + typography, + spacing, + borderRadius, +} from "../../Theme/styleConstants"; +import { Token } from "@phoenix-protocol/types"; +import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; +import { Button } from "../../Button/Button"; // Import Button +import { StrategyMetadata } from "@phoenix-protocol/strategies"; // Import StrategyMetadata + +export interface StrategyEntryProps { + // Use StrategyMetadata directly for clarity + strategy: StrategyMetadata; + isMobile: boolean; + onViewDetails: (id: string) => void; // Keep for potential future use or different click areas + onBondClick: (strategy: StrategyMetadata) => void; // Callback for Bond action + onUnbondClick: (strategy: StrategyMetadata) => void; // Callback for Unbond action +} + +const BoxStyle = { + p: { xs: spacing.md, md: spacing.lg }, + borderRadius: "16px", + background: + "linear-gradient(135deg, rgba(15, 15, 15, 0.9) 0%, rgba(25, 25, 25, 0.9) 100%)", + border: "1px solid rgba(249, 115, 22, 0.1)", + position: "relative", + overflow: "hidden", + boxShadow: "0 4px 20px rgba(0, 0, 0, 0.3)", + marginTop: spacing.md, + transition: "all 0.4s ease", + backdropFilter: "blur(10px)", + "&:hover": { + transform: "translateY(-4px)", + boxShadow: "0 8px 32px rgba(249, 115, 22, 0.2)", + borderColor: "rgba(249, 115, 22, 0.3)", + }, +}; + +const StrategyEntry = ({ + strategy, // Use the full metadata object + isMobile, + onViewDetails, // Keep if needed elsewhere + onBondClick, + onUnbondClick, +}: StrategyEntryProps) => { + const theme = useTheme(); + const { + id, + name, + description, + tvl, + apr, + rewardToken, + unbondTime, + assets, + userStake = 0, + userRewards = 0, + hasJoined = false, + } = strategy; // Destructure metadata + + // Format unbond time for display + const formatUnbondTime = (time: number) => { + if (time === 0) return "Instant"; + const days = Math.floor(time / 86400); + const hours = Math.floor((time % 86400) / 3600); + + if (days > 0) return `${days} days`; + if (hours > 0) return `${hours} hours`; + return "< 1 hour"; + }; + + return ( + // Removed motion.div and onClick from the outer Box to avoid conflicting clicks + + {/* Background asset graphics */} + {assets.map((asset, index) => ( + + ))} + + {/* Content wrapper with z-index */} + + {/* Mobile Layout */} + {isMobile ? ( + + {/* Header with assets and name */} + + + {assets.map((asset, idx) => ( + + ))} + + + {name} + + + + {/* Description if available */} + {description && ( + + {description} + + )} + + {/* Strategy details */} + + + + TVL + + + {formatCurrencyStatic.format(tvl)} + + + + + + APR + + + up to {(apr * 100).toFixed(1)}% + + + + {/* User stake - only show if user has joined */} + {hasJoined && ( + <> + + + {formatCurrencyStatic.format(userStake)} + + + + + + + 0 + ? colors.success[300] + : colors.neutral[300], + fontSize: typography.fontSize.xs, + fontWeight: typography.fontWeights.medium, + }} + > + {userRewards.toFixed(2)} {rewardToken.name} + + + + + )} + + {!hasJoined && ( + <> + + + Reward + + + + + {rewardToken.name} + + + + + + + Unbond Time + + + {formatUnbondTime(unbondTime)} + + + + )} + + + {/* Action Button - Updated for mobile */} + {hasJoined ? ( + + + + + ) : ( + + )} + + ) : ( + /* Desktop Layout */ + + {/* Assets Column */} + + + {assets.map((asset, idx) => ( + + + + {asset.name} + + + ))} + + + + {/* Strategy Name */} + + + + {name} + + + + + {/* TVL */} + + + {formatCurrencyStatic.format(tvl)} + + + + {/* APR */} + + + Up to {(apr * 100).toFixed(1)}% + + + + {/* Reward Token */} + + + + {rewardToken.name} + + + + {/* Your Stake - only show if joined */} + {hasJoined && ( + + + Your Stake + + + {formatCurrencyStatic.format(userStake)} + + + )} + + {/* Claimable rewards - only show if joined */} + {hasJoined && ( + + + Claimable + + + + 0 + ? colors.success[300] + : colors.neutral[300], + }} + > + {userRewards.toFixed(2)} {rewardToken.name} + + + + )} + + {/* Unbond Time - only show if not joined */} + {!hasJoined && ( + + + {formatUnbondTime(unbondTime)} + + + )} + + {/* Action Button Column - Updated for desktop */} + + {hasJoined ? ( + + + + + ) : ( + + + + )} + + + )} + + + ); +}; + +export default StrategyEntry; diff --git a/packages/ui/src/Earn/YieldSummary/YieldSummary.stories.tsx b/packages/ui/src/Earn/YieldSummary/YieldSummary.stories.tsx new file mode 100644 index 00000000..d13a6b03 --- /dev/null +++ b/packages/ui/src/Earn/YieldSummary/YieldSummary.stories.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { Meta, StoryObj } from "@storybook/react"; +import { YieldSummary } from "./YieldSummary"; +import { Grid } from "@mui/material"; + +const meta: Meta = { + title: "Earn/YieldSummary", + decorators: [ + (Story) => ( + + + + + + ), + ], + argTypes: { + totalValue: { control: "number" }, + claimableRewards: { control: "number" }, + onClaimAll: { action: "clicked" }, + }, +}; + +export default meta; + +type Story = StoryObj; + +const Template = (args: any) => ; + +export const Default: Story = { + render: Template, + args: { + totalValue: 12345.67, + claimableRewards: 123.45, + }, +}; diff --git a/packages/ui/src/Earn/YieldSummary/YieldSummary.tsx b/packages/ui/src/Earn/YieldSummary/YieldSummary.tsx new file mode 100644 index 00000000..957503b0 --- /dev/null +++ b/packages/ui/src/Earn/YieldSummary/YieldSummary.tsx @@ -0,0 +1,193 @@ +import React from "react"; +import { Box, Grid, Typography } from "@mui/material"; +import { motion } from "framer-motion"; +import { Button } from "../../Button/Button"; +import { formatCurrencyStatic } from "@phoenix-protocol/utils"; +import { + colors, + typography, + spacing, + borderRadius, +} from "../../Theme/styleConstants"; + +export interface YieldSummaryProps { + totalValue: number; + claimableRewards: number; + onClaimAll: () => void; +} + +export const YieldSummary = ({ + totalValue, + claimableRewards, + onClaimAll, +}: YieldSummaryProps) => { + return ( + + + {/* Background graphic - more subtle */} + + + + + + + Your Staked Assets + + + {formatCurrencyStatic.format(totalValue)} + + + Track and manage your active positions across all yield + strategies + + + + + + + + Claimable Rewards + + 0 ? "#F97316" : colors.neutral[50], + fontWeight: typography.fontWeights.bold, + marginBottom: spacing.md, + background: + claimableRewards > 0 + ? "linear-gradient(135deg, #F97316 0%, #FB923C 100%)" + : "linear-gradient(135deg, #FFFFFF 0%, #F3F4F6 100%)", + WebkitBackgroundClip: "text", + WebkitTextFillColor: "transparent", + backgroundClip: "text", + }} + > + {formatCurrencyStatic.format(claimableRewards)} + + + + + + + + ); +}; diff --git a/packages/ui/src/Earn/index.ts b/packages/ui/src/Earn/index.ts new file mode 100644 index 00000000..984b5ba9 --- /dev/null +++ b/packages/ui/src/Earn/index.ts @@ -0,0 +1,8 @@ +export * from "./FilterBar/FilterBar"; +export * from "./StrategiesTable/StrategiesTable"; +export * from "./YieldSummary/YieldSummary"; +export * from "./EarnPage"; // Assuming EarnPage component is here or moved here +// Export Modals +export * from "./Modals/BondModal"; +export * from "./Modals/UnbondModal"; +export * from "./Modals/ClaimAllModal"; diff --git a/packages/ui/src/HelpCenter/ArticleCard/ArticleCard.stories.tsx b/packages/ui/src/HelpCenter/ArticleCard/ArticleCard.stories.tsx index 87636aff..457152cf 100644 --- a/packages/ui/src/HelpCenter/ArticleCard/ArticleCard.stories.tsx +++ b/packages/ui/src/HelpCenter/ArticleCard/ArticleCard.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import { ArticleCard } from "./ArticleCard"; import example from "./example.json"; import { Grid, Container } from "@mui/material"; +import React from "react"; // Default metadata of the story https://storybook.js.org/docs/react/api/csf#default-export const meta: Meta = { diff --git a/packages/ui/src/HelpCenter/ArticleCard/ArticleCard.tsx b/packages/ui/src/HelpCenter/ArticleCard/ArticleCard.tsx index c461dd91..b5eeeda7 100644 --- a/packages/ui/src/HelpCenter/ArticleCard/ArticleCard.tsx +++ b/packages/ui/src/HelpCenter/ArticleCard/ArticleCard.tsx @@ -20,11 +20,10 @@ const ArticleCard = ({ article }: { article: HelpCenterArticle }) => { { left: "1rem", top: "1rem", borderRadius: "1rem", - border: "1px solid var(--Primary-P3, #E2571C)", - background: "#3D3D3D", + border: "1px solid var(--primary-500, #F97316)", + background: "rgba(226, 73, 26, 0.10)", }} /> { left: "1rem", top: "1rem", borderRadius: "1rem", - border: "1px solid var(--Primary-P3, #E2571C)", + border: "1px solid var(--primary-500, #F97316)", background: "rgba(226, 73, 26, 0.10)", }} > { width="440px" image={`https://phoenix-helpcenter.pockethost.io/api/files/${collectionId}/${id}/${thumbnail}`} alt="Article image" - sx={{ bgcolor: "#2E2E2E" }} + sx={{ bgcolor: "var(--neutral-800, #262626)" }} /> { variant="body2" sx={{ overflow: "hidden", - color: "var(--Secondary-S2-2, #BDBEBE)", + color: "var(--neutral-300, #D4D4D4)", textOverflow: "ellipsis", whiteSpace: "nowrap", fontFamily: "Ubuntu", diff --git a/packages/ui/src/HelpCenter/CategoryCard/CategoryCard.stories.tsx b/packages/ui/src/HelpCenter/CategoryCard/CategoryCard.stories.tsx index 006571b1..5040441e 100644 --- a/packages/ui/src/HelpCenter/CategoryCard/CategoryCard.stories.tsx +++ b/packages/ui/src/HelpCenter/CategoryCard/CategoryCard.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import CategoryCard from "./CategoryCard"; import { Container, Grid } from "@mui/material"; +import React from "react"; // Default metadata of the story https://storybook.js.org/docs/react/api/csf#default-export const meta: Meta = { diff --git a/packages/ui/src/HelpCenter/CategoryCard/CategoryCard.tsx b/packages/ui/src/HelpCenter/CategoryCard/CategoryCard.tsx index e4852481..db059059 100644 --- a/packages/ui/src/HelpCenter/CategoryCard/CategoryCard.tsx +++ b/packages/ui/src/HelpCenter/CategoryCard/CategoryCard.tsx @@ -16,20 +16,20 @@ const CategoryCard = ({ category }: CategoryCardProps) => { { src={thumbnail} sx={{ display: "flex", - width: "5rem", - height: "5rem", + width: "4rem", + height: "4rem", justifyContent: "center", alignItems: "center", flexShrink: 0, @@ -52,16 +52,21 @@ const CategoryCard = ({ category }: CategoryCardProps) => { /> {name} {description} diff --git a/packages/ui/src/Modal/Modal.tsx b/packages/ui/src/Modal/Modal.tsx index cb45850f..fd51df02 100644 --- a/packages/ui/src/Modal/Modal.tsx +++ b/packages/ui/src/Modal/Modal.tsx @@ -6,10 +6,11 @@ import { Modal as MuiModal, Typography, } from "@mui/material"; -import Colors from "../Theme/colors"; import { Button } from "../Button/Button"; import { ModalProps } from "@phoenix-protocol/types"; import SwapAnimation from "./SwapAnimation"; +import { motion } from "framer-motion"; +import { colors, typography, spacing, borderRadius, shadows } from "../Theme/styleConstants"; const Modal = ({ type, @@ -30,42 +31,46 @@ const Modal = ({ transform: "translate(-50%, -50%)", width: 317, maxWidth: "calc(100vw - 16px)", - background: "linear-gradient(180deg, #292B2C 0%, #1F2123 100%)", - borderRadius: "16px", + background: colors.gradients.card, + borderRadius: borderRadius.lg, display: "flex", flexDirection: "column" as "column", - padding: "16px", + padding: spacing.md, + boxShadow: shadows.card, }; const tokenHeaderStyle = { color: "rgba(255, 255, 255, 0.70)", - fontSize: "12px", - fontWeight: 400, + fontSize: typography.fontSize.xs, + fontWeight: typography.fontWeights.regular, + fontFamily: typography.fontFamily, lineHeight: "140%", - marginBottom: "8px", + marginBottom: spacing.xs, }; const tokenIconStyle = { width: "24px", height: "24px", - marginRight: "8px", + marginRight: spacing.xs, }; const tokenAmountStyle = { - color: "#FFF", - fontSize: "14px", - fontWeight: 700, + color: colors.neutral[50], + fontSize: typography.fontSize.sm, + fontWeight: typography.fontWeights.bold, + fontFamily: typography.fontFamily, lineHeight: "140%", }; const getAsset = () => { - if (type == "SUCCESS") { + if (type === "SUCCESS") { return "/check.svg"; - } else if (type == "WARNING") { + } else if (type === "WARNING") { return "/warning.svg"; - } else if (type == "ERROR") { + } else if (type === "ERROR") { return "/cross.svg"; } + return ""; }; const phoIconStyle = { @@ -77,196 +82,203 @@ const Modal = ({ setOpen(false)} - aria-labelledby="connectwallet-modal" - aria-describedby="connect your wallet to the app" + aria-labelledby="modal-title" + aria-describedby="modal-description" > - - - + + + setOpen(false)} - component="img" sx={{ - display: "inline-flex", - justifyContent: "center", - alignItems: "center", - w: "16px", - h: "16px", - backgroundColor: Colors.inputsHover, - borderRadius: "8px", - cursor: "pointer", + display: "flex", + justifyContent: "flex-end", }} - src="/x.svg" - /> - - - {type == "LOADING" || type == "LOADING_SWAP" ? ( + > setOpen(false)} + component="img" sx={{ - h: "98px", - width: type == "LOADING_SWAP" ? "60%" : "98px", - marginBottom: "12px", - display: "flex", + display: "inline-flex", justifyContent: "center", alignItems: "center", + width: "16px", + height: "16px", + backgroundColor: colors.neutral[800], + borderRadius: borderRadius.sm, + cursor: "pointer", }} - > - {type == "LOADING_SWAP" ? ( - // @ts-ignore - - ) : ( - - )} - - ) : ( - - )} - + - {title} - - - {!!tokens && type !== "LOADING_SWAP" && ( - + {type === "LOADING_SWAP" ? ( + // @ts-ignore + + ) : ( + + )} + + ) : ( + + )} + + {title} + + + {!!tokens && type !== "LOADING_SWAP" && ( - - 1 ? 6 : 12}> - - {tokenTitles[0]} - - - - - {tokenAmounts[0]} - - - - {tokens.length > 1 && ( - + + + 1 ? 6 : 12}> - {tokenTitles[1]} + {tokenTitles[0]} - {tokenAmounts[1]} + {tokenAmounts[0]} - )} - + {tokens.length > 1 && ( + + + {tokenTitles[1]} + + + + + {tokenAmounts[1]} + + + + )} + + - - )} + )} - {description && ( - - - {description} - - - )} - {error && ( - - + + {description} + + + )} + {error && ( + + + {error} + + + )} + {onButtonClick && type !== "LOADING_SWAP" && error && ( + + + + - Stake - - - Available {balance.toFixed(2)} + Available: {balance.toFixed(4)} {tokenName} - + ); }; @@ -221,69 +273,190 @@ const ClaimRewards = ({ }) => { const theme = useTheme(); const largerThenMd = useMediaQuery(theme.breakpoints.up("md")); + return ( - - - - Total rewards - - {rewards.length > 0 ? ( - rewards.map((reward, index) => ( + + {/* Glow effect */} + + + + + Total Rewards + + + {rewards.length > 0 ? ( + rewards.map((reward, index) => ( + + 0 ? 1.5 : 0, + p: 2, + borderRadius: "12px", + background: "rgba(249, 115, 22, 0.08)", + border: "1px solid rgba(249, 115, 22, 0.15)", + }} + > + + + {reward.amount} {reward.name} + + + + )) + ) : ( - - - {reward.amount} {reward.name} + + No rewards available - )) - ) : ( - - - No rewards - - - )} + )} + + + + + - - + ); }; @@ -307,35 +480,100 @@ const LiquidityMining = ({ onStake, }: LiquidityMiningProps) => { const [amount, setAmount] = useState(""); + return ( - - - + + {/* Animated background glow */} + - Liquidity Mining - - - Bond liquidity to earn liquidity reward and swap fees - - - - onStake(Number(amount))} - tokenName={tokenName} /> - - - - - + + + + + Liquidity Mining + + + Bond liquidity to earn liquidity rewards and swap fees + + + + onStake(Number(amount))} + tokenName={tokenName} + /> + + + + + + + ); }; diff --git a/packages/ui/src/PoolSingle/Overview/Overview.stories.tsx b/packages/ui/src/PoolSingle/Overview/Overview.stories.tsx index c45aec3d..30163c73 100644 --- a/packages/ui/src/PoolSingle/Overview/Overview.stories.tsx +++ b/packages/ui/src/PoolSingle/Overview/Overview.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import Overview from "./Overview"; +import React from "react"; // Default metadata of the story https://storybook.js.org/docs/react/api/csf#default-export const meta: Meta = { diff --git a/packages/ui/src/PoolSingle/PoolLiquidity/PoolLiquidity.stories.tsx b/packages/ui/src/PoolSingle/PoolLiquidity/PoolLiquidity.stories.tsx index 57b4af6d..dbb9dc25 100644 --- a/packages/ui/src/PoolSingle/PoolLiquidity/PoolLiquidity.stories.tsx +++ b/packages/ui/src/PoolSingle/PoolLiquidity/PoolLiquidity.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import PoolLiquidity from "./PoolLiquidity"; import { Grid } from "@mui/material"; import { testTokens } from "../../Dashboard/WalletBalanceTable/WalletBalanceTable.stories"; +import React from "react"; // Default metadata of the story https://storybook.js.org/docs/react/api/csf#default-export const meta: Meta = { diff --git a/packages/ui/src/PoolSingle/PoolLiquidity/PoolLiquidity.tsx b/packages/ui/src/PoolSingle/PoolLiquidity/PoolLiquidity.tsx index 92d6f9eb..641046cc 100644 --- a/packages/ui/src/PoolSingle/PoolLiquidity/PoolLiquidity.tsx +++ b/packages/ui/src/PoolSingle/PoolLiquidity/PoolLiquidity.tsx @@ -13,6 +13,7 @@ import { TokenBox } from "../../Swap"; import { Button } from "../../Button/Button"; import { LabTabProps, PoolLiquidityProps } from "@phoenix-protocol/types"; import { motion } from "framer-motion"; +import { borderRadius, colors, typography } from "../../Theme/styleConstants"; /** * GlowingChart Component @@ -32,8 +33,8 @@ const GlowingChart = ({ data }: { data: number[][] }) => ( - - + + ( v[1]} - stroke="#E2491A" + stroke="#F97316" strokeWidth={2} filter="url(#neonGlow)" fill="url(#chartFill)" @@ -95,49 +96,90 @@ const LabTabs = ({ const buttonStyles = { flex: 1, maxWidth: "200px", - color: "#FFF", + color: "#FFFFFF", fontSize: "0.875rem", - fontWeight: 700, + fontWeight: 600, textTransform: "none", - borderRadius: "12px", - transition: "all 0.3s", - backgroundImage: - "linear-gradient(95.06deg, rgb(226, 73, 26) 0%, rgb(226, 27, 27) 16.92%, rgb(226, 73, 26) 42.31%, rgb(226, 170, 27) 99.08%)", - "&:hover": { - transform: "scale(1.05)", - }, + borderRadius: "16px", + transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", + fontFamily: "Ubuntu, sans-serif", + py: 1.5, + px: 3, }; return ( - + - - setValue("1")} - sx={{ - ...buttonStyles, - backgroundImage: - value === "1" - ? "linear-gradient(95.06deg, rgb(226, 73, 26) 0%, rgb(226, 27, 27) 16.92%, rgb(226, 73, 26) 42.31%, rgb(226, 170, 27) 99.08%)" - : "none", - filter: value === "1" ? "brightness(1.2)" : "brightness(1)", - }} - > - Add Liquidity - - setValue("2")} - sx={{ - ...buttonStyles, - backgroundImage: - value === "2" - ? "linear-gradient(95.06deg, rgb(226, 73, 26) 0%, rgb(226, 27, 27) 16.92%, rgb(226, 73, 26) 42.31%, rgb(226, 170, 27) 99.08%)" - : "none", - filter: value === "2" ? "brightness(1.2)" : "brightness(1)", - }} - > - Remove Liquidity - + + + setValue("1")} + sx={{ + ...buttonStyles, + background: + value === "1" + ? "linear-gradient(135deg, #F97316 0%, #FB923C 100%)" + : "rgba(249, 115, 22, 0.1)", + border: + value === "1" + ? "1px solid rgba(249, 115, 22, 0.3)" + : "1px solid rgba(249, 115, 22, 0.2)", + boxShadow: + value === "1" + ? "0 8px 32px rgba(249, 115, 22, 0.3), 0 0 0 1px rgba(249, 115, 22, 0.2)" + : "none", + "&:hover": { + background: + value === "1" + ? "linear-gradient(135deg, #F97316 0%, #FB923C 100%)" + : "rgba(249, 115, 22, 0.15)", + border: "1px solid rgba(249, 115, 22, 0.4)", + boxShadow: "0 8px 32px rgba(249, 115, 22, 0.2)", + }, + }} + > + Add Liquidity + + + + + setValue("2")} + sx={{ + ...buttonStyles, + background: + value === "2" + ? "linear-gradient(135deg, #F97316 0%, #FB923C 100%)" + : "rgba(249, 115, 22, 0.1)", + border: + value === "2" + ? "1px solid rgba(249, 115, 22, 0.3)" + : "1px solid rgba(249, 115, 22, 0.2)", + boxShadow: + value === "2" + ? "0 8px 32px rgba(249, 115, 22, 0.3), 0 0 0 1px rgba(249, 115, 22, 0.2)" + : "none", + "&:hover": { + background: + value === "2" + ? "linear-gradient(135deg, #F97316 0%, #FB923C 100%)" + : "rgba(249, 115, 22, 0.15)", + border: "1px solid rgba(249, 115, 22, 0.4)", + boxShadow: "0 8px 32px rgba(249, 115, 22, 0.2)", + }, + }} + > + Remove Liquidity + + @@ -202,79 +244,244 @@ const PoolLiquidity = ({ onRemoveLiquidity, }: PoolLiquidityProps) => { return ( - - - - - - - Pool Liquidity - - - - - - - {tokenA.name} - - - {liquidityA} - - - - + {/* Header with enhanced icons */} + + - {tokenB.name} - - - {liquidityB} - - - - + + + + + + + - Ratio - - - 1:{(liquidityB / liquidityA).toFixed(2)} - + + + + + + + Pool Liquidity + + + {" "} + + + + {tokenA.name} + + + {liquidityA} + + + + + {tokenB.name} + + + {liquidityB} + + + + + Ratio + + + 1:{(liquidityB / liquidityA).toFixed(2)} + + - - - + + + ); }; diff --git a/packages/ui/src/PoolSingle/PoolStats/PoolStats.stories.tsx b/packages/ui/src/PoolSingle/PoolStats/PoolStats.stories.tsx index a02d895f..1f5d33b0 100644 --- a/packages/ui/src/PoolSingle/PoolStats/PoolStats.stories.tsx +++ b/packages/ui/src/PoolSingle/PoolStats/PoolStats.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import PoolStats from "./PoolStats"; import { Grid } from "@mui/material"; +import React from "react"; // Default metadata of the story https://storybook.js.org/docs/react/api/csf#default-export const meta: Meta = { diff --git a/packages/ui/src/PoolSingle/PoolStats/PoolStats.tsx b/packages/ui/src/PoolSingle/PoolStats/PoolStats.tsx index c6115332..03a20d8a 100644 --- a/packages/ui/src/PoolSingle/PoolStats/PoolStats.tsx +++ b/packages/ui/src/PoolSingle/PoolStats/PoolStats.tsx @@ -2,6 +2,7 @@ import React from "react"; import { Box, Grid, Typography, Skeleton } from "@mui/material"; import { PoolStatsProps, PoolStatsBoxProps } from "@phoenix-protocol/types"; import { motion } from "framer-motion"; +import { colors } from "../../Theme/styleConstants"; /** * PoolStatsBox Component @@ -15,43 +16,92 @@ import { motion } from "framer-motion"; const PoolStatsBox = ({ title, value }: PoolStatsBoxProps) => { return ( + {/* Animated glow effect */} + + - {title.toUpperCase()} + {title} + {value} @@ -71,13 +121,37 @@ const PoolStatsBox = ({ title, value }: PoolStatsBoxProps) => { */ const PoolStats = ({ stats }: PoolStatsProps) => { return ( - - {stats.map((stat, key) => ( - - - - ))} - + + + + Pool Statistics + + + + + {stats.map((stat, key) => ( + + + + ))} + + ); }; diff --git a/packages/ui/src/PoolSingle/StakingList/StakingList.stories.tsx b/packages/ui/src/PoolSingle/StakingList/StakingList.stories.tsx index ce2c8658..9d4c4402 100644 --- a/packages/ui/src/PoolSingle/StakingList/StakingList.stories.tsx +++ b/packages/ui/src/PoolSingle/StakingList/StakingList.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import StakingList from "./StakingList"; import { Grid } from "@mui/material"; import { StakingListEntry as Entry } from "@phoenix-protocol/types"; +import React from "react"; // Default metadata of the story https://storybook.js.org/docs/react/api/csf#default-export const meta: Meta = { diff --git a/packages/ui/src/PoolSingle/StakingList/StakingList.tsx b/packages/ui/src/PoolSingle/StakingList/StakingList.tsx index 1039e319..57dba20e 100644 --- a/packages/ui/src/PoolSingle/StakingList/StakingList.tsx +++ b/packages/ui/src/PoolSingle/StakingList/StakingList.tsx @@ -3,6 +3,7 @@ import { Box, Grid, IconButton, Typography, useTheme } from "@mui/material"; import { StakingListEntry as Entry } from "@phoenix-protocol/types"; import { motion } from "framer-motion"; import { ArrowBack } from "@mui/icons-material"; +import { colors } from "../../Theme/styleConstants"; /** * StakingEntry Component @@ -11,75 +12,215 @@ import { ArrowBack } from "@mui/icons-material"; const StakingEntry = ({ entry }: { entry: Entry }) => { return ( - - + {/* Glow effect */} + + + + + sx={{ + width: 40, + height: 40, + borderRadius: "50%", + border: `2px solid ${colors.neutral[600]}`, + background: `linear-gradient(135deg, ${colors.neutral[800]} 0%, ${colors.neutral[700]} 100%)`, + display: "flex", + alignItems: "center", + justifyContent: "center", + mr: 2, + }} + > + + {entry.title} + - - {entry.apr} APR + + APR + + + {entry.apr} + - - Locked: {entry.lockedPeriod} + + Locked - - - - {entry.amount.tokenAmount} (${entry.amount.tokenValueInUsd}) + + {entry.lockedPeriod} - - + - - + + {entry.amount.tokenAmount} + + + ${entry.amount.tokenValueInUsd} + + + + + + - Unstake - - + + + Unstake + + + @@ -93,36 +234,160 @@ const StakingEntry = ({ entry }: { entry: Entry }) => { */ const StakingList = ({ entries }: { entries: Entry[] }) => { return ( - - {/* Header */} - - Your Staked Assets - - {/* List */} - {entries.length > 0 ? ( - entries.map((entry, index) => ( - - )) - ) : ( - + + {/* Enhanced Header */} + - It looks like you haven't staked yet. - - )} - + {/* Glow effect */} + + + + Your Staked Assets + + + Track and manage your staked liquidity positions + + + + {/* Stakes List */} + {entries.length > 0 ? ( + + {entries.map((entry, index) => ( + + + + ))} + + ) : ( + + + + + 📊 + + + + + No Staked Assets + + + Start earning rewards by staking your liquidity tokens above + + + + )} + + ); }; diff --git a/packages/ui/src/Pools/Pools.tsx b/packages/ui/src/Pools/Pools.tsx index 1480f9aa..99912fa2 100644 --- a/packages/ui/src/Pools/Pools.tsx +++ b/packages/ui/src/Pools/Pools.tsx @@ -10,7 +10,6 @@ import { MenuItem, Select, Typography, - Skeleton, } from "@mui/material"; import React, { useMemo, useState } from "react"; import { @@ -19,15 +18,15 @@ import { PoolsProps, } from "@phoenix-protocol/types"; import { motion } from "framer-motion"; +import { + borderRadius, + colors, + spacing, + typography, +} from "../Theme/styleConstants"; /** * Button Component for Pool Filter - * - * @component - * @param {Object} props - The properties for the FilterButton. - * @param {string} props.label - The label of the filter button. - * @param {boolean} props.selected - Whether the button is selected. - * @param {Function} props.onClick - Function to execute on button click. */ const FilterButton = React.memo( ({ @@ -40,33 +39,75 @@ const FilterButton = React.memo( onClick: () => void; }) => { return ( - + + {label} + + + ); } ); -const PoolItem = React.memo( +/** + * Pool Item Component + */ +export const PoolItem = React.memo( ({ pool, filter, @@ -86,51 +127,76 @@ const PoolItem = React.memo( md={4} lg={3} xl={3} - onClick={() => onShowDetailsClick(pool)} component={motion.div} initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={{ duration: 0.4, ease: "easeInOut" }} - whileHover={{ scale: 1.05 }} - whileTap={{ scale: 0.95 }} + whileHover={{ scale: 1.02, y: -4 }} + whileTap={{ scale: 0.98 }} + onClick={() => onShowDetailsClick(pool)} > - {/* Logos in the background */} + {/* Animated Background Glow */} + + {/* Background Token Icons */} + @@ -139,139 +205,268 @@ const PoolItem = React.memo( sx={{ display: "flex", alignItems: "center", - gap: "12px", - marginBottom: "16px", + gap: spacing.md, + marginBottom: spacing.lg, zIndex: 1, + position: "relative", }} > - - - - - {`${pool.tokens[0].name} - ${pool.tokens[1].name}`} - - - - - - - {/* Pool Stats */} - - - + - TVL - - - - + + + + - {pool.tvl} - - - + + + + + + + - Max APR + {`${pool.tokens[0].name}-${pool.tokens[1].name}`} - - - {pool.maxApr} + Liquidity Pool - - {filter === "MY" && ( - <> - - - My Liquidity - - - + + + + {/* Pool Stats with enhanced styling */} + + + + + TVL + + + {pool.tvl} + + + + + Max APR + + - {pool.userLiquidity} + {pool.maxApr} - - - )} - + {parseFloat(pool.maxApr.replace("%", "")) > 50 && ( + + )} + + + {filter === "MY" && ( + <> + + + My Liquidity + + + ${pool.userLiquidity.toFixed(2)} + + + + )} + + + + {/* Action Button */} + + + ); @@ -279,10 +474,7 @@ const PoolItem = React.memo( ); /** - * Pools Overview Component - * - * @component - * @param {PoolsProps} props - The properties for the Pools component. + * Pools Component */ const Pools = ({ pools, @@ -309,16 +501,32 @@ const Pools = ({ const sortedPools = [...filteredPools]; switch (sort) { case "HighTVL": - sortedPools.sort((a, b) => parseFloat(b.tvl) - parseFloat(a.tvl)); + sortedPools.sort( + (a, b) => + parseFloat(b.tvl.replace(/[^0-9.-]+/g, "")) - + parseFloat(a.tvl.replace(/[^0-9.-]+/g, "")) + ); break; case "LowTVL": - sortedPools.sort((a, b) => parseFloat(a.tvl) - parseFloat(b.tvl)); + sortedPools.sort( + (a, b) => + parseFloat(a.tvl.replace(/[^0-9.-]+/g, "")) - + parseFloat(b.tvl.replace(/[^0-9.-]+/g, "")) + ); break; case "HighAPR": - sortedPools.sort((a, b) => parseFloat(b.maxApr) - parseFloat(a.maxApr)); + sortedPools.sort( + (a, b) => + parseFloat(b.maxApr.replace(/[^0-9.-]+/g, "")) - + parseFloat(a.maxApr.replace(/[^0-9.-]+/g, "")) + ); break; case "LowAPR": - sortedPools.sort((a, b) => parseFloat(a.maxApr) - parseFloat(b.maxApr)); + sortedPools.sort( + (a, b) => + parseFloat(a.maxApr.replace(/[^0-9.-]+/g, "")) - + parseFloat(b.maxApr.replace(/[^0-9.-]+/g, "")) + ); break; default: break; @@ -328,38 +536,80 @@ const Pools = ({ }, [pools, searchValue, filter, sort]); return ( - - + {/* Header Section */} + - Pools - - - + + All Pools ({filteredAndSortedPools.length}) + + + {/* Filter Buttons */} + onFilterClick("ALL")} label="All Pools" selected={filter === "ALL"} /> - - - + onFilterClick("MY")} + label="My Pools" + selected={filter === "MY"} + /> + + + + {/* Search and Sort Controls */} + setSearchValue(e.target.value)} sx={{ - width: "100%", - borderRadius: "16px", - border: "1px solid #2D303A", - background: "#1D1F21", - padding: "8px 16px", - lineHeight: "18px", - fontSize: "13px", + flex: 1, + borderRadius: borderRadius.lg, + border: `1px solid ${colors.neutral[700]}`, + background: `linear-gradient(145deg, ${colors.neutral[900]} 0%, ${colors.neutral[850]} 100%)`, + padding: `${spacing.sm} ${spacing.md}`, + lineHeight: "1.5", + fontSize: typography.fontSize.sm, + color: colors.neutral[50], + transition: "all 0.3s ease", + "&:hover": { + borderColor: colors.neutral[600], + background: `linear-gradient(145deg, ${colors.neutral[850]} 0%, ${colors.neutral[800]} 100%)`, + }, + "&:focus-within": { + borderColor: colors.primary.main, + boxShadow: `0 0 0 3px rgba(249, 115, 22, 0.1)`, + }, "&:before": { content: "none", }, @@ -368,20 +618,29 @@ const Pools = ({ }, }} startAdornment={ - search } /> Sort by @@ -400,36 +659,119 @@ const Pools = ({ label="Sort by" value={sort} sx={{ - padding: "16px", - height: "46px", - borderRadius: "16px", - border: "1px solid #2D303A !important", - background: "#1F2123", - fontSize: "14px !important", + padding: `${spacing.sm} ${spacing.md}`, + height: "50px", + borderRadius: borderRadius.lg, + border: `1px solid ${colors.neutral[700]} !important`, + background: `linear-gradient(145deg, ${colors.neutral[900]} 0%, ${colors.neutral[850]} 100%)`, + fontSize: `${typography.fontSize.sm} !important`, + color: colors.neutral[50], + transition: "all 0.3s ease", + "&:hover": { + borderColor: colors.neutral[600], + background: `linear-gradient(145deg, ${colors.neutral[850]} 0%, ${colors.neutral[800]} 100%)`, + }, + "&.Mui-focused": { + borderColor: colors.primary.main, + boxShadow: `0 0 0 3px rgba(249, 115, 22, 0.1)`, + }, + "& .MuiSelect-icon": { + color: colors.neutral[400], + }, }} > - TVL High to Low + + TVL High to Low + - TVL Low to High + + TVL Low to High + - APR High to Low + + APR High to Low + - APR Low to High + + APR Low to High + + + {/* No Results State */} + {filteredAndSortedPools.length === 0 && ( + + + + No pools found + + + {searchValue + ? `Try adjusting your search term "${searchValue}" or browse all available pools.` + : filter === "MY" + ? "You haven't provided liquidity to any pools yet. Start by adding liquidity to earn rewards!" + : "No pools are currently available."} + + + + )} + + {/* Pools Grid */} {filteredAndSortedPools.map((pool, index) => ( onAddLiquidityClick(pool)} - onShowDetailsClick={onShowDetailsClick} + onShowDetailsClick={() => onShowDetailsClick(pool)} pool={pool} /> ))} diff --git a/packages/ui/src/SidebarNavigation/SidebarNavigation.stories.tsx b/packages/ui/src/SidebarNavigation/SidebarNavigation.stories.tsx index 67ff7501..2b4a4647 100644 --- a/packages/ui/src/SidebarNavigation/SidebarNavigation.stories.tsx +++ b/packages/ui/src/SidebarNavigation/SidebarNavigation.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import { SidebarNavigation } from "./SidebarNavigation"; import MailIcon from "@mui/icons-material/Mail"; +import React from "react"; // Default metadata of the story https://storybook.js.org/docs/react/api/csf#default-export const meta: Meta = { diff --git a/packages/ui/src/SidebarNavigation/SidebarNavigation.tsx b/packages/ui/src/SidebarNavigation/SidebarNavigation.tsx index 3c32d32f..978692b4 100644 --- a/packages/ui/src/SidebarNavigation/SidebarNavigation.tsx +++ b/packages/ui/src/SidebarNavigation/SidebarNavigation.tsx @@ -1,4 +1,3 @@ -import Colors from "../Theme/colors"; import React, { useEffect } from "react"; import { CSSObject, styled, Theme } from "@mui/material/styles"; import { @@ -15,7 +14,13 @@ import { useTheme, } from "@mui/material"; import { DrawerProps } from "@phoenix-protocol/types"; -import { motion, MotionProps } from "framer-motion"; // Import MotionProps +import { motion, MotionProps } from "framer-motion"; +import { + colors, + typography, + spacing, + borderRadius, +} from "../Theme/styleConstants"; const drawerWidth = 240; @@ -29,7 +34,7 @@ const openedMixin = (theme: Theme): CSSObject => ({ [theme.breakpoints.down("md")]: { width: "100%", top: "70px", - paddingTop: 2, + paddingTop: spacing.md, }, }); @@ -41,7 +46,7 @@ const closedMixin = (theme: Theme): CSSObject => ({ overflowX: "hidden", width: 0, [theme.breakpoints.up("md")]: { - width: `calc(${theme.spacing(8)} + 1px)`, + width: `calc(${theme.spacing(10)} + 1px)`, // Increased from 8 to 10 }, [theme.breakpoints.down("md")]: { top: "70px", @@ -56,7 +61,6 @@ const DrawerHeader = styled("div")(({ theme }) => ({ justifyContent: "flex-start", })); -// Define props interface for DrawerMotion interface DrawerMotionProps extends MotionProps { open?: boolean; } @@ -103,49 +107,62 @@ const SidebarNavigation = ({ return ( {largerThenMd && ( {open && ( - arrow + )} @@ -153,12 +170,13 @@ const SidebarNavigation = ({ Menu @@ -171,25 +189,34 @@ const SidebarNavigation = ({ open={open} /> {!open && ( - + - arrow + )} {bottomItems && ( - + {items.map((item) => ( - - { - if (!largerThenMd) { - setOpen(false); - } - onNavClick(item.href, item.target); - }} + - - {item.icon} - - { + if (!largerThenMd) { + setOpen(false); + } + onNavClick(item.href, item.target); }} sx={{ - padding: "16px 24px 16px 20px", - opacity: item.active ? 1 : 0.6, + padding: open ? 0 : spacing.xs, + justifyContent: open ? "flex-start" : "center", + minHeight: "40px", }} - primary={item.label} - /> - - + > + + {item.icon} + + + + + ))} ); diff --git a/packages/ui/src/Skeletons/Swap/Swap.tsx b/packages/ui/src/Skeletons/Swap/Swap.tsx index 1ff50627..ccda7026 100644 --- a/packages/ui/src/Skeletons/Swap/Swap.tsx +++ b/packages/ui/src/Skeletons/Swap/Swap.tsx @@ -1,23 +1,26 @@ import { Box, Skeleton, Typography, List, ListItem } from "@mui/material"; import React from "react"; +import { colors, typography, spacing, borderRadius } from "../../Theme/styleConstants"; const listItemContainer = { display: "flex", justifyContent: "space-between", - padding: "8px 0", + padding: `${spacing.xs} 0`, }; const listItemNameStyle = { - color: "var(--content-medium-emphasis, rgba(255, 255, 255, 0.70))", - fontSize: "14px", + color: "rgba(255, 255, 255, 0.70)", + fontSize: typography.fontSize.sm, + fontFamily: typography.fontFamily, lineHeight: "140%", marginBottom: 0, }; const listItemContentStyle = { - color: "#FFF", - fontSize: "14px", - fontWeight: "700", + color: colors.neutral[50], + fontSize: typography.fontSize.sm, + fontFamily: typography.fontFamily, + fontWeight: typography.fontWeights.bold, lineHeight: "140%", }; @@ -28,7 +31,7 @@ export const Swap = () => { width: "100%", display: "flex", flexDirection: "column", - gap: "16px", + gap: spacing.md, }} > {/* Header Section */} @@ -37,34 +40,76 @@ export const Swap = () => { display: "flex", justifyContent: "space-between", alignItems: "center", + background: colors.neutral[900], + borderRadius: borderRadius.lg, + border: `1px solid ${colors.neutral[700]}`, + padding: spacing.md, }} > Swap tokens instantly - + {/* Main Content Section */} {/* Swap Form Section */} - - - - + + + + {/* Swap Details Section */} @@ -72,18 +117,19 @@ export const Swap = () => { sx={{ flex: 1, width: "100%", - padding: "24px", - borderRadius: "12px", - border: "1px solid var(--Secondary-S4, #2C2C31)", - background: - "var(--Secondary-S3, linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.03) 100%))", + padding: spacing.lg, + borderRadius: borderRadius.lg, + border: `1px solid ${colors.neutral[700]}`, + background: colors.neutral[900], }} > Swap Details @@ -97,25 +143,41 @@ export const Swap = () => { Exchange rate - + Protocol fee - + Route - + Slippage tolerance - + diff --git a/packages/ui/src/Swap/AssetSelector/AssetItem.tsx b/packages/ui/src/Swap/AssetSelector/AssetItem.tsx index e2e470b7..1561e98b 100644 --- a/packages/ui/src/Swap/AssetSelector/AssetItem.tsx +++ b/packages/ui/src/Swap/AssetSelector/AssetItem.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Box, Button } from "@mui/material"; import { Token } from "@phoenix-protocol/types"; +import { colors, typography, spacing, borderRadius } from "../../Theme/styleConstants"; /** * AssetItem @@ -26,19 +27,19 @@ const AssetItem = ({ alignItems: "center", justifyContent: "flex-start", width: "100%", - padding: "8px 12px", - borderRadius: "8px", - background: - "linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.03) 100%)", - color: "white", + padding: `${spacing.xs} ${spacing.md}`, + borderRadius: borderRadius.sm, + background: colors.neutral[900], + border: `1px solid ${colors.neutral[700]}`, + color: colors.neutral[300], textTransform: "none", - fontSize: "14px", - fontWeight: 500, - lineHeight: "1.5", + fontSize: typography.fontSize.sm, + fontWeight: typography.fontWeights.medium, + fontFamily: typography.fontFamily, + lineHeight: 1.5, mb: 1, "&:hover": { - background: - "linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%)", + background: colors.neutral[800], }, }} > @@ -49,7 +50,8 @@ const AssetItem = ({ sx={{ width: "24px", height: "24px", - marginRight: "12px", + marginRight: spacing.md, + opacity: 0.7, }} /> {token.name} diff --git a/packages/ui/src/Swap/AssetSelector/AssetSelector.tsx b/packages/ui/src/Swap/AssetSelector/AssetSelector.tsx index 20db8b17..f9099733 100644 --- a/packages/ui/src/Swap/AssetSelector/AssetSelector.tsx +++ b/packages/ui/src/Swap/AssetSelector/AssetSelector.tsx @@ -1,18 +1,18 @@ import React, { useState, useMemo } from "react"; -import { Box, Grid, IconButton, Input, Typography } from "@mui/material"; +import { Box, Grid, IconButton, Typography } from "@mui/material"; import { motion } from "framer-motion"; import { KeyboardArrowLeft } from "@mui/icons-material"; import { AssetSelectorProps } from "@phoenix-protocol/types"; import AssetItem from "./AssetItem"; +import { SearchInput } from "../../Common/SearchInput"; +import { colors, typography, spacing, borderRadius, shadows } from "../../Theme/styleConstants"; +import { CardContainer } from "../../Common/CardContainer"; /** * AssetSelector * A modern and searchable token selector modal with quick select and token list sections. - * - * @param {AssetSelectorProps} props - Props containing token data and event handlers. - * @returns {JSX.Element} */ -const AssetSelector = ({ +export const AssetSelector = ({ tokens, tokensAll, onClose, @@ -38,13 +38,10 @@ const AssetSelector = ({ exit={{ opacity: 0 }} transition={{ duration: 0.3, ease: "easeInOut" }} > - {/* Header */} @@ -52,26 +49,30 @@ const AssetSelector = ({ sx={{ display: "flex", alignItems: "center", - marginBottom: "1rem", + marginBottom: spacing.md, }} > - + Select Token @@ -84,29 +85,11 @@ const AssetSelector = ({ animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }} > - ) => - setSearchValue(e.target.value) - } - sx={{ - width: "100%", - borderRadius: "16px", - border: "1px solid #2D303A", - background: "#1D1F21", - padding: "8px 16px", - color: "white", - fontSize: "14px", - marginBottom: "16px", - "&:before, &:after": { content: "none" }, - }} - startAdornment={ - Search - } + value={searchValue} + onChange={(e) => setSearchValue(e.target.value)} + sx={{ marginBottom: spacing.md }} /> @@ -117,21 +100,14 @@ const AssetSelector = ({ animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3, delay: 0.2 }} > - + Quick select @@ -143,25 +119,19 @@ const AssetSelector = ({ ))} - + )} {/* All Tokens Section */} - + All tokens @@ -174,8 +144,8 @@ const AssetSelector = ({ width: "6px", }, "&::-webkit-scrollbar-thumb": { - backgroundColor: "#E2491A", - borderRadius: "8px", + backgroundColor: colors.primary.main, + borderRadius: borderRadius.sm, }, }} > @@ -189,31 +159,30 @@ const AssetSelector = ({ display: "flex", flexDirection: "column", alignItems: "center", - padding: "1rem", + padding: spacing.md, }} > - We didn’t find any assets for "{searchValue}" + We didn't find any assets for "{searchValue}" )} - - + + ); }; - -export { AssetSelector }; diff --git a/packages/ui/src/Swap/SlippageSettings/SlippageSettings.stories.tsx b/packages/ui/src/Swap/SlippageSettings/SlippageSettings.stories.tsx index b72455af..b50669c3 100644 --- a/packages/ui/src/Swap/SlippageSettings/SlippageSettings.stories.tsx +++ b/packages/ui/src/Swap/SlippageSettings/SlippageSettings.stories.tsx @@ -16,7 +16,7 @@ type Story = StoryObj; export const Primary: Story = { args: { - options: ["1%", "3%", "5%"], + options: [1, 3, 5], selectedOption: 1, }, }; diff --git a/packages/ui/src/Swap/SlippageSettings/SlippageSettings.tsx b/packages/ui/src/Swap/SlippageSettings/SlippageSettings.tsx index b13bf2e6..d48b4194 100644 --- a/packages/ui/src/Swap/SlippageSettings/SlippageSettings.tsx +++ b/packages/ui/src/Swap/SlippageSettings/SlippageSettings.tsx @@ -1,22 +1,25 @@ -import React from "react"; +import React, { useState, useEffect } from "react"; import { Alert, Box, FormControl, - FormControlLabel, - FormLabel, Grid, IconButton, - Input, InputAdornment, - Radio, - RadioGroup, TextField, Typography, + Slider, + Tooltip, } from "@mui/material"; -import { KeyboardArrowLeft } from "@mui/icons-material"; -import Colors from "../../Theme/colors"; +import { KeyboardArrowLeft, InfoOutlined } from "@mui/icons-material"; +import { + colors, + typography, + spacing, + borderRadius, +} from "../../Theme/styleConstants"; import { SlippageOptionsProps } from "@phoenix-protocol/types"; +import { motion, AnimatePresence } from "framer-motion"; const SlippageSettings = ({ options, @@ -24,28 +27,141 @@ const SlippageSettings = ({ onClose, onChange, }: SlippageOptionsProps) => { - const [customInputValue, setCustomInputValue] = React.useState(""); + const [customInputValue, setCustomInputValue] = useState( + selectedOption + ); + const [activeOption, setActiveOption] = useState( + options.includes(selectedOption) ? selectedOption : null + ); + const [customValue, setCustomValue] = useState(false); + const [showWarning, setShowWarning] = useState(false); + + // Set initial values on component mount or when selectedOption changes + useEffect(() => { + setCustomInputValue(selectedOption); + setActiveOption(options.includes(selectedOption) ? selectedOption : null); + }, [selectedOption, options]); + + // Check if slippage is high or too low + const evaluateSlippage = (value: number) => { + if (value > 5) { + return "high"; + } else if (value < 0.5) { + return "low"; + } + return "normal"; + }; + + const slippageStatus = evaluateSlippage(customInputValue); - const handleChange = (optionValue: string) => { - onChange(optionValue !== "custom" ? optionValue : customInputValue); + const handleOptionClick = (optionValue: number) => { + setActiveOption(optionValue); + setCustomInputValue(optionValue); + onChange(optionValue); }; const handleCustomInputChange = (event) => { - const value = Number(event.target.value) > 30 ? "30" : event.target.value; + const value = event.target.value; + + // Validate the input - allow both commas and dots + if (value === "" || /^\d*[.,]?\d*$/.test(value)) { + // Convert commas to dots for proper number parsing + const normalizedValue = value.replace(",", "."); + const numericValue = Number(normalizedValue); + + // If input matches an option, highlight that option + const matchedOption = options.includes(numericValue) + ? numericValue + : null; + setActiveOption(matchedOption); + + if (numericValue > 30) { + setCustomInputValue(30); + onChange(30); + setShowWarning(true); + } else { + // Set the input field to show the original input (with comma or dot) + setCustomInputValue(normalizedValue === "" ? null : numericValue); + onChange(numericValue || 0); + setShowWarning(numericValue > 5); + } + } + }; + + const getSlippageColor = () => { + switch (slippageStatus) { + case "high": + return colors.warning.main; + case "low": + return colors.error.main; + default: + return colors.success[300]; + } + }; - setCustomInputValue(value); - handleChange(value); + const getSlippageMessage = () => { + switch (slippageStatus) { + case "high": + return "High slippage tolerance may lead to unfavorable trades"; + case "low": + return "Transaction may fail due to price movements"; + default: + return null; + } }; + const slippageMessage = getSlippageMessage(); + + const OptionButton = ({ value, isSelected, onClick }) => ( + + onClick(value)} + sx={{ + padding: `${spacing.xs} ${spacing.sm}`, + borderRadius: borderRadius.md, + background: isSelected + ? `rgba(${colors.primary.gradient}, 0.1)` + : colors.neutral[900], + border: `1px solid ${ + isSelected ? colors.primary.main : colors.neutral[700] + }`, + cursor: "pointer", + textAlign: "center", + transition: "all 0.2s ease", + "&:hover": { + borderColor: colors.primary.main, + }, + }} + > + + {value} + + + + ); + return ( - Settings + Slippage Settings - - + Slippage Tolerance + + + + + + + + + Set the maximum price slippage you're willing to accept for your + trades + + + {/* Slippage options */} + + {options.map((option) => ( + + + + ))} + + + {/* Custom option */} + + setCustomValue(true)} + onFocus={() => setCustomValue(true)} + placeholder="Custom" + type="number" + InputProps={{ + endAdornment: ( + + % + + ), + sx: { + color: colors.neutral[50], + fontSize: typography.fontSize.md, + fontWeight: typography.fontWeights.medium, + borderRadius: borderRadius.md, + background: customValue + ? `rgba(${colors.primary.gradient}, 0.05)` + : "transparent", + border: `1px solid ${ + customValue ? colors.primary.main : colors.neutral[700] + }`, + "& .MuiOutlinedInput-notchedOutline": { + border: "none", + }, + "&:hover": { + borderColor: colors.primary.main, + }, + transition: "all 0.2s ease", + }, + }} + /> + + {/* Visual slippage indicator */} + - Select Spread tolerance - - handleChange(e.target.value)} - > - {options.map((option, index) => ( - - } - label={ - - {option} - - } - /> - ))} - + + + + {/* Warning message */} + + {slippageMessage && ( + + + - } - label={ - % - ), - sx: { - minWidth: "140px", - color: "white", - fontSize: "14px", - lineHeight: "16px", - borderRadius: "16px", - "&:hover fieldset": { - border: "1px solid #E2621B!important", - }, - "&:focus-within fieldset, &:focus-visible fieldset": { - border: "2px solid #E2621B!important", - color: "white!important", - }, - }, - }} - /> - } - /> - - + > + {slippageMessage} + + + + )} + ); diff --git a/packages/ui/src/Swap/SwapContainer/SwapAssetsButton.stories.tsx b/packages/ui/src/Swap/SwapContainer/SwapAssetsButton.stories.tsx new file mode 100644 index 00000000..841fb7c6 --- /dev/null +++ b/packages/ui/src/Swap/SwapContainer/SwapAssetsButton.stories.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { StoryFn, Meta } from "@storybook/react"; +import { SwapAssetsButton } from "./SwapAssetsButton"; +import { Box } from "@mui/material"; + +export default { + title: "Swap/SwapAssetsButton", + component: SwapAssetsButton, + parameters: { + layout: "centered", + }, +} as Meta; + +const Template: StoryFn = (args) => ( + + + +); + +export const Default = Template.bind({}); +Default.args = { + onClick: () => console.log("Swap assets clicked"), +}; diff --git a/packages/ui/src/Swap/SwapContainer/SwapAssetsButton.tsx b/packages/ui/src/Swap/SwapContainer/SwapAssetsButton.tsx new file mode 100644 index 00000000..e2f28e0b --- /dev/null +++ b/packages/ui/src/Swap/SwapContainer/SwapAssetsButton.tsx @@ -0,0 +1,60 @@ +import React, { useState } from "react"; +import { motion } from "framer-motion"; +import { colors } from "../../Theme/styleConstants"; + +interface SwapAssetsButtonProps { + onClick: () => void; +} + +export const SwapAssetsButton = ({ onClick }: SwapAssetsButtonProps) => { + const [isSpinning, setIsSpinning] = useState(false); + + const handleClick = () => { + if (!isSpinning) { + onClick(); + setIsSpinning(true); + setTimeout(() => setIsSpinning(false), 1000); // Reset spinning animation after 1 second + } + }; + + return ( + + + + ); +}; diff --git a/packages/ui/src/Swap/SwapContainer/SwapContainer.tsx b/packages/ui/src/Swap/SwapContainer/SwapContainer.tsx index e12f2216..09a3ea9a 100644 --- a/packages/ui/src/Swap/SwapContainer/SwapContainer.tsx +++ b/packages/ui/src/Swap/SwapContainer/SwapContainer.tsx @@ -1,76 +1,18 @@ -import React, { useState } from "react"; -import { Box, IconButton, List, ListItem, Typography } from "@mui/material"; +import React from "react"; +import { Box, List, ListItem, Typography } from "@mui/material"; import { motion } from "framer-motion"; -import { ExpandMore as ExpandMoreIcon } from "@mui/icons-material"; import { TokenBox } from "../TokenBox/TokenBox"; import { Button } from "../../Button/Button"; import { SwapContainerProps } from "@phoenix-protocol/types"; - -const listItemContainer = { - display: "flex", - justifyContent: "space-between", - padding: "8px 0", -}; - -const listItemNameStyle = { - color: "var(--content-medium-emphasis, rgba(255, 255, 255, 0.70))", - fontSize: "14px", - lineHeight: "140%", - marginBottom: 0, -}; - -const listItemContentStyle = { - color: "#FFF", - fontSize: "14px", - fontWeight: "700", - lineHeight: "140%", -}; - -const SwapAssetsButton = ({ onClick }: { onClick: () => void }) => { - const [isSpinning, setIsSpinning] = useState(false); - - const handleClick = () => { - if (!isSpinning) { - onClick(); - setIsSpinning(true); - setTimeout(() => setIsSpinning(false), 1000); // Reset spinning animation after 1 second - } - }; - - return ( - - ); -}; +import { SwapAssetsButton } from "./SwapAssetsButton"; +import { CardContainer } from "../../Common/CardContainer"; +import { SwapDetails } from "./SwapDetails"; +import { + colors, + typography, + spacing, + borderRadius, +} from "../../Theme/styleConstants"; const SwapContainer = ({ fromToken, @@ -95,179 +37,264 @@ const SwapContainer = ({ }: SwapContainerProps) => { return ( {/* Header Section */} Swap tokens instantly - - Options - + Trade tokens with minimal slippage and low fees + {/* Main Content Section */} {/* Swap Form Section */} - -
- onTokenSelectorClick(true)} - onChange={(value) => onInputChange(true, value)} - /> - + +
- - - onTokenSelectorClick(false)} - onChange={(value) => onInputChange(false, value)} - disabled={true} - loadingValues={loadingSimulate} - /> -
- {trustlineButtonActive ? ( - <> - + /> + { sx={{ "& .MuiMenu-paper": { display: "flex", - padding: "0.75rem 1.5rem", + padding: `${spacing.sm} ${spacing.lg}`, flexDirection: "column", - gap: "1.5rem", - borderRadius: "0.5rem", - border: "1px solid #292B2C", - background: "linear-gradient(180deg, #292B2C 0%, #1F2123 100%)", - boxShadow: - "-3px 3px 10px 0px rgba(25, 13, 1, 0.10), -12px 13px 18px 0px rgba(25, 13, 1, 0.09), -26px 30px 24px 0px rgba(25, 13, 1, 0.05), -46px 53px 28px 0px rgba(25, 13, 1, 0.02), -73px 83px 31px 0px rgba(25, 13, 1, 0.00)", + gap: spacing.lg, + borderRadius: borderRadius.md, + border: `1px solid ${colors.neutral[700]}`, + background: colors.neutral[800], + color: colors.neutral[300], + boxShadow: shadows.tooltip, }, }} > @@ -161,45 +144,11 @@ const FilterMenu = ({ activeFilters, applyFilters }: FilterMenuProps) => { display: "flex", justifyContent: "space-between", alignItems: "center", - }} - > - - DATE RANGE - - - - - From - { sx={{ "& .MuiInputBase-root": { alignItems: "center", - color: "rgba(255, 255, 255, 0.70)", - fontFamily: "Ubuntu", - fontSize: "0.875rem", - fontStyle: "normal", - fontWeight: 400, - borderRadius: "1rem", + color: colors.neutral[300], + fontFamily: typography.fontFamily, + fontSize: typography.fontSize.sm, + fontWeight: typography.fontWeights.regular, + borderRadius: borderRadius.lg, minWidth: "unset", + background: colors.neutral[900], + border: `1px solid ${colors.neutral[700]}`, }, "& legend": { display: "none", }, "& .MuiOutlinedInput-notchedOutline": { - border: "1px solid #2D303A", - top: "-2px", + border: "none", }, }} /> @@ -246,12 +195,11 @@ const FilterMenu = ({ activeFilters, applyFilters }: FilterMenuProps) => { @@ -271,7 +219,6 @@ const FilterMenu = ({ activeFilters, applyFilters }: FilterMenuProps) => { onChange={(newValue: dayjs.Dayjs | null) => setDateRange({ ...dateRange, - // @ts-ignore to: newValue === null ? undefined : newValue.toDate(), }) } @@ -282,13 +229,14 @@ const FilterMenu = ({ activeFilters, applyFilters }: FilterMenuProps) => { sx={{ "& .MuiInputBase-root": { alignItems: "center", - color: "rgba(255, 255, 255, 0.70)", - fontFamily: "Ubuntu", - fontSize: "0.875rem", - fontStyle: "normal", - fontWeight: 400, - borderRadius: "1rem", + color: colors.neutral[300], + fontFamily: typography.fontFamily, + fontSize: typography.fontSize.sm, + fontWeight: typography.fontWeights.regular, + borderRadius: borderRadius.lg, minWidth: "unset", + background: colors.neutral[900], + border: `1px solid ${colors.neutral[700]}`, }, "& .MuiTextField-root": { minWidth: "0 !important", @@ -297,8 +245,7 @@ const FilterMenu = ({ activeFilters, applyFilters }: FilterMenuProps) => { display: "none", }, "& .MuiOutlinedInput-notchedOutline": { - border: "1px solid #2D303A", - top: "-2px", + border: "none", }, }} /> @@ -306,392 +253,219 @@ const FilterMenu = ({ activeFilters, applyFilters }: FilterMenuProps) => { - - + + + {/* Trade Size Filter */} + - TRADE SIZE + Trade Size - - - - - - From - - - - setTradeSize({ - ...tradeSize, - from: parseInt(e.target.value), - }) - } - sx={{ - marginTop: "0.5rem", - width: "100%", - "& .MuiInputBase-root": { - margin: 0, - alignItems: "center", - color: "rgba(255, 255, 255, 0.70)", - fontFamily: "Ubuntu", - fontSize: "0.875rem", - fontStyle: "normal", - fontWeight: 400, - borderRadius: "1rem", - "& .MuiOutlinedInput-input": { - padding: "0.5rem 0.75rem 0.75rem 0.75rem", - margin: 0, - }, - }, - "& legend": { - display: "none", - }, - "& .MuiOutlinedInput-notchedOutline": { - border: "1px solid #2D303A", - }, - }} - /> - - - - To - - - setTradeSize({ - ...tradeSize, - to: parseInt(e.target.value), - }) - } - fullWidth - sx={{ - marginTop: "0.5rem", - width: "100%", - "& .MuiInputBase-root": { - margin: 0, - alignItems: "center", - color: "rgba(255, 255, 255, 0.70)", - fontFamily: "Ubuntu", - fontSize: "0.875rem", - fontStyle: "normal", - fontWeight: 400, - borderRadius: "1rem", - "& .MuiOutlinedInput-input": { - padding: "0.5rem 0.75rem 0.75rem 0.75rem", - margin: 0, - }, - }, - "& legend": { - display: "none", - }, - "& .MuiOutlinedInput-notchedOutline": { - border: "1px solid #2D303A", - }, - }} - /> + + + From + + + setTradeSize({ + ...tradeSize, + from: + e.target.value === "" + ? undefined + : Number(e.target.value), + }) + } + placeholder="Min" + style={{ + width: "100%", + background: colors.neutral[900], + border: `1px solid ${colors.neutral[700]}`, + color: colors.neutral[300], + padding: "8px 12px", + borderRadius: borderRadius.lg, + fontFamily: typography.fontFamily, + fontSize: typography.fontSize.sm, + outline: "none", + }} + /> + + + + To + + + setTradeSize({ + ...tradeSize, + to: + e.target.value === "" + ? undefined + : Number(e.target.value), + }) + } + placeholder="Max" + style={{ + width: "100%", + background: colors.neutral[900], + border: `1px solid ${colors.neutral[700]}`, + color: colors.neutral[300], + padding: "8px 12px", + borderRadius: borderRadius.lg, + fontFamily: typography.fontFamily, + fontSize: typography.fontSize.sm, + outline: "none", + }} + /> + - - + + + + {/* Trade Value Filter */} + - TRADE VALUE + Trade Value (USD) - - - - - - From - - - - setTradeValue({ - ...tradeValue, - from: parseInt(e.target.value), - }) - } - sx={{ - marginTop: "0.5rem", - width: "100%", - "& .MuiInputBase-root": { - margin: 0, - alignItems: "center", - color: "rgba(255, 255, 255, 0.70)", - fontFamily: "Ubuntu", - fontSize: "0.875rem", - fontStyle: "normal", - fontWeight: 400, - borderRadius: "1rem", - "& .MuiOutlinedInput-input": { - padding: "0.5rem 0.75rem 0.75rem 0.75rem", - margin: 0, - }, - }, - "& legend": { - display: "none", - }, - "& .MuiOutlinedInput-notchedOutline": { - border: "1px solid #2D303A", - }, - }} - /> - - - - To - - - setTradeValue({ - ...tradeValue, - to: parseInt(e.target.value), - }) - } - fullWidth - sx={{ - marginTop: "0.5rem", - width: "100%", - "& .MuiInputBase-root": { - margin: 0, - alignItems: "center", - color: "rgba(255, 255, 255, 0.70)", - fontFamily: "Ubuntu", - fontSize: "0.875rem", - fontStyle: "normal", - fontWeight: 400, - borderRadius: "1rem", - "& .MuiOutlinedInput-input": { - padding: "0.5rem 0.75rem 0.75rem 0.75rem", - margin: 0, - }, - }, - "& legend": { - display: "none", - }, - "& .MuiOutlinedInput-notchedOutline": { - border: "1px solid #2D303A", - }, - }} - /> + + + From + + + setTradeValue({ + ...tradeValue, + from: + e.target.value === "" + ? undefined + : Number(e.target.value), + }) + } + placeholder="Min" + style={{ + width: "100%", + background: colors.neutral[900], + border: `1px solid ${colors.neutral[700]}`, + color: colors.neutral[300], + padding: "8px 12px", + borderRadius: borderRadius.lg, + fontFamily: typography.fontFamily, + fontSize: typography.fontSize.sm, + outline: "none", + }} + /> + + + + To + + + setTradeValue({ + ...tradeValue, + to: + e.target.value === "" + ? undefined + : Number(e.target.value), + }) + } + placeholder="Max" + style={{ + width: "100%", + background: colors.neutral[900], + border: `1px solid ${colors.neutral[700]}`, + color: colors.neutral[300], + padding: "8px 12px", + borderRadius: borderRadius.lg, + fontFamily: typography.fontFamily, + fontSize: typography.fontSize.sm, + outline: "none", + }} + /> + - - updateFilters()} /> - - resetFilters()} - /> + + +
diff --git a/packages/ui/src/Transactions/TransactionsTable/TransactionEntry.tsx b/packages/ui/src/Transactions/TransactionsTable/TransactionEntry.tsx index 12f9b9a7..1ff1a3cc 100644 --- a/packages/ui/src/Transactions/TransactionsTable/TransactionEntry.tsx +++ b/packages/ui/src/Transactions/TransactionsTable/TransactionEntry.tsx @@ -1,8 +1,8 @@ import React from "react"; import { Box, Grid, Typography, useMediaQuery } from "@mui/material"; -import { ArrowForward, ArrowBack } from "@mui/icons-material"; -import { TransactionTableEntryProps } from "@phoenix-protocol/types"; +import { ArrowForward } from "@mui/icons-material"; import LaunchIcon from "@mui/icons-material/Launch"; +import { TransactionTableEntryProps } from "@phoenix-protocol/types"; const TransactionEntry = ( props: TransactionTableEntryProps & { isMobile: boolean } @@ -10,9 +10,9 @@ const TransactionEntry = ( const { isMobile } = props; // Destructure isMobile const BoxStyle = { p: 3, - borderRadius: "8px", - background: - "linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.03) 100%)", + borderRadius: "12px", // Adjusted border radius + background: "var(--neutral-900, #171717)", // Adjusted background + border: "1px solid var(--neutral-700, #404040)", // Adjusted border position: "relative", overflow: "hidden", boxShadow: "2px 2px 4px rgba(0, 0, 0, 0.3)", @@ -64,6 +64,7 @@ const TransactionEntry = ( fontSize: "12px", fontWeight: "700", textTransform: "uppercase", + color: "var(--neutral-400, #A3A3A3)", // Adjusted color opacity: 0.6, }} > @@ -72,7 +73,7 @@ const TransactionEntry = ( )} @@ -106,20 +108,25 @@ const TransactionEntry = ( component="img" src={props.fromAsset.icon} alt={props.fromAsset.name} - sx={{ width: "20px", height: "20px", mr: "0.5rem" }} + sx={{ + width: "20px", + height: "20px", + mr: "0.5rem", + opacity: 0.7, + }} // Adjusted opacity /> {props.fromAmount} {props.toAmount} @@ -187,6 +202,7 @@ const TransactionEntry = ( sx={{ fontSize: isMobile ? "12px" : "14px", fontWeight: "400", + color: "var(--neutral-300, #D4D4D4)", // Adjusted color }} > ${props.tradeValue} @@ -199,6 +215,7 @@ const TransactionEntry = ( fontSize: "12px", fontWeight: "700", textTransform: "uppercase", + color: "var(--neutral-400, #A3A3A3)", // Adjusted color opacity: 0.6, }} > @@ -219,14 +236,19 @@ const TransactionEntry = ( fontWeight: "400", textDecoration: "underline", textDecorationStyle: "dotted", + color: "var(--neutral-300, #D4D4D4)", // Adjusted color "&:hover": { textDecoration: "underline", cursor: "pointer", }, }} > - {props.txHash} diff --git a/packages/ui/src/Transactions/TransactionsTable/TransactionsHeader.tsx b/packages/ui/src/Transactions/TransactionsTable/TransactionsHeader.tsx index 09f31286..466b02d4 100644 --- a/packages/ui/src/Transactions/TransactionsTable/TransactionsHeader.tsx +++ b/packages/ui/src/Transactions/TransactionsTable/TransactionsHeader.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Box, Typography } from "@mui/material"; import { ArrowDownward, SwapVert } from "@mui/icons-material"; +import { colors, typography } from "../../Theme/styleConstants"; function convertToCamelCase(input: string): string { return input @@ -31,10 +32,11 @@ const TransactionHeader = ({ > ) : ( - + ))} ); diff --git a/packages/ui/src/Transactions/TransactionsTable/TransactionsTable.stories.tsx b/packages/ui/src/Transactions/TransactionsTable/TransactionsTable.stories.tsx index 66f1d7b1..8b3d4ae2 100644 --- a/packages/ui/src/Transactions/TransactionsTable/TransactionsTable.stories.tsx +++ b/packages/ui/src/Transactions/TransactionsTable/TransactionsTable.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { TransactionsTable } from "./TransactionsTable"; +import React from "react"; // Default metadata of the story https://storybook.js.org/docs/react/api/csf#default-export const meta: Meta = { diff --git a/packages/ui/src/Transactions/TransactionsTable/TransactionsTable.tsx b/packages/ui/src/Transactions/TransactionsTable/TransactionsTable.tsx index acb897d8..24f7dc3f 100644 --- a/packages/ui/src/Transactions/TransactionsTable/TransactionsTable.tsx +++ b/packages/ui/src/Transactions/TransactionsTable/TransactionsTable.tsx @@ -6,63 +6,114 @@ import { TransactionsTableProps } from "@phoenix-protocol/types"; import TransactionEntry from "./TransactionEntry"; import TransactionHeader from "./TransactionsHeader"; import { maxWidth } from "@mui/system"; +import { + colors, + typography, + spacing, + borderRadius, +} from "../../Theme/styleConstants"; const customSpacing = { - xs: "8px", - sm: "12px", - md: "16px", + xs: spacing.xs, + sm: spacing.sm, + md: spacing.md, }; const classes = { root: { marginTop: customSpacing.md, padding: `${customSpacing.md} ${customSpacing.md}`, - borderRadius: "8px", - background: - "linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.03) 100%)", + borderRadius: "20px", + background: ` + linear-gradient(135deg, rgba(23, 23, 23, 0.95) 0%, rgba(38, 38, 38, 0.85) 100%) + `, + backdropFilter: "blur(20px)", + border: "1px solid rgba(249, 115, 22, 0.2)", overflowX: "auto", + boxShadow: + "0 20px 40px rgba(0, 0, 0, 0.4), 0 0 30px rgba(249, 115, 22, 0.1)", + position: "relative", + "&::before": { + content: '""', + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + background: + "linear-gradient(135deg, rgba(249, 115, 22, 0.03) 0%, rgba(234, 88, 12, 0.02) 100%)", + borderRadius: "20px", + pointerEvents: "none", + }, }, tabUnselected: { display: "flex", - width: "2.75rem", - height: "2.3125rem", - padding: `${customSpacing.md} ${customSpacing.sm}`, + width: "auto", + minWidth: "80px", + height: "40px", + padding: `12px 20px`, justifyContent: "center", alignItems: "center", gap: "0.625rem", - borderRadius: "1rem", + borderRadius: "12px", cursor: "pointer", - background: - "linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.03) 100%)", - color: "#FFF", - opacity: 0.6, + color: "rgba(255, 255, 255, 0.7)", + background: "rgba(23, 23, 23, 0.8)", + backdropFilter: "blur(10px)", + opacity: 0.9, textAlign: "center", fontFeatureSettings: "'clig' off, 'liga' off", - fontFamily: "Ubuntu", - fontSize: "0.625rem", + fontFamily: typography.fontFamily, + fontSize: "14px", fontStyle: "normal", - fontWeight: 700, - lineHeight: "1.25rem", // 200% + fontWeight: typography.fontWeights.medium, + lineHeight: "1.25rem", + border: "1px solid rgba(255, 255, 255, 0.1)", + transition: "all 0.3s ease", + "&:hover": { + background: "rgba(249, 115, 22, 0.15)", + border: "1px solid rgba(249, 115, 22, 0.3)", + color: "rgba(255, 255, 255, 0.9)", + transform: "translateY(-1px)", + boxShadow: "0 4px 12px rgba(249, 115, 22, 0.2)", + }, }, tabSelected: { display: "flex", - height: "2.25rem", - padding: `${customSpacing.md} ${customSpacing.sm}`, + minWidth: "80px", + height: "40px", + padding: `12px 20px`, justifyContent: "center", alignItems: "center", gap: "0.625rem", flex: "1 0 0", - borderRadius: "1rem", - border: "1px solid var(--Primary-P3, #E2571C)", - background: "rgba(226, 73, 26, 0.10)", - color: "#FFF", + borderRadius: "12px", + border: "1px solid rgba(249, 115, 22, 0.8)", + background: ` + linear-gradient(135deg, rgba(249, 115, 22, 0.25) 0%, rgba(234, 88, 12, 0.15) 100%) + `, + backdropFilter: "blur(10px)", + color: "#FAFAFA", textAlign: "center", fontFeatureSettings: "'clig' off, 'liga' off", - fontFamily: "Ubuntu", - fontSize: "0.625rem", + fontFamily: typography.fontFamily, + fontSize: "14px", fontStyle: "normal", - fontWeight: 700, - lineHeight: "1.25rem", // 200% + fontWeight: typography.fontWeights.bold, + lineHeight: "1.25rem", + boxShadow: + "0 0 20px rgba(249, 115, 22, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.1)", + animation: "glow 2s ease-in-out infinite alternate", + "@keyframes glow": { + "0%": { + boxShadow: + "0 0 20px rgba(249, 115, 22, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.1)", + }, + "100%": { + boxShadow: + "0 0 30px rgba(249, 115, 22, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.2)", + }, + }, }, }; @@ -154,12 +205,29 @@ const TransactionsTable = ({
{!isMobile && ( // Hide header on mobile @@ -219,20 +287,66 @@ const TransactionsTable = ({ ))} {entries.length === 0 && ( - - + + + 📊 + + + + + {activeView === "personal" + ? "No Personal Transactions" + : "No Transactions Found"} + + + {activeView === "personal" - ? "It looks like you haven't made any transactions yet." - : "No transactions found."} + ? "It looks like you haven't made any transactions yet. Start trading to see your transaction history here." + : "No transactions match your current filters. Try adjusting your search criteria or check back later."} )} diff --git a/packages/ui/src/Transactions/VolumeChart/VolumeChart.stories.tsx b/packages/ui/src/Transactions/VolumeChart/VolumeChart.stories.tsx index 2cb5b4f6..9aa963c2 100644 --- a/packages/ui/src/Transactions/VolumeChart/VolumeChart.stories.tsx +++ b/packages/ui/src/Transactions/VolumeChart/VolumeChart.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { VolumeChart } from "./VolumeChart"; +import React from "react"; // Default metadata of the story https://storybook.js.org/docs/react/api/csf#default-export const meta: Meta = { diff --git a/packages/ui/src/Transactions/VolumeChart/VolumeChart.tsx b/packages/ui/src/Transactions/VolumeChart/VolumeChart.tsx index 96028d9b..f585b868 100644 --- a/packages/ui/src/Transactions/VolumeChart/VolumeChart.tsx +++ b/packages/ui/src/Transactions/VolumeChart/VolumeChart.tsx @@ -1,4 +1,4 @@ -import { Box, Typography, MenuItem, Select, TextField } from "@mui/material"; +import { Box, Typography, MenuItem, TextField } from "@mui/material"; import React, { useMemo, useRef, useState } from "react"; import { BarChart, @@ -11,6 +11,8 @@ import { ResponsiveContainer, } from "recharts"; import { formatCurrencyStatic } from "@phoenix-protocol/utils"; +import { CustomDropdown } from "../../Common/CustomDropdown"; +import { colors, typography, spacing, borderRadius } from "../../Theme/styleConstants"; type Pool = { tokenA: { icon: string; symbol: string }; @@ -78,16 +80,17 @@ const tabUnselectedStyles = { justifyContent: "center", alignItems: "center", gap: "0.625rem", - borderRadius: "1rem", + borderRadius: borderRadius.md, cursor: "pointer", - background: - "var(--Secondary-S3, linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.03) 100%))", + color: colors.neutral[300], + background: colors.neutral[900], + border: `1px solid ${colors.neutral[700]}`, }; const tabSelectedStyles = { - borderRadius: "1rem", - border: "1px solid #E2571C", + borderRadius: borderRadius.md, background: "rgba(226, 73, 26, 0.10)", + color: colors.neutral[50], }; const VolumeChart = ({ @@ -131,8 +134,8 @@ const VolumeChart = ({ alignItems: "flex-start", gap: "1.5625rem", borderRadius: "1.5rem", - background: - "linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.03) 100%)", + background: colors.neutral[900], // Adjusted background + border: `1px solid ${colors.neutral[700]}`, // Adjusted border }} > Volume {resolveSelectedVolume(selectedTab)} (USD) - + {formatCurrencyStatic.format(totalVolume)} @@ -317,7 +217,7 @@ const VolumeChart = ({
- + @@ -339,12 +239,11 @@ const VolumeChart = ({ - { if (active && payload && payload.length) { diff --git a/packages/ui/src/UnstakeInfoModal/UnstakeInfoModal.tsx b/packages/ui/src/UnstakeInfoModal/UnstakeInfoModal.tsx index 0d363779..8f20c53f 100644 --- a/packages/ui/src/UnstakeInfoModal/UnstakeInfoModal.tsx +++ b/packages/ui/src/UnstakeInfoModal/UnstakeInfoModal.tsx @@ -6,9 +6,9 @@ import { Grid, CircularProgress, } from "@mui/material"; -import Colors from "../Theme/colors"; import { Button } from "../Button/Button"; import { UnstakeInfoModalProps } from "@phoenix-protocol/types"; +import { colors, typography, spacing, borderRadius, shadows } from "../Theme/styleConstants"; const UnstakeInfoModal = ({ open, @@ -22,11 +22,12 @@ const UnstakeInfoModal = ({ transform: "translate(-50%, -50%)", width: 512, maxWidth: "calc(100vw - 16px)", - background: "linear-gradient(180deg, #292B2C 0%, #1F2123 100%)", - borderRadius: "16px", + background: colors.neutral[900], + borderRadius: borderRadius.lg, display: "flex", flexDirection: "column" as "column", - padding: "16px", + padding: spacing.lg, + boxShadow: shadows.card, }; return ( @@ -53,10 +54,10 @@ const UnstakeInfoModal = ({ display: "inline-flex", justifyContent: "center", alignItems: "center", - w: "16px", - h: "16px", - backgroundColor: Colors.inputsHover, - borderRadius: "8px", + width: "16px", + height: "16px", + backgroundColor: colors.neutral[800], + borderRadius: borderRadius.sm, cursor: "pointer", }} src="/x.svg" @@ -72,39 +73,45 @@ const UnstakeInfoModal = ({ > Warning - - - - You{"'"}re about to unstake. By doing so, you will lose the APR - progress you've accumulated. Remember, your APR increases daily - up to 60 days. If you unstake now, you'll need to start over - from day one. -
-
- Are you sure you want to proceed? -
-