diff --git a/.eslint/rules/no-extra-adapter-methods.cjs b/.eslint/rules/no-extra-adapter-methods.cjs index 10719a1d..f7af8eac 100644 --- a/.eslint/rules/no-extra-adapter-methods.cjs +++ b/.eslint/rules/no-extra-adapter-methods.cjs @@ -36,7 +36,6 @@ module.exports = { // Known interface methods from ContractAdapter const interfaceMethods = [ 'loadContract', - 'loadMockContract', 'getWritableFunctions', 'mapParameterTypeToFieldType', 'getCompatibleFieldTypes', @@ -46,6 +45,18 @@ module.exports = { 'isValidAddress', 'getSupportedExecutionMethods', 'validateExecutionConfig', + 'isViewFunction', + 'queryViewFunction', + 'formatFunctionResult', + 'supportsWalletConnection', + 'getAvailableConnectors', + 'connectWallet', + 'disconnectWallet', + 'getWalletConnectionStatus', + 'onWalletConnectionChange', + 'getExplorerUrl', + 'getExplorerTxUrl', + 'waitForTransactionConfirmation', ]; // Common standard methods and properties that are allowed diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0deb98ff..6341fcc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,9 @@ jobs: - name: Install dependencies run: pnpm install + - name: Build all packages + run: pnpm -r build + - name: Type check run: pnpm tsc --noEmit diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 19ee0c3f..72804528 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -26,6 +26,9 @@ jobs: - name: Install dependencies run: pnpm install + - name: Build all packages + run: pnpm -r build + - name: Run tests with coverage run: pnpm test:coverage diff --git a/.github/workflows/export-testing.yml b/.github/workflows/export-testing.yml index 047edb35..09617e05 100644 --- a/.github/workflows/export-testing.yml +++ b/.github/workflows/export-testing.yml @@ -38,6 +38,10 @@ jobs: - name: Install dependencies run: pnpm install + # Add this build step to ensure all workspace packages are built before testing + - name: Build all packages + run: pnpm -r build + # Run only export-related tests - name: Run export tests run: pnpm --filter @openzeppelin/transaction-form-builder-core test src/export/__tests__/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2861a953..17cdef99 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,6 +13,23 @@ Thank you for considering contributing to Transaction Form Builder! This documen 6. Push to your branch: `git push origin feature/amazing-feature` 7. Open a Pull Request +### Adding New Adapters + +If you are contributing support for a new blockchain ecosystem, please follow the detailed instructions in the main **[README.md under the "Adding New Adapters" section](./README.md#adding-new-adapters)**. + +Key steps include: + +1. **Familiarize Yourself:** Read the **[Adapter Architecture Guide](./docs/ADAPTER_ARCHITECTURE.md)** to understand the modular structure and responsibilities. +2. **Package Setup**: Create a new `packages/adapter-` package with appropriate `package.json` (depending on `@openzeppelin/transaction-form-types`) and `tsconfig.json`. +3. **Network Configurations**: Define `YourEcosystemNetworkConfig` objects in `src/networks/`, ensuring they provide all necessary details (RPC URLs, chain IDs, etc.). Export a combined list (e.g., `export const suiNetworks = [...]`) and individual configurations from `src/networks/index.ts`. +4. **Adapter Implementation**: Implement the `ContractAdapter` interface from `@openzeppelin/transaction-form-types` in `src/adapter.ts`. The constructor must accept its specific `NetworkConfig` (e.g., `constructor(networkConfig: SuiNetworkConfig)`) and use `this.networkConfig` internally. +5. **Exports**: Export your adapter class and the main networks array (and ideally individual network configs) from your adapter package's `src/index.ts`. +6. **Ecosystem Registration**: Register your new ecosystem in `packages/core/src/core/ecosystemManager.ts` by: + - Adding an entry to `ecosystemRegistry` with the `AdapterClass` constructor and the `networksExportName` (the name of your exported network list). + - Updating the `switch` statement in `loadAdapterPackageModule` to enable dynamic import of your adapter package. +7. **Testing**: Add comprehensive unit and integration tests for your adapter's logic and network configurations. +8. **Documentation**: Update any relevant documentation. + ## Pull Request Process 1. Ensure your code follows the style guidelines of the project diff --git a/README.md b/README.md index dcc8fa1a..5cacf407 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,14 @@ This project is currently in development. This project is organized as a monorepo with the following packages: -- **packages/core**: The main application with the form builder UI -- **packages/form-renderer**: The shared form rendering library (published to npm) -- **packages/styles**: Centralized styling system with shared CSS variables and configurations +- **packages/core**: The main application with the form builder UI and core logic. +- **packages/form-renderer**: The shared form rendering library (published to npm). +- **packages/types**: Shared TypeScript type definitions for all packages (published to npm). +- **packages/styles**: Centralized styling system with shared CSS variables and configurations. +- **packages/adapter-evm**: Adapter implementation for EVM-compatible chains. +- **packages/adapter-solana**: Adapter implementation for the Solana blockchain. +- **packages/adapter-stellar**: Adapter implementation for the Stellar network. +- **packages/adapter-midnight**: Adapter implementation for the Midnight blockchain. ## Packages @@ -57,6 +62,19 @@ Features: For more details, see the [Form-Renderer README](./packages/form-renderer/README.md). +### Types Package + +The `types` package contains shared TypeScript type definitions for all packages in the ecosystem. It serves as the single source of truth for types used across the Transaction Form Builder. + +Features: + +- Centralized type definitions +- Organized namespaces for contracts, adapters, and forms +- Clear separation of concerns +- TypeScript project references for proper type checking + +For more details, see the [Types README](./packages/types/README.md). + ### Styles Package The `styles` package contains the centralized styling system used across all packages. It provides consistent theming, spacing, and component styles throughout the application. @@ -76,6 +94,7 @@ For more details, see the [Styles README](./packages/styles/README.md). - Adapter pattern for easily adding support for new blockchains - Modern React components for building transaction forms - Customizable UI with Tailwind CSS and shadcn/ui +- Handles wallet connection state consistently in both core app and exported forms - Configure transaction execution methods (EOA, Relayer, Multisig) via adapters - Type-safe with TypeScript - Fast development with Vite @@ -169,15 +188,11 @@ transaction-form-builder/ │ │ │ │ ├── Common/ # Shared components across features │ │ │ │ └── FormBuilder/ # Form builder components │ │ │ ├── core/ # Chain-agnostic core functionality -│ │ │ │ ├── types/ # Type definitions +│ │ │ │ ├── types/ # Core-specific Type definitions (distinct from packages/types) │ │ │ │ ├── utils/ # Utility functions │ │ │ │ ├── hooks/ # Shared hooks -│ │ │ │ └── factories/ # Schema factories -│ │ │ ├── adapters/ # Chain-specific implementations -│ │ │ │ ├── evm/ # Ethereum Virtual Machine adapter -│ │ │ │ ├── midnight/ # Midnight blockchain adapter -│ │ │ │ ├── solana/ # Solana blockchain adapter -│ │ │ │ └── stellar/ # Stellar blockchain adapter +│ │ │ │ ├── factories/ # Schema factories +│ │ │ │ └── ecosystemManager.ts # Central management of ecosystems, adapters, and network configs │ │ │ ├── export/ # Export system │ │ │ │ ├── generators/ # Form code generators │ │ │ │ ├── codeTemplates/ # Individual file templates for generation @@ -192,8 +207,6 @@ transaction-form-builder/ │ │ │ │ ├── form-builder/# Stories for form builder components │ │ │ │ └── ui/ # Stories for UI components │ │ │ ├── test/ # Package-specific tests -│ │ │ ├── mocks/ # Mock data for development and testing -│ │ │ ├── types/ # Shared types for core package │ │ │ ├── App.tsx # Main application component │ │ │ ├── main.tsx # Application entry point │ │ │ └── index.css # Main CSS entry point (imports from @styles) @@ -217,11 +230,27 @@ transaction-form-builder/ │ │ ├── scripts/ # Build scripts │ │ ├── tsconfig.json # TypeScript configuration │ │ └── package.json # Package configuration -│ └── styles/ # Centralized styling system -│ ├── global.css # Global CSS variables and base styles -│ ├── src/ # Source directory for styles -│ ├── utils/ # Styling utilities -│ └── README.md # Styling documentation +│ ├── types/ # Shared TypeScript type definitions +│ │ ├── src/ +│ │ │ ├── adapters/ # Contract adapter interfaces +│ │ │ ├── contracts/ # Contract and blockchain types +│ │ │ ├── forms/ # Form field and layout definitions +│ │ │ └── index.ts # Main entry point +│ │ ├── tsconfig.json # TypeScript configuration +│ │ └── package.json # Package configuration +│ ├── styles/ # Centralized styling system +│ │ ├── global.css # Global CSS variables and base styles +│ │ ├── src/ # Source directory for styles +│ │ ├── utils/ # Styling utilities +│ │ └── README.md # Styling documentation +│ ├── adapter-evm/ # NEW: EVM Adapter Package +│ │ └── src/ # Contains EvmAdapter implementation +│ ├── adapter-solana/ # NEW: Solana Adapter Package +│ │ └── src/ # Contains SolanaAdapter implementation +│ ├── adapter-stellar/ # NEW: Stellar Adapter Package +│ │ └── src/ # Contains StellarAdapter implementation +│ └── adapter-midnight/ # NEW: Midnight Adapter Package +│ └── src/ # Contains MidnightAdapter implementation ├── tailwind.config.cjs # Central Tailwind CSS configuration ├── postcss.config.cjs # Central PostCSS configuration ├── components.json # Central shadcn/ui configuration @@ -238,14 +267,17 @@ transaction-form-builder/ ## Architecture -The application uses an adapter pattern to support multiple blockchain ecosystems: +The application uses a modular, domain-driven adapter pattern to support multiple blockchain ecosystems. For a detailed explanation of the adapter architecture and module responsibilities, please see the **[Adapter Architecture Guide](./docs/ADAPTER_ARCHITECTURE.md)**. -- **Core**: Chain-agnostic components, types, and utilities -- **Adapters**: Chain-specific implementations that conform to a common interface (including methods for field mapping, transaction formatting, address validation, and discovering/validating execution methods) -- **UI Components**: React components that use adapters to interact with different blockchains -- **Styling System**: Centralized CSS variables and styling approach used across all packages +**Key Components:** -This architecture allows for easy extension to support additional blockchain ecosystems without modifying the core application logic. It utilizes **custom Vite plugins** to create **virtual modules**, enabling reliable loading of shared assets (like configuration files and CSS) across package boundaries, ensuring consistency between development, testing, and exported builds. +- **Core**: Chain-agnostic application logic, UI components, export system, and the central `ecosystemManager.ts` for managing ecosystem details, network configurations, and adapter instantiation. +- **Adapters (`packages/adapter-*`)**: Individual packages containing chain-specific implementations (e.g., `EvmAdapter`, `SolanaAdapter`). Each adapter conforms to the common `ContractAdapter` interface defined in `packages/types`. Adapters are now instantiated with a specific `NetworkConfig`, making them network-aware. The core package dynamically discovers network configurations and instantiates adapters via `ecosystemManager.ts`. +- **Form Renderer**: Shared library containing form rendering components and common utilities (like logging). +- **Types**: Shared TypeScript type definitions across all packages, including the crucial `ContractAdapter` interface. +- **Styling System**: Centralized CSS variables and styling approach used across all packages. + +This architecture allows for easy extension to support additional blockchain ecosystems without modifying the core application logic. The core package dynamically loads and uses adapters via the `ecosystemManager.ts`, and the export system includes the specific adapter package needed for the target chain in exported forms. It utilizes **custom Vite plugins** to create **virtual modules**, enabling reliable loading of shared assets (like configuration files between packages) across package boundaries, ensuring consistency between development, testing, and exported builds. ### Adapter Pattern Enforcement @@ -258,7 +290,7 @@ To maintain the integrity of the adapter pattern, this project includes: These enforcement mechanisms ensure that the adapter interface remains the single source of truth for adapter implementations, preventing interface drift and maintaining architectural consistency. -For more detailed documentation about the adapter pattern, implementation guidelines, and validation rules, see the [Adapter System documentation](./packages/core/src/adapters/README.md). +For more detailed documentation about the adapter pattern, implementation guidelines, and validation rules, see the documentation within the [`packages/types/src/adapters/base.ts`](./packages/types/src/adapters/base.ts) file where the `ContractAdapter` interface is defined. ## Component Architecture @@ -384,6 +416,42 @@ The project is configured with: 1. **Update Dependencies workflow**: Runs weekly to check for and apply updates +## Adding New Adapters + +To add support for a new blockchain ecosystem: + +1. **Create Package**: Create a new directory `packages/adapter-` (e.g., `packages/adapter-sui`). +2. **Define `package.json`**: + - Set the package name (e.g., `@openzeppelin/transaction-form-adapter-sui`). + - Add a dependency on `@openzeppelin/transaction-form-types` (`workspace:*`). + - Add any chain-specific SDKs or libraries required by the adapter. + - Include standard build scripts (refer to existing adapter packages). + - **Important**: Ensure your package exports a named array of its `NetworkConfig[]` objects (e.g., `export const suiNetworks = [...]`) and its main `Adapter` class from its entry point (`src/index.ts`). +3. **Define `tsconfig.json`**: Create a `tsconfig.json` extending the root `tsconfig.base.json`. +4. **Implement Adapter**: + - Create `src/adapter.ts`. + - Import `ContractAdapter`, the specific `YourEcosystemNetworkConfig` (e.g., `SuiNetworkConfig`), and related types from `@openzeppelin/transaction-form-types`. + - Implement the `ContractAdapter` interface. The constructor **must** accept its specific `NetworkConfig` (e.g., `constructor(networkConfig: SuiNetworkConfig)`). + - Implement methods to use `this.networkConfig` internally for network-specific operations (e.g., initializing HTTP clients with RPC URLs from the config). +5. **Define Network Configurations**:\ + - Create `src/networks/mainnet.ts`, `testnet.ts`, etc., defining `YourEcosystemNetworkConfig` objects for each supported network. + - Each network config must provide all necessary details for the adapter to function, such as RPC endpoints (`rpcUrl` or `rpcEndpoint`), chain identifiers (`chainId` for EVM), explorer URLs, native currency details, etc., as defined by its `YourEcosystemNetworkConfig` interface. + - Create `src/networks/index.ts` to export the combined list of networks (e.g., `export const suiNetworks = [...mainnetSuiNetworks, ...testnetSuiNetworks];`) and also export each network configuration individually by its constant name (e.g., `export { suiMainnet, suiTestnet } from './mainnet';`). +6. **Export Adapter & Networks**: Create `src/index.ts` in your adapter package and export the adapter class (e.g., `export { SuiAdapter } from './adapter';`) and the main networks array (e.g., `export { suiNetworks } from './networks';`). It's also good practice to re-export individual network configurations from the adapter's main entry point if they might be directly imported by consumers. +7. **Register Ecosystem in Core**: + - Open `packages/core/src/core/ecosystemManager.ts`. + - Import the new adapter class (e.g., `import { SuiAdapter } from '@openzeppelin/transaction-form-adapter-sui';`). + - Add a new entry to the `ecosystemRegistry` object. This entry defines: + - `networksExportName`: The string name of the exported network list (e.g., 'suiNetworks'). This is used by the `EcosystemManager` to dynamically load all network configurations for an ecosystem. + - `AdapterClass`: The constructor of your adapter (e.g., `SuiAdapter as AnyAdapterConstructor`). + - Add a case for your new ecosystem in the `switch` statement within `loadAdapterPackageModule` to enable dynamic import of your adapter package module (which should export the `AdapterClass` and the `networksExportName` list). + - Note: If the adapter requires specific package dependencies for _exported projects_ (beyond its own runtime dependencies), these are typically managed by the `PackageManager` configuration within the adapter package itself (e.g., an `adapter.config.ts` file exporting dependency details). +8. **Workspace**: Ensure the new package is included in the `pnpm-workspace.yaml` (if not covered by `packages/*`). +9. **Build & Test**: + - Build the new adapter package (`pnpm --filter @openzeppelin/transaction-form-adapter- build`). + - Add relevant unit/integration tests. + - Ensure the core application (`pnpm --filter @openzeppelin/transaction-form-builder-core build`) and the export system still function correctly. + ## Commit Convention This project follows [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). See [COMMIT_CONVENTION.md](./COMMIT_CONVENTION.md) for diff --git a/SECURITY.md b/SECURITY.md index 14d0efd9..d603c701 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,9 @@ | Version | Supported | | ------- | ------------------ | -| 0.1.x | :white_check_mark: | +| 1.1.x | :white_check_mark: | +| 1.0.x | :white_check_mark: | +| < 1.0 | :x: | ## Reporting a Vulnerability diff --git a/commitlint.config.js b/commitlint.config.js index ea217f07..cc69bba8 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -41,11 +41,14 @@ export default { 'deps', 'config', 'form', + 'types', 'transaction', 'utils', 'docs', 'tests', 'release', + 'adapter', + 'types', ], ], 'scope-empty': [2, 'never'], diff --git a/docs/ADAPTER_ARCHITECTURE.md b/docs/ADAPTER_ARCHITECTURE.md new file mode 100644 index 00000000..81cc30dc --- /dev/null +++ b/docs/ADAPTER_ARCHITECTURE.md @@ -0,0 +1,172 @@ +# Adapter Architecture Guide + +This document outlines the standardized architecture for blockchain adapters within the Transaction Form Builder project. + +## 1. Overview + +The goal of the adapter architecture is to provide a consistent, maintainable, and extensible way to integrate support for various blockchain ecosystems. The core principle is **separation of concerns** through a domain-driven modular structure, enforced by the central `ContractAdapter` interface defined in `packages/types`. + +Each adapter lives in its own package (e.g., `packages/adapter-evm`, `packages/adapter-solana`) and implements the `ContractAdapter` interface. A key architectural principle is that **adapters are network-aware**. They are instantiated with a specific `NetworkConfig` object (e.g., `EvmNetworkConfig`, `SolanaNetworkConfig`) corresponding to the target network (like Ethereum Mainnet or Solana Devnet). This `networkConfig` is stored internally (usually as `this.networkConfig`) and used by the adapter's methods for all network-dependent operations (e.g., using the correct RPC URL, chain ID, explorer URL). + +The main `adapter.ts` file within each package acts as an orchestrator, delegating specific tasks to functions or classes exported from dedicated modules within its `src/` directory. + +## 2. Core `ContractAdapter` Interface + +All adapters **must** implement the `ContractAdapter` interface found in `packages/types/src/adapters/base.ts`. This interface defines the required methods for: + +- Loading contract definitions (e.g., `loadContract`) +- Mapping blockchain types to form field types (e.g., `mapParameterTypeToFieldType`, `getCompatibleFieldTypes`) +- Generating default form fields (e.g., `generateDefaultField`) +- Parsing user input and formatting transaction data (e.g., `formatTransactionData`) +- Signing and broadcasting transactions (e.g., `signAndBroadcast`, `waitForTransactionConfirmation?`) +- Querying view functions (e.g., `isViewFunction`, `queryViewFunction`) +- Formatting query results (e.g., `formatFunctionResult`) +- Handling wallet connections (e.g., `supportsWalletConnection`, `connectWallet`, `disconnectWallet`, `getWalletConnectionStatus`, etc.) +- Providing configuration and metadata (e.g., `getSupportedExecutionMethods`, `validateExecutionConfig`, `getExplorerUrl`, `getExplorerTxUrl?`) +- Basic validation (e.g., `isValidAddress`) + +**Note:** Methods requiring network context (like `queryViewFunction`, `getExplorerUrl`, `loadContract` when fetching from network) rely on the `networkConfig` provided during adapter instantiation, rather than receiving it as a parameter. + +## 3. Standardized Module Structure + +To promote consistency and maintainability, each adapter package should follow this general structure within its `src/` directory: + +```plaintext +adapter-/ +└── src/ + ├── adapter.ts # Main Adapter class implementing ContractAdapter + ├── networks/ # Network configurations + │ ├── mainnet.ts # Specific mainnet NetworkConfig objects + │ ├── testnet.ts # Specific testnet NetworkConfig objects + │ └── index.ts # Exports all configs + combined list (e.g., evmNetworks) + ├── [chain-specific-def]/ # e.g., abi/ (EVM), idl/ (Solana), etc. + │ ├── loader.ts # Implements `loadContract` logic + │ ├── [source].ts # e.g., etherscan.ts (uses NetworkConfig.apiUrl) + │ └── transformer.ts # Transforms raw def -> ContractSchema + │ └── index.ts + ├── mapping/ # Generic: Type mapping, field generation + │ ├── constants.ts + │ ├── type-mapper.ts + │ └── field-generator.ts + │ └── index.ts + ├── transform/ # Generic: Data serialization/deserialization + │ ├── input-parser.ts + │ └── output-formatter.ts + │ └── index.ts + ├── transaction/ # Generic: Transaction formatting/sending + │ ├── formatter.ts + │ └── sender.ts + │ └── index.ts + ├── query/ # Generic: View function querying + │ ├── handler.ts # Uses NetworkConfig for RPC/client + │ └── view-checker.ts + │ └── index.ts + ├── wallet/ # Generic: Wallet connection interface logic + │ ├── connection.ts # Wraps implementation calls + │ ├── [impl].ts # e.g., wagmi-implementation.ts + │ └── index.ts + ├── configuration/ # Generic: Metadata/configuration logic + │ ├── execution.ts + │ └── explorer.ts # Uses NetworkConfig for explorer URLs + │ └── index.ts + ├── types.ts # Adapter-specific internal types + ├── utils/ # Adapter-specific utils + │ └── ... + │ └── index.ts + └── index.ts # Main export for the adapter package +``` + +## 4. Module Responsibilities + +- **`adapter.ts`:** + + - Contains the main class (e.g., `EvmAdapter`) that `implements ContractAdapter`. + - Constructor accepts a specific `NetworkConfig` (e.g., `EvmNetworkConfig`) and stores it. + - Should be lean, acting primarily as an orchestrator. + - Instantiates necessary internal classes (like `WagmiWalletImplementation`). + - Imports functions/classes from other modules. + - Delegates the implementation of `ContractAdapter` interface methods to the imported functions/classes, passing necessary state (like `this.networkConfig`, `walletImplementation`) or instance methods. + +- **`networks/`:** + + - **Purpose:** Defines and exports the specific `NetworkConfig` objects for this adapter's ecosystem (e.g., `ethereumMainnet`, `polygonAmoy`). + - **Key Exports:** Individual named `NetworkConfig` constants, and a combined array of all configurations (e.g., `evmNetworks`). + +- **`[chain-specific-def]/` (e.g., `abi/`, `idl/`):** + + - **Purpose:** Handles loading and parsing the chain's native contract interface definition format (ABI, IDL, etc.) and transforming it into the common `ContractSchema` defined in `packages/types`. May use `networkConfig` (e.g., `apiUrl` for Etherscan). + - **Key Exports:** A primary function (e.g., `loadEvmContract`) called by `Adapter.loadContract`. Might also export the transformer (e.g., `transformAbiToSchema`). + - **Flexibility:** This directory name is flexible to reflect the chain's specific definition format. + +- **`mapping/`:** + + - **Purpose:** Handles the logic for mapping blockchain-specific parameter types to the standard `FieldType` used by the form builder, determining compatible field types, and generating default `FormFieldType` configurations. + - **Key Exports:** `map[Chain]ParamTypeToFieldType`, `get[Chain]CompatibleFieldTypes`, `generate[Chain]DefaultField`. + +- **`transform/`:** + + - **Purpose:** Handles the serialization and deserialization of data between user-friendly formats (strings, JSON strings) and the formats required by the blockchain/client libraries (e.g., `BigInt`, hex strings, typed objects). + - **Key Exports:** `parse[Chain]Input`, `format[Chain]FunctionResult`. + +- **`transaction/`:** + + - **Purpose:** Contains logic specifically related to preparing and executing state-changing transactions. Uses `networkConfig` for details like Chain ID. + - **Key Exports:** `format[Chain]TransactionData`, `signAndBroadcast[Chain]Transaction`, `waitFor[Chain]TransactionConfirmation`. + +- **`query/`:** + + - **Purpose:** Handles the logic for querying read-only (view/pure) contract functions. Uses `networkConfig` to connect to the correct RPC endpoint. + - **Key Exports:** `query[Chain]ViewFunction`, `is[Chain]ViewFunction`. + +- **`wallet/`:** + + - **Purpose:** Encapsulates all direct interaction with wallet connection libraries (e.g., Wagmi, WalletConnect, Solana Wallet Adapter). May use `networkConfig` to initialize or configure the library. + - **Key Exports:** `connect[Chain]Wallet`, `disconnect[Chain]Wallet`, `get[Chain]WalletConnectionStatus`, etc. + - **Internal Implementation:** Often contains a class (e.g., `WagmiWalletImplementation`) that manages the library specifics. The exported functions act as a facade. + +- **`configuration/`:** + + - **Purpose:** Provides configuration metadata about the adapter and chain. Uses `networkConfig` for network-specific details like explorer URLs. + - **Key Exports:** `get[Chain]SupportedExecutionMethods`, `validate[Chain]ExecutionConfig`, `get[Chain]ExplorerAddressUrl`, `get[Chain]ExplorerTxUrl`. + +- **`utils/`:** + + - **Purpose:** Contains general utility functions specific to the needs of this adapter (e.g., formatting helpers, JSON helpers). + +- **`types.ts`:** + + - **Purpose:** Defines any internal TypeScript types used only within this specific adapter package. + +## 5. Data Flow Example (EVM View Query) + +```mermaid +graph LR + UI(UI Component) -- Calls --> Adapter(EvmAdapter.queryViewFunction); + Adapter -- Passes call + deps --> QueryHandler(query.handler.queryEvmViewFunction); + QueryHandler -- Gets client --> WalletImpl(wallet.WagmiWalletImplementation); + WalletImpl -- Gets status --> WagmiCore("@wagmi/core.getAccount"); + QueryHandler -- Gets client --> CreateClient(viem.createPublicClient); + QueryHandler -- Needs schema --> LoadContract(Adapter.loadContract); + LoadContract -- Delegates --> AbiLoader(abi.loader.loadEvmContract); + QueryHandler -- Needs to parse params --> InputParser(transform.input-parser.parseEvmInput); + QueryHandler -- Needs ABI item --> AbiTransformer(abi.transformer.createAbiFunctionItem); + QueryHandler -- Calls --> ReadContract(viem.PublicClient.readContract); + ReadContract -- Returns decoded --> QueryHandler; + QueryHandler -- Returns decoded --> Adapter; + Adapter -- Returns decoded --> UI; + UI -- Calls --> FormatAdapter(EvmAdapter.formatFunctionResult); + FormatAdapter -- Delegates --> OutputFormatter(transform.output-formatter.formatEvmFunctionResult); + OutputFormatter -- Needs util --> JsonUtil(utils.json.stringifyWithBigInt); + OutputFormatter -- Returns formatted string --> FormatAdapter; + FormatAdapter -- Returns formatted string --> UI; +``` + +## 6. Enforcement & Contribution + +- Please refer to this document when developing new adapters or refactoring existing ones. +- The `CONTRIBUTING.md` guide contains steps for adding new adapters following this architecture. +- A scaffolding script (`pnpm create-adapter `) may be available to generate the basic structure. +- Code reviews should verify adherence to this modular structure. +- The `no-extra-adapter-methods` ESLint rule helps enforce interface compliance at the `adapter.ts` level. + +By following this structure, we aim for a cleaner, more testable, and easier-to-manage adapter system as the project grows. diff --git a/eslint.config.cjs b/eslint.config.cjs index d193c803..7ae5c18a 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -48,6 +48,15 @@ const baseConfig = [ '**/*.css', '**/*.md', '**/tsconfig.tsbuildinfo', + '**/dist/', + 'packages/core/test-results/', + '*.snap', + '*.lock', + '*.log', + 'badges/', + 'public/', + '.husky/_/', + 'exports/', ], }, @@ -199,7 +208,7 @@ const baseConfig = [ // Add custom adapter plugin config only if available if (customPlugin) { baseConfig.push({ - files: ['src/adapters/**/*.ts', 'packages/core/src/adapters/**/*.ts'], + files: ['packages/adapter-*/src/adapter.ts'], plugins: { custom: customPlugin, }, diff --git a/lint-adapters.cjs b/lint-adapters.cjs index 23406386..cbc1ca88 100644 --- a/lint-adapters.cjs +++ b/lint-adapters.cjs @@ -9,26 +9,21 @@ const fs = require('fs'); // Create an instance of ESLint with our custom config const eslint = new ESLint(); -// Function to find all adapter implementations +// Function to find adapter implementation in the current package function findAdapterFiles() { - const adaptersDir = path.resolve(process.cwd(), 'src/adapters'); const adapterFiles = []; + const srcDir = path.resolve(process.cwd(), 'src'); - // Read the adapters directory - const items = fs.readdirSync(adaptersDir, { withFileTypes: true }); - - // Process each item - for (const item of items) { - // Skip the index.ts file and any non-directories - if (item.name === 'index.ts' || !item.isDirectory()) continue; - - const adapterDir = path.join(adaptersDir, item.name); - const adapterFile = path.join(adapterDir, 'adapter.ts'); + // Skip if the src directory doesn't exist + if (!fs.existsSync(srcDir)) { + console.warn(`Warning: src directory not found: ${srcDir}`); + return adapterFiles; + } - // Check if the adapter.ts file exists - if (fs.existsSync(adapterFile)) { - adapterFiles.push(adapterFile); - } + // Look for adapter.ts in the src directory + const adapterFile = path.join(srcDir, 'adapter.ts'); + if (fs.existsSync(adapterFile)) { + adapterFiles.push(adapterFile); } return adapterFiles; @@ -43,7 +38,7 @@ async function lintAdapters() { const adapterFiles = findAdapterFiles(); if (adapterFiles.length === 0) { - console.error('No adapter files found. Check the src/adapters directory structure.'); + console.error('No adapter files found. Check the src directory for adapter.ts file.'); process.exit(1); } diff --git a/package.json b/package.json index c4dea024..598263db 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "lint": "pnpm -r lint", "lint:fix": "pnpm -r lint:fix", "lint:all-fix": "pnpm -r lint:all-fix", - "lint:adapters": "pnpm --filter=@openzeppelin/transaction-form-builder-core lint:adapters", + "lint:adapters": "pnpm --filter='./packages/adapter-*' lint:adapters", "lint:config-files": "pnpm --filter=@openzeppelin/transaction-form-builder-core lint:config-files", "type-check": "pnpm -r type-check", "format": "pnpm -r format", diff --git a/packages/adapter-evm/README.md b/packages/adapter-evm/README.md new file mode 100644 index 00000000..d05b0032 --- /dev/null +++ b/packages/adapter-evm/README.md @@ -0,0 +1,35 @@ +# EVM Adapter (`@openzeppelin/transaction-form-adapter-evm`) + +This package provides the `ContractAdapter` implementation for EVM-compatible blockchains (Ethereum, Polygon, BSC, etc.) for the Transaction Form Builder. + +It is responsible for: + +- Implementing the `ContractAdapter` interface from `@openzeppelin/transaction-form-types`. +- Defining and exporting specific EVM network configurations (e.g., Ethereum Mainnet, Sepolia Testnet) as `EvmNetworkConfig` objects. These are located in `src/networks/` and include details like RPC URLs, Chain IDs, explorer URLs, and native currency information. +- Loading contract ABIs (from JSON strings or via Etherscan, using the `apiUrl` from the provided `EvmNetworkConfig`). +- Mapping EVM-specific data types to the form field types used by the form builder. +- Parsing user input (including complex types like structs and arrays) into EVM-compatible transaction data, according to the `EvmNetworkConfig`. +- Formatting results from view function calls. +- Interacting with EVM wallets (via Wagmi/Viem) for signing and broadcasting transactions on the configured network. +- Providing other EVM-specific configurations and validation (e.g., for execution methods). + +## Usage + +The `EvmAdapter` class is instantiated with a specific `EvmNetworkConfig` object, making it aware of the target network from its creation: + +```typescript +import { EvmAdapter, ethereumSepolia } from '@openzeppelin/transaction-form-adapter-evm'; + +// Or any other exported EvmNetworkConfig + +const networkConfig = ethereumSepolia; +const evmAdapter = new EvmAdapter(networkConfig); + +// Now use evmAdapter for operations on the Ethereum Sepolia testnet +``` + +Network configurations for various EVM chains (mainnets and testnets) are exported from `src/networks/index.ts` within this package (e.g., `ethereumMainnet`, `polygonMainnet`, `ethereumSepolia`, `polygonAmoy`). The full list of available networks is exported as `evmNetworks`. + +## Internal Structure + +This adapter generally follows the standard module structure outlined in the main project [Adapter Architecture Guide](../../docs/ADAPTER_ARCHITECTURE.md), with the addition of the `src/networks/` directory for managing network configurations. diff --git a/packages/adapter-evm/package.json b/packages/adapter-evm/package.json new file mode 100644 index 00000000..1580f523 --- /dev/null +++ b/packages/adapter-evm/package.json @@ -0,0 +1,70 @@ +{ + "name": "@openzeppelin/transaction-form-adapter-evm", + "version": "0.0.1", + "private": true, + "description": "EVM Adapter for Transaction Form Builder", + "keywords": [ + "openzeppelin", + "transaction", + "form", + "builder", + "adapter", + "evm", + "ethereum", + "wagmi" + ], + "author": "Aleksandr Pasevin ", + "homepage": "https://github.com/OpenZeppelin/transaction-form-builder#readme", + "license": "MIT", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "files": [ + "dist", + "src" + ], + "publishConfig": { + "access": "public", + "provenance": true + }, + "repository": { + "type": "git", + "url": "https://github.com/OpenZeppelin/transaction-form-builder.git", + "directory": "packages/adapter-evm" + }, + "bugs": { + "url": "https://github.com/OpenZeppelin/transaction-form-builder/issues" + }, + "scripts": { + "build": "tsc -b", + "clean": "rm -rf dist tsconfig.tsbuildinfo", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "lint:adapters": "node ../../lint-adapters.cjs", + "prepublishOnly": "pnpm build", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@openzeppelin/transaction-form-types": "workspace:*", + "@wagmi/connectors": "^5.1.0", + "@wagmi/core": "^2.15.0", + "ethers": "^6.13.1", + "lodash": "^4.17.21", + "viem": "^2.28.0", + "wagmi": "^2.15.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@types/lodash": "^4.17.16", + "eslint": "^9.22.0", + "jsdom": "^26.0.0", + "typescript": "^5.8.2", + "vitest": "^3.0.8" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } +} diff --git a/packages/adapter-evm/src/__tests__/adapter-parsing.test.ts b/packages/adapter-evm/src/__tests__/adapter-parsing.test.ts new file mode 100644 index 00000000..cbfd9ed0 --- /dev/null +++ b/packages/adapter-evm/src/__tests__/adapter-parsing.test.ts @@ -0,0 +1,466 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +// Adjust path as needed +import type { ContractFunction, FunctionParameter } from '@openzeppelin/transaction-form-types'; + +import { EvmAdapter } from '../adapter'; +import { parseEvmInput as parseEvmInputFunction } from '../transform'; + +import { mockEvmNetworkConfig } from './mocks/mock-network-configs'; + +// Mock FunctionParameter type helper +const createParam = ( + type: string, + name: string, + components?: FunctionParameter[] +): FunctionParameter => ({ + name, + type, + displayName: name, // Keep it simple for tests + components, +}); + +describe('EvmAdapter Input Parsing', () => { + // Define valid address constants accessible to multiple test blocks + const validAddress = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; + const checksummedAddress = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; // viem getAddress checksums + + // Helper to call the imported parseEvmInput function + const parseInput = (param: FunctionParameter, value: unknown) => { + return parseEvmInputFunction(param, value); + }; + + // --- Simple Type Tests --- + describe('Simple Types', () => { + // uint/int tests + describe('Integer Types (uint/int)', () => { + const uintParam = createParam('uint256', 'amount'); + const intParam = createParam('int8', 'delta'); + + it('should parse valid numbers/strings to BigInt', () => { + expect(parseInput(uintParam, 123)).toBe(123n); + expect(parseInput(uintParam, '456')).toBe(456n); + expect(parseInput(intParam, -10)).toBe(-10n); + expect(parseInput(intParam, '-20')).toBe(-20n); + expect(parseInput(uintParam, BigInt(1e18))).toBe(1000000000000000000n); + }); + + it('should throw error for empty numeric input', () => { + expect(() => parseInput(uintParam, '')).toThrowError( + /Failed to parse value for parameter 'amount' .* Numeric value cannot be empty/ + ); + }); + + it('should throw error for invalid numeric strings', () => { + expect(() => parseInput(uintParam, 'abc')).toThrowError( + /Failed to parse value for parameter 'amount' .* Invalid numeric value: 'abc'/ + ); + expect(() => parseInput(intParam, '10.5')).toThrowError( + // BigInt doesn't allow decimals + /Failed to parse value for parameter 'delta' .* Invalid numeric value: '10.5'/ + ); + expect(() => parseInput(uintParam, '10n')).toThrowError( + // Invalid BigInt syntax + /Failed to parse value for parameter 'amount' .* Invalid numeric value: '10n'/ + ); + }); + }); + + // address tests + describe('Address Type', () => { + const param = createParam('address', 'recipient'); + + it('should parse and checksum valid address', () => { + expect(parseInput(param, validAddress.toLowerCase())).toBe(checksummedAddress); + expect(parseInput(param, validAddress)).toBe(checksummedAddress); + }); + + it('should throw error for invalid address format', () => { + expect(() => parseInput(param, '0x123')).toThrowError( + /Failed to parse value for parameter 'recipient' .* Invalid address format: '0x123'/ + ); + expect(() => parseInput(param, 'not_an_address')).toThrowError( + /Failed to parse value for parameter 'recipient' .* Invalid address format: 'not_an_address'/ + ); + }); + + it('should throw error for empty address input', () => { + expect(() => parseInput(param, '')).toThrowError( + /Failed to parse value for parameter 'recipient' .* Address value must be a non-empty string/ + ); + }); + + it('should throw error for non-string address input', () => { + expect(() => parseInput(param, 123)).toThrowError( + /Failed to parse value for parameter 'recipient' .* Address value must be a non-empty string/ + ); + }); + }); + + // bool tests + describe('Boolean Type', () => { + const param = createParam('bool', 'isActive'); + + it('should parse boolean values', () => { + expect(parseInput(param, true)).toBe(true); + expect(parseInput(param, false)).toBe(false); + }); + + it('should parse string representations "true"/"false"', () => { + expect(parseInput(param, 'true')).toBe(true); + expect(parseInput(param, 'false')).toBe(false); + expect(parseInput(param, ' True ')).toBe(true); // Handle whitespace + expect(parseInput(param, ' FALSE ')).toBe(false); + }); + + // Current implementation uses Boolean() which is very lenient + // Test this behaviour, but maybe flag it for review + it('should parse other truthy/falsy values (current behaviour)', () => { + expect(parseInput(param, 1)).toBe(true); + expect(parseInput(param, 0)).toBe(false); + expect(parseInput(param, 'any string')).toBe(true); + expect(parseInput(param, '')).toBe(false); // Empty string is falsy + expect(parseInput(param, null)).toBe(false); + expect(parseInput(param, undefined)).toBe(false); + }); + + // Add a test that *would* fail if strict parsing was enforced + it.skip('should ideally throw for ambiguous non-boolean/non-string values', () => { + expect(() => parseInput(param, 1)).toThrow(); + expect(() => parseInput(param, 'abc')).toThrow(); + expect(() => parseInput(param, '')).toThrow(); + }); + }); + + // string tests + describe('String Type', () => { + const param = createParam('string', 'message'); + + it('should keep strings as strings', () => { + expect(parseInput(param, 'hello world')).toBe('hello world'); + expect(parseInput(param, '')).toBe(''); + }); + + it('should convert numbers to strings', () => { + expect(parseInput(param, 123)).toBe('123'); + expect(parseInput(param, 0)).toBe('0'); + }); + + it('should convert booleans to strings', () => { + expect(parseInput(param, true)).toBe('true'); + expect(parseInput(param, false)).toBe('false'); + }); + }); + }); + + // --- Bytes Type Tests --- + describe('Bytes Types', () => { + describe('bytes (dynamic)', () => { + const param = createParam('bytes', 'data'); + + it('should parse valid hex strings', () => { + expect(parseInput(param, '0x')).toBe('0x'); + expect(parseInput(param, '0x1234')).toBe('0x1234'); + expect(parseInput(param, '0xabcdef')).toBe('0xabcdef'); + }); + + it('should throw error for invalid hex strings', () => { + expect(() => parseInput(param, '0x123')).toThrowError(/Invalid hex string format/); // Odd length + expect(() => parseInput(param, '0xghij')).toThrowError(/Invalid hex string format/); // Invalid chars + expect(() => parseInput(param, '1234')).toThrowError(/Invalid hex string format/); // Missing 0x + expect(() => parseInput(param, '')).toThrowError(/Invalid hex string format/); + }); + + it('should throw error for non-string input', () => { + expect(() => parseInput(param, 123)).toThrowError(/Bytes input must be a string/); + }); + }); + + describe('bytesN (fixed)', () => { + const param = createParam('bytes4', 'selector'); + + it('should parse valid hex strings of correct length', () => { + expect(parseInput(param, '0x12345678')).toBe('0x12345678'); + }); + + it('should throw error for invalid hex strings', () => { + expect(() => parseInput(param, '0x1234567')).toThrowError(/Invalid hex string format/); // Odd length + expect(() => parseInput(param, '0xghij')).toThrowError(/Invalid hex string format/); // Invalid chars + expect(() => parseInput(param, '12345678')).toThrowError(/Invalid hex string format/); // Missing 0x + }); + + it('should throw error for hex strings of incorrect length', () => { + expect(() => parseInput(param, '0x1234')).toThrowError( + /Invalid length for bytes4: expected 4 bytes .* got 2 bytes/ + ); + expect(() => parseInput(param, '0x1234567890')).toThrowError( + /Invalid length for bytes4: expected 4 bytes .* got 5 bytes/ + ); + }); + }); + }); + + // --- Array Type Tests --- + describe('Array Types', () => { + const uintArrParam = createParam('uint256[]', 'ids'); + const addrArrParam = createParam('address[]', 'recipients'); + const tupleArrParam = createParam('tuple[]', 'records', [ + createParam('address', 'user'), + createParam('uint256', 'balance'), + ]); + + it('should parse valid JSON array of simple types', () => { + expect(parseInput(uintArrParam, '[1, "2", 30]')).toEqual([1n, 2n, 30n]); + expect( + parseInput( + addrArrParam, + '["0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"]' + ) + ).toEqual([ + '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', + '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + ]); + expect(parseInput(uintArrParam, '[]')).toEqual([]); + }); + + it('should parse valid JSON array of tuples', () => { + const json = + '[{"user": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", "balance": "100"}, {"user": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", "balance": 200}]'; + expect(parseInput(tupleArrParam, json)).toEqual([ + { user: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', balance: 100n }, + { user: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', balance: 200n }, + ]); + }); + + it('should throw error for non-string input', () => { + expect(() => parseInput(uintArrParam, [1, 2])).toThrowError( + /Array input must be a JSON string/ + ); + }); + + it('should throw error for invalid JSON string', () => { + expect(() => parseInput(uintArrParam, '[1, 2,')).toThrowError(/Invalid JSON for array/); + }); + + it('should throw error if parsed JSON is not an array', () => { + expect(() => parseInput(uintArrParam, '{"a": 1}')).toThrowError( + /Parsed JSON is not an array/ + ); + }); + + it('should throw error if any element fails parsing', () => { + expect(() => parseInput(uintArrParam, '[1, "abc", 3]')).toThrowError( + /Failed to parse value for parameter 'ids' .* Invalid numeric value: 'abc'/ + ); + expect(() => parseInput(addrArrParam, `["${validAddress}", "invalid"]`)).toThrowError( + /Failed to parse value for parameter 'recipients' .* Invalid address format: 'invalid'/ + ); + const invalidTupleJson = `[{"user": "${validAddress}", "balance": "100"}, {"user": "invalid", "balance": 200}]`; + expect(() => parseInput(tupleArrParam, invalidTupleJson)).toThrowError( + /Failed to parse value for parameter 'user' .* Invalid address format: 'invalid'/ + ); + }); + }); + + // --- Tuple Type Tests --- + describe('Tuple Types', () => { + const simpleTupleParam = createParam('tuple', 'config', [ + createParam('address', 'owner'), + createParam('uint256', 'threshold'), + ]); + const nestedTupleParam = createParam('tuple', 'nested', [ + createParam('string', 'label'), + createParam('tuple', 'inner', [createParam('bool', 'flag'), createParam('bytes4', 'id')]), + ]); + + it('should parse valid JSON object matching tuple structure', () => { + const json = '{"owner": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", "threshold": "5"}'; + expect(parseInput(simpleTupleParam, json)).toEqual({ + owner: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266', + threshold: 5n, + }); + }); + + it('should parse valid JSON object with nested tuple', () => { + const json = '{"label": "Test", "inner": {"flag": true, "id": "0x12345678"}}'; + expect(parseInput(nestedTupleParam, json)).toEqual({ + label: 'Test', + inner: { flag: true, id: '0x12345678' }, + }); + }); + + it('should throw error for non-string input', () => { + expect(() => parseInput(simpleTupleParam, { owner: '0x...', threshold: 5 })).toThrowError( + /Tuple input must be a JSON string/ + ); + }); + + it('should throw error for invalid JSON string', () => { + expect(() => parseInput(simpleTupleParam, '{"owner": "0xf39..."')).toThrowError( + /Invalid JSON for tuple/ + ); + }); + + it('should throw error if parsed JSON is not an object', () => { + expect(() => parseInput(simpleTupleParam, '[1, 2]')).toThrowError( + /Parsed JSON is not an object for tuple/ + ); + expect(() => parseInput(simpleTupleParam, '"string"')).toThrowError( + /Parsed JSON is not an object for tuple/ + ); + }); + + it('should throw error if a component is missing', () => { + const json = '{"owner": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"}'; + expect(() => parseInput(simpleTupleParam, json)).toThrowError( + /Missing component 'threshold' in tuple JSON/ + ); + }); + + it('should throw error if there are extra keys', () => { + const json = `{"owner": "${validAddress}", "threshold": 5, "extra": 1}`; + expect(() => parseInput(simpleTupleParam, json)).toThrowError( + /Tuple object has incorrect number of keys/ + ); + }); + + it('should throw error if a component value fails parsing', () => { + const json = '{"owner": "invalid", "threshold": "5"}'; + expect(() => parseInput(simpleTupleParam, json)).toThrowError( + /Failed to parse value for parameter 'owner' .* Invalid address format: 'invalid'/ + ); + const json2 = `{"owner": "${validAddress}", "threshold": "abc"}`; + expect(() => parseInput(simpleTupleParam, json2)).toThrowError( + /Failed to parse value for parameter 'threshold' .* Invalid numeric value: 'abc'/ + ); + }); + + it('should throw error if nested component value fails parsing', () => { + const json = '{"label": "Test", "inner": {"flag": true, "id": "invalid"}}'; + expect(() => parseInput(nestedTupleParam, json)).toThrowError( + /Failed to parse value for parameter 'id' .* Invalid hex string format/ + ); + }); + }); +}); + +// --- Output Formatting Tests --- +describe('EvmAdapter Output Formatting', () => { + let adapter: EvmAdapter; + + beforeEach(() => { + // Instantiate adapter WITH shared mock config + adapter = new EvmAdapter(mockEvmNetworkConfig); + }); + + // Helper to call formatFunctionResult + const formatResult = (result: unknown, outputs: FunctionParameter[]) => { + // Mock minimal FunctionDetails needed for formatting + const mockFunctionDetails: ContractFunction = { + id: 'mock_func', + name: 'mockFunc', + displayName: 'mockFunc', + type: 'function', + inputs: [], // Not used by formatFunctionResult + outputs: outputs, + modifiesState: false, + stateMutability: 'view', + }; + return adapter.formatFunctionResult(result, mockFunctionDetails); + }; + + it('should format simple types correctly', () => { + expect(formatResult('hello', [createParam('string', 'message')])).toBe('hello'); + expect(formatResult(123, [createParam('uint8', 'value')])).toBe('123'); + expect(formatResult(123n, [createParam('uint256', 'value')])).toBe('123'); + expect(formatResult(true, [createParam('bool', 'flag')])).toBe('true'); + expect( + formatResult('0x1234567890abcdef1234567890abcdef12345678', [createParam('address', 'addr')]) + ).toBe('0x1234567890abcdef1234567890abcdef12345678'); + }); + + it('should format null/undefined as (null)', () => { + expect(formatResult(null, [createParam('string', 'message')])).toBe('(null)'); + expect(formatResult(undefined, [createParam('string', 'message')])).toBe('(null)'); + }); + + it('should handle empty outputs array', () => { + // When outputs array is empty, queryViewFunction returns undefined, which formats to '(null)' + expect(formatResult(undefined, [])).toBe('(null)'); + }); + + it('should format single-element arrays from queryViewFunction correctly', () => { + // Simulate queryViewFunction returning a single value wrapped in an array + expect(formatResult([123n], [{ name: 'value', type: 'uint256' }])).toBe('123'); + expect(formatResult(['hello'], [{ name: 'value', type: 'string' }])).toBe('hello'); + }); + + it('should format multi-element arrays as JSON string with BigInts as strings', () => { + const result = [123n, 'hello', true, 456n]; + const outputs: FunctionParameter[] = [ + createParam('uint256', 'num1'), + createParam('string', 'str'), + createParam('bool', 'flag'), + createParam('int64', 'num2'), + ]; + const expectedJson = JSON.stringify(['123', 'hello', true, '456'], null, 2); + expect(formatResult(result, outputs)).toBe(expectedJson); + }); + + it('should format simple tuples/structs as JSON string with BigInts as strings', () => { + const result = { owner: '0x123', threshold: 5n }; // Assume readContract returns object for structs + const outputs: FunctionParameter[] = [ + { name: 'owner', type: 'address' }, + { name: 'threshold', type: 'uint256' }, + ]; + const expectedJson = JSON.stringify({ owner: '0x123', threshold: '5' }, null, 2); + // Note: formatFunctionResult expects the *decoded* value. If a struct is returned as a single output, + // viem might return it directly, not wrapped in an array. + expect(formatResult(result, outputs)).toBe(expectedJson); + }); + + it('should format nested structures (array of tuples) as JSON string', () => { + const result = [ + { user: '0x123', balance: 100n }, + { user: '0x456', balance: 200n }, + ]; + const outputs: FunctionParameter[] = [ + { + name: 'records', + type: 'tuple[]', + components: [ + { name: 'user', type: 'address' }, + { name: 'balance', type: 'uint256' }, + ], + }, + ]; + const expectedJson = JSON.stringify( + [ + { user: '0x123', balance: '100' }, + { user: '0x456', balance: '200' }, + ], + null, + 2 + ); + // If queryViewFunction returns array of tuples for tuple[], pass it directly + expect(formatResult(result, outputs)).toBe(expectedJson); + }); + + it('should handle missing output definitions', () => { + const mockFunctionDetails: ContractFunction = { + id: 'mock_func', + name: 'mockFunc', + displayName: 'mockFunc', + type: 'function', + inputs: [], + outputs: undefined, // Simulate missing outputs + modifiesState: false, + stateMutability: 'view', + }; + expect(adapter.formatFunctionResult('some value', mockFunctionDetails)).toBe( + '[Error: Output ABI definition missing]' + ); + }); + + // Potential TODO: Add test for error during stringifyWithBigInt if possible (e.g., circular refs, though unlikely here) +}); diff --git a/packages/adapter-evm/src/__tests__/mocks/mock-network-configs.ts b/packages/adapter-evm/src/__tests__/mocks/mock-network-configs.ts new file mode 100644 index 00000000..6daabd9c --- /dev/null +++ b/packages/adapter-evm/src/__tests__/mocks/mock-network-configs.ts @@ -0,0 +1,22 @@ +import type { EvmNetworkConfig } from '@openzeppelin/transaction-form-types'; + +/** + * Mock EVM Network Configuration for testing purposes. + */ +export const mockEvmNetworkConfig: EvmNetworkConfig = { + id: 'test-evm-mocknet', + exportConstName: 'mockEvmNetworkConfig', + name: 'Test EVM Mocknet', + ecosystem: 'evm', + network: 'ethereum', // Can be any mock string + type: 'testnet', + isTestnet: true, + chainId: 1337, // Common local testnet chain ID + rpcUrl: 'http://localhost:8545', // Mock RPC URL + nativeCurrency: { name: 'TestETH', symbol: 'TETH', decimals: 18 }, + apiUrl: 'https://api.etherscan.io/api', // Mock API URL + icon: 'ethereum', +}; + +// Add mocks for other ecosystems here if needed later +// export const mockSolanaNetworkConfig: SolanaNetworkConfig = { ... }; diff --git a/packages/adapter-evm/src/__tests__/wallet-connect.test.ts b/packages/adapter-evm/src/__tests__/wallet-connect.test.ts new file mode 100644 index 00000000..c752b6d3 --- /dev/null +++ b/packages/adapter-evm/src/__tests__/wallet-connect.test.ts @@ -0,0 +1,113 @@ +/** + * Tests for EVM adapter wallet connection functionality + */ +import type { GetAccountReturnType } from '@wagmi/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { EvmAdapter } from '../adapter'; + +import { mockEvmNetworkConfig } from './mocks/mock-network-configs'; + +// Mock the WagmiWalletImplementation to isolate EvmAdapter logic +vi.mock('../wallet/wagmi-implementation', () => { + // --- Mock implementations for WagmiWalletImplementation methods --- + const mockGetAvailableConnectors = vi.fn().mockResolvedValue([ + { id: 'injected', name: 'Browser Wallet' }, + { id: 'walletConnect', name: 'WalletConnect' }, + ]); + + const mockConnect = vi.fn().mockImplementation(async (_connectorId: string) => ({ + connected: true, + address: '0x1234567890123456789012345678901234567890', + chainId: mockEvmNetworkConfig.chainId, + error: undefined, + })); + + const mockDisconnect = vi.fn().mockResolvedValue({ disconnected: true, error: undefined }); + + const mockSwitchNetwork = vi.fn().mockResolvedValue(undefined); + + // Mock the raw Wagmi status returned by the implementation class + const mockWagmiStatus: GetAccountReturnType = { + address: undefined, + addresses: undefined, + chain: undefined, + chainId: undefined, + connector: undefined, + isConnected: false, + isConnecting: false, + isDisconnected: true, + isReconnecting: false, + status: 'disconnected', + }; + + const mockGetWalletConnectionStatus = vi.fn().mockReturnValue(mockWagmiStatus); + + const mockOnWalletConnectionChange = vi.fn().mockImplementation((_callback) => { + // Return a dummy unsubscribe function + return () => {}; + }); + // --- End Mock Implementations --- + + return { + WagmiWalletImplementation: vi.fn().mockImplementation(() => ({ + // Expose mocks + getAvailableConnectors: mockGetAvailableConnectors, + connect: mockConnect, + disconnect: mockDisconnect, + getWalletConnectionStatus: mockGetWalletConnectionStatus, + onWalletConnectionChange: mockOnWalletConnectionChange, + switchNetwork: mockSwitchNetwork, + })), + }; +}); + +describe('EvmAdapter Wallet Connection', () => { + let adapter: EvmAdapter; + + beforeEach(() => { + // Instantiate adapter WITH shared mock config + adapter = new EvmAdapter(mockEvmNetworkConfig); + // Optionally clear mock history if needed between tests + // vi.clearAllMocks(); + }); + + it('should support wallet connection', () => { + expect(adapter.supportsWalletConnection()).toBe(true); + }); + + it('should get available connectors', async () => { + const connectors = await adapter.getAvailableConnectors(); + expect(connectors).toBeInstanceOf(Array); + expect(connectors.length).toBeGreaterThan(0); + expect(connectors[0]).toHaveProperty('id'); + expect(connectors[0]).toHaveProperty('name'); + }); + + it('should connect wallet with a connector ID', async () => { + const connectorId = 'injected'; // Example connector ID + const result = await adapter.connectWallet(connectorId); + expect(result.connected).toBe(true); + expect(result.address).toBe('0x1234567890123456789012345678901234567890'); + expect(result.error).toBeUndefined(); + }); + + it('should disconnect wallet', async () => { + const result = await adapter.disconnectWallet(); + expect(result.disconnected).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('should get wallet connection status', () => { + const status = adapter.getWalletConnectionStatus(); + expect(status).toHaveProperty('isConnected'); + expect(status.isConnected).toBe(false); + }); + + it('should subscribe to wallet connection changes', () => { + const callback = vi.fn(); + const unsubscribe = adapter.onWalletConnectionChange(callback); + + expect(typeof unsubscribe).toBe('function'); + }); +}); diff --git a/packages/adapter-evm/src/abi/__tests__/transformer.test.ts b/packages/adapter-evm/src/abi/__tests__/transformer.test.ts new file mode 100644 index 00000000..e3c87adb --- /dev/null +++ b/packages/adapter-evm/src/abi/__tests__/transformer.test.ts @@ -0,0 +1,342 @@ +/** + * Unit tests for ABI transformation logic. + */ +import type { AbiFunction } from 'viem'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ContractFunction, ContractSchema } from '@openzeppelin/transaction-form-types'; + +import type { AbiItem } from '../../types'; +// Adjust path as necessary +import { createAbiFunctionItem, transformAbiToSchema } from '../transformer'; + +// Mock utility functions as their specific formatting is not under test here +vi.mock('../../utils', () => ({ + formatMethodName: vi.fn((name: string) => `formatted_${name}`), + formatInputName: vi.fn( + (name: string | undefined, type: string) => `${name ? `formatted_${name}` : `param_${type}`}` + ), +})); + +describe('ABI Transformer', () => { + describe('transformAbiToSchema', () => { + const mockContractName = 'TestContract'; + const mockContractAddress = '0x1234567890123456789012345678901234567890'; + + it('should transform an empty ABI to an empty functions array', () => { + const abi: readonly AbiItem[] = []; + const expectedSchema: ContractSchema = { + ecosystem: 'evm', + name: mockContractName, + address: undefined, + functions: [], + }; + expect(transformAbiToSchema(abi, mockContractName)).toEqual(expectedSchema); + }); + + it('should transform a simple function correctly', () => { + const abi: readonly AbiItem[] = [ + { + type: 'function', + name: 'myFunction', + inputs: [], + outputs: [], + stateMutability: 'nonpayable', + }, + ]; + const result = transformAbiToSchema(abi, mockContractName, mockContractAddress); + expect(result.functions).toHaveLength(1); + expect(result.functions[0]).toEqual( + expect.objectContaining({ + name: 'myFunction', + displayName: 'formatted_myFunction', + inputs: [], + outputs: [], + stateMutability: 'nonpayable', + modifiesState: true, + type: 'function', + }) + ); + expect(result.ecosystem).toBe('evm'); + expect(result.name).toBe(mockContractName); + expect(result.address).toBe(mockContractAddress); + }); + + it('should handle different stateMutability values', () => { + const abi: readonly AbiItem[] = [ + { type: 'function', name: 'viewFunc', inputs: [], outputs: [], stateMutability: 'view' }, + { type: 'function', name: 'pureFunc', inputs: [], outputs: [], stateMutability: 'pure' }, + { + type: 'function', + name: 'payableFunc', + inputs: [], + outputs: [], + stateMutability: 'payable', + }, + { + type: 'function', + name: 'nonPayableFunc', + inputs: [], + outputs: [], + stateMutability: 'nonpayable', + }, + ]; + const result = transformAbiToSchema(abi, mockContractName); + expect(result.functions[0]).toHaveProperty('modifiesState', false); + expect(result.functions[1]).toHaveProperty('modifiesState', false); + expect(result.functions[2]).toHaveProperty('modifiesState', true); + expect(result.functions[3]).toHaveProperty('modifiesState', true); + }); + + it('should correctly map input and output parameters', () => { + const abi: readonly AbiItem[] = [ + { + type: 'function', + name: 'transfer', + inputs: [ + { name: 'to', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ name: 'success', type: 'bool' }], + stateMutability: 'nonpayable', + }, + ]; + const result = transformAbiToSchema(abi, mockContractName); + const func = result.functions[0]; + expect(func.inputs).toHaveLength(2); + expect(func.inputs[0]).toEqual({ name: 'to', type: 'address', displayName: 'formatted_to' }); + expect(func.inputs[1]).toEqual({ + name: 'amount', + type: 'uint256', + displayName: 'formatted_amount', + }); + expect(func.outputs).toBeDefined(); + expect(func.outputs).toHaveLength(1); + if (func.outputs) { + expect(func.outputs[0]).toEqual({ + name: 'success', + type: 'bool', + displayName: 'formatted_success', + }); + } + }); + + it('should strip internalType and other non-standard properties from parameters', () => { + const abi: readonly AbiItem[] = [ + { + type: 'function', + name: 'complexCall', + inputs: [ + { + name: 'param1', + type: 'address', + internalType: 'contract IERC20', // This should be stripped + extraProperty: 'shouldBeGone', // This should be stripped + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, // Cast the input object to allow extra property for the test + ], + outputs: [], + stateMutability: 'view', + }, + ]; + const result = transformAbiToSchema(abi, mockContractName); + const funcInput = result.functions[0].inputs[0]; + expect(funcInput).toEqual({ + name: 'param1', + type: 'address', + displayName: 'formatted_param1', + }); + expect(funcInput).not.toHaveProperty('internalType'); + expect(funcInput).not.toHaveProperty('extraProperty'); + }); + + it('should handle tuple inputs and strip internalType from components', () => { + const abi: readonly AbiItem[] = [ + { + type: 'function', + name: 'processStruct', + inputs: [ + { + name: 'myStruct', + type: 'tuple', + components: [ + { name: 'field1', type: 'uint256', internalType: 'uint256_internal' }, + { name: 'field2', type: 'address', internalType: 'address_internal' }, + ], + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + ]; + const result = transformAbiToSchema(abi, mockContractName); + const structInput = result.functions[0].inputs[0]; + expect(structInput.type).toBe('tuple'); + expect(structInput.components).toBeDefined(); + expect(structInput.components).toHaveLength(2); + if (structInput.components) { + expect(structInput.components[0]).toEqual({ + name: 'field1', + type: 'uint256', + displayName: 'formatted_field1', + }); + expect(structInput.components[0]).not.toHaveProperty('internalType'); + expect(structInput.components[1]).toEqual({ + name: 'field2', + type: 'address', + displayName: 'formatted_field2', + }); + expect(structInput.components[1]).not.toHaveProperty('internalType'); + } + }); + + it('should generate a unique ID for functions, considering overloads', () => { + const abi: readonly AbiItem[] = [ + { + type: 'function', + name: 'overloadedFunc', + inputs: [{ name: 'a', type: 'uint256' }], + outputs: [], + stateMutability: 'view', + }, + { + type: 'function', + name: 'overloadedFunc', + inputs: [{ name: 'a', type: 'string' }], + outputs: [], + stateMutability: 'view', + }, + ]; + const result = transformAbiToSchema(abi, mockContractName); + expect(result.functions[0].id).toBe('overloadedFunc_uint256'); + expect(result.functions[1].id).toBe('overloadedFunc_string'); + }); + + it('should handle missing names for functions and parameters gracefully', () => { + // Define the parameter with undefined name separately to test handling + const paramWithoutName = { name: undefined, type: 'bool' }; + const abi: readonly AbiItem[] = [ + { + type: 'function', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + name: undefined as any, // Simulate missing function name - intentional 'any' for test + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - Deliberately passing possibly invalid input type for robustness test + inputs: [paramWithoutName], // Use the defined object, ignoring TS error for test + outputs: [], + stateMutability: 'view', + }, + ]; + const result = transformAbiToSchema(abi, mockContractName); + expect(result.functions[0].name).toBe(''); + expect(result.functions[0].displayName).toBe('formatted_'); + expect(result.functions[0].inputs[0].name).toBe(''); + expect(result.functions[0].inputs[0].displayName).toBe('param_bool'); + }); + }); + + describe('createAbiFunctionItem', () => { + it('should convert a simple ContractFunction to AbiFunction', () => { + const contractFunc: ContractFunction = { + id: 'test_func_', + name: 'testFunc', + displayName: 'Test Func', + inputs: [], + outputs: [], + type: 'function', + stateMutability: 'view', + modifiesState: false, + }; + const expectedAbiFunc: AbiFunction = { + name: 'testFunc', + type: 'function', + inputs: [], + outputs: [], + stateMutability: 'view', + }; + expect(createAbiFunctionItem(contractFunc)).toEqual(expectedAbiFunc); + }); + + it('should convert ContractFunction with inputs and outputs', () => { + const contractFunc: ContractFunction = { + id: 'transfer_address_uint256', + name: 'transfer', + displayName: 'Transfer', + inputs: [ + { name: 'to', type: 'address', displayName: 'To' }, + { name: 'amount', type: 'uint256', displayName: 'Amount' }, + ], + outputs: [{ name: 'success', type: 'bool', displayName: 'Success' }], + type: 'function', + stateMutability: 'nonpayable', + modifiesState: true, + }; + const expectedAbiFunc: AbiFunction = { + name: 'transfer', + type: 'function', + inputs: [ + { name: 'to', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ name: 'success', type: 'bool' }], + stateMutability: 'nonpayable', + }; + expect(createAbiFunctionItem(contractFunc)).toEqual(expectedAbiFunc); + }); + + it('should convert ContractFunction with tuple components', () => { + const contractFunc: ContractFunction = { + id: 'structFunc_tuple', + name: 'structFunc', + displayName: 'Struct Func', + inputs: [ + { + name: 'myStruct', + type: 'tuple', + displayName: 'My Struct', + components: [ + { name: 'field1', type: 'uint256', displayName: 'Field 1' }, + { name: 'field2', type: 'address', displayName: 'Field 2' }, + ], + }, + ], + outputs: [], + type: 'function', + stateMutability: 'pure', + modifiesState: false, + }; + const expectedAbiFunc: AbiFunction = { + name: 'structFunc', + type: 'function', + inputs: [ + { + name: 'myStruct', + type: 'tuple', + components: [ + { name: 'field1', type: 'uint256' }, + { name: 'field2', type: 'address' }, + ], + }, + ], + outputs: [], + stateMutability: 'pure', + }; + expect(createAbiFunctionItem(contractFunc)).toEqual(expectedAbiFunc); + }); + + it('should default stateMutability to view if undefined in ContractFunction', () => { + const contractFunc: ContractFunction = { + id: 'anotherFunc_', + name: 'anotherFunc', + displayName: 'Another Func', + inputs: [], + outputs: [], + type: 'function', + // stateMutability is undefined + modifiesState: false, + }; + const result = createAbiFunctionItem(contractFunc); + expect(result.stateMutability).toBe('view'); + }); + }); +}); diff --git a/packages/adapter-evm/src/abi/etherscan.ts b/packages/adapter-evm/src/abi/etherscan.ts new file mode 100644 index 00000000..23be9f6e --- /dev/null +++ b/packages/adapter-evm/src/abi/etherscan.ts @@ -0,0 +1,87 @@ +import type { ContractSchema, EvmNetworkConfig } from '@openzeppelin/transaction-form-types'; + +import type { AbiItem } from '../types'; + +import { transformAbiToSchema } from './transformer'; + +/** + * Fetches and parses an ABI from Etherscan-compatible explorers using a contract address and network config. + */ +export async function loadAbiFromEtherscan( + address: string, + networkConfig: EvmNetworkConfig +): Promise { + // Try to get the API key from environment variables + const apiKey = import.meta.env.VITE_ETHERSCAN_API_KEY; + if (!apiKey) { + console.error('loadAbiFromEtherscan', 'Etherscan API Key (VITE_ETHERSCAN_API_KEY) is missing.'); + throw new Error('Etherscan API Key is not configured.'); + } + + const apiBaseUrl = networkConfig.apiUrl; + + if (!apiBaseUrl) { + console.error( + 'loadAbiFromEtherscan', + `API URL (apiUrl) is missing in the network configuration for ${networkConfig.name} (ID: ${networkConfig.id}).` + ); + throw new Error( + `Etherscan-compatible API URL is not configured for network: ${networkConfig.name}` + ); + } + + const url = `${apiBaseUrl}?module=contract&action=getabi&address=${address}&apikey=${apiKey}`; + + let response: Response; + try { + console.info(`Fetching ABI from ${apiBaseUrl} for address: ${address}`); + response = await fetch(url); + } catch (networkError) { + console.error('Network error fetching ABI from Etherscan:', networkError); + throw new Error(`Network error fetching ABI: ${(networkError as Error).message}`); + } + + if (!response.ok) { + console.error(`Etherscan API request failed with status: ${response.status}`); + throw new Error(`Etherscan API request failed: ${response.status} ${response.statusText}`); + } + + let etherscanResult: { status: string; message: string; result: string }; + try { + etherscanResult = await response.json(); + } catch (jsonError) { + console.error('Failed to parse Etherscan API response as JSON:', jsonError); + throw new Error('Invalid JSON response received from Etherscan API.'); + } + + if (etherscanResult.status !== '1') { + console.warn( + 'Etherscan API error:', + `Status ${etherscanResult.status}, Result: ${etherscanResult.result}` + ); + if (etherscanResult.result?.includes('Contract source code not verified')) { + throw new Error( + `Contract not verified on ${networkConfig.name} explorer (address: ${address}). ABI not available.` + ); + } + throw new Error(`Etherscan API Error: ${etherscanResult.result || etherscanResult.message}`); + } + + let abi: AbiItem[]; + try { + abi = JSON.parse(etherscanResult.result); + if (!Array.isArray(abi)) { + throw new Error('Parsed ABI from Etherscan is not an array.'); + } + } catch (error) { + console.error('Failed to parse ABI JSON string from Etherscan result:', error); + throw new Error(`Invalid ABI JSON received from Etherscan: ${(error as Error).message}`); + } + + console.info( + `Successfully parsed Etherscan ABI for ${networkConfig.name} with ${abi.length} items.` + ); + // TODO: Fetch contract name? + const contractName = `Contract_${address.substring(0, 6)}`; + return transformAbiToSchema(abi, contractName, address); +} diff --git a/packages/adapter-evm/src/abi/index.ts b/packages/adapter-evm/src/abi/index.ts new file mode 100644 index 00000000..b6652700 --- /dev/null +++ b/packages/adapter-evm/src/abi/index.ts @@ -0,0 +1,3 @@ +// Barrel file for abi module +export * from './transformer'; +export * from './loader'; diff --git a/packages/adapter-evm/src/abi/loader.ts b/packages/adapter-evm/src/abi/loader.ts new file mode 100644 index 00000000..49c8b1b2 --- /dev/null +++ b/packages/adapter-evm/src/abi/loader.ts @@ -0,0 +1,47 @@ +import { isAddress } from 'viem'; + +import type { ContractSchema, EvmNetworkConfig } from '@openzeppelin/transaction-form-types'; + +import type { AbiItem } from '../types'; + +import { loadAbiFromEtherscan } from './etherscan'; +import { transformAbiToSchema } from './transformer'; + +/** + * Loads and parses an ABI directly from a JSON string. + */ +async function loadAbiFromJson(abiJsonString: string): Promise { + let abi: AbiItem[]; + try { + abi = JSON.parse(abiJsonString); + if (!Array.isArray(abi)) { + throw new Error('Parsed JSON is not an array.'); + } + } catch (error) { + console.error('loadAbiFromJson', 'Failed to parse source string as JSON ABI:', error); + throw new Error(`Invalid JSON ABI provided: ${(error as Error).message}`); + } + + console.info(`Successfully parsed JSON ABI with ${abi.length} items.`); + const contractName = 'ContractFromABI'; // Default name for direct ABI + return transformAbiToSchema(abi, contractName, undefined); +} + +/** + * Loads contract schema by detecting if the source is an address (fetch from Etherscan) + * or a JSON string (parse directly). + * + * Requires networkConfig when source is an address. + */ +export async function loadEvmContract( + source: string, + networkConfig: EvmNetworkConfig +): Promise { + if (isAddress(source)) { + console.info(`Detected address: ${source}. Attempting Etherscan ABI fetch...`); + return loadAbiFromEtherscan(source, networkConfig); + } else { + console.info('Input is not an address. Attempting to parse as JSON ABI...'); + return loadAbiFromJson(source); + } +} diff --git a/packages/adapter-evm/src/abi/transformer.ts b/packages/adapter-evm/src/abi/transformer.ts new file mode 100644 index 00000000..d82421a7 --- /dev/null +++ b/packages/adapter-evm/src/abi/transformer.ts @@ -0,0 +1,163 @@ +import type { AbiFunction, AbiParameter, AbiStateMutability } from 'viem'; + +import type { + ContractFunction, + ContractSchema, + FunctionParameter, +} from '@openzeppelin/transaction-form-types'; + +import type { AbiItem } from '../types'; +import { formatInputName, formatMethodName } from '../utils'; + +/** + * Transforms a standard ABI array (typically from an EVM-compatible chain) + * into the project's internal `ContractSchema` format. + * This schema is used by the form builder and renderer to represent contract interactions + * in a chain-agnostic way (though this specific transformer is for EVM ABIs). + * + * @param abi The raw ABI array (e.g., parsed from a JSON ABI file or fetched from Etherscan). + * It's expected to be an array of `AbiItem` (from viem types or a compatible structure). + * @param contractName A name to assign to the contract within the schema. This might be derived + * from a file name, user input, or a default if not otherwise available. + * @param address Optional address of the deployed contract. If provided, it's included in the schema. + * @returns A `ContractSchema` object representing the contract's interface. + */ +export function transformAbiToSchema( + abi: readonly AbiItem[], + contractName: string, + address?: string +): ContractSchema { + console.info(`Transforming ABI to ContractSchema for: ${contractName}`); + const functions: ContractFunction[] = []; + + for (const item of abi) { + // We are only interested in 'function' type items from the ABI + // to map them to our ContractFunction interface. + if (item.type === 'function') { + // After confirming item.type is 'function', we can safely cast it to AbiFunction + // to access function-specific properties like `stateMutability`, `inputs`, `outputs`. + const abiFunctionItem = item as AbiFunction; + functions.push({ + // Generate a unique ID for the function within the schema. + // This often combines name and input types to handle overloads. + id: `${abiFunctionItem.name}_${abiFunctionItem.inputs?.map((i) => i.type).join('_') || ''}`, + name: abiFunctionItem.name || '', // Fallback for unnamed functions (though rare). + displayName: formatMethodName(abiFunctionItem.name || ''), // Create a more readable name for UI. + // Recursively map ABI inputs and outputs to our FunctionParameter structure. + // This ensures that any non-standard properties (like 'internalType') are stripped. + inputs: mapAbiParametersToSchemaParameters(abiFunctionItem.inputs), + outputs: mapAbiParametersToSchemaParameters(abiFunctionItem.outputs), + type: 'function', // Explicitly set, as we filtered for this type. + stateMutability: abiFunctionItem.stateMutability, // Preserve EVM-specific state mutability. + // Determine if the function modifies blockchain state based on its `stateMutability`. + // This is a crucial piece of information for the UI (e.g., to differentiate read vs. write calls). + modifiesState: + !abiFunctionItem.stateMutability || // If undefined, assume it modifies state (safer default) + !['view', 'pure'].includes(abiFunctionItem.stateMutability), + }); + } + } + + const contractSchema: ContractSchema = { + ecosystem: 'evm', // This transformer is specific to EVM. + name: contractName, + address, + functions, + }; + console.info(`Transformation complete. Found ${contractSchema.functions.length} functions.`); + return contractSchema; +} + +/** + * Recursively maps an array of ABI parameters (from viem's `AbiParameter` type or compatible) + * to an array of `FunctionParameter` objects, which is our internal representation. + * This function is crucial for stripping any properties not defined in `FunctionParameter` + * (e.g., `internalType` from the raw ABI) and for handling nested components (structs/tuples). + * + * @param abiParams An array of ABI parameter objects. Can be undefined (e.g., if a function has no inputs/outputs). + * @returns An array of `FunctionParameter` objects, or an empty array if `abiParams` is undefined. + */ +function mapAbiParametersToSchemaParameters( + abiParams: readonly AbiParameter[] | undefined +): FunctionParameter[] { + if (!abiParams) { + return []; + } + return abiParams.map((param): FunctionParameter => { + // Create the base FunctionParameter object, picking only defined properties. + const schemaParam: FunctionParameter = { + name: param.name || '', // Ensure name is a string, fallback if undefined in ABI. + type: param.type, // The raw type string from the ABI (e.g., 'uint256', 'address', 'tuple'). + displayName: formatInputName(param.name || '', param.type), // Generate a user-friendly name. + // `description` is not a standard part of an ABI parameter, so it's not mapped here. + // It can be added later by the user in the form builder UI. + }; + // Check for nested components (structs/tuples). + // `param.type.startsWith('tuple')` checks if it's a tuple or tuple array. + // `'components' in param` is a type guard for discriminated unions. + // `param.components && param.components.length > 0` ensures components exist and are not empty. + if ( + param.type.startsWith('tuple') && + 'components' in param && // Type guard for discriminated union (AbiParameter) + param.components && + param.components.length > 0 + ) { + // If components exist, recursively call this function to map them. + // This ensures that nested structures also conform to `FunctionParameter` and strip extra fields. + // Cast `param.components` because TypeScript might not fully infer its type after the `in` check within the map. + schemaParam.components = mapAbiParametersToSchemaParameters( + param.components as readonly AbiParameter[] + ); + } + return schemaParam; + }); +} + +/** + * Helper function to convert one of our internal `FunctionParameter` objects + * back into a format compatible with viem's `AbiParameter` type. + * This is primarily used by `createAbiFunctionItem` when constructing an `AbiFunction` + * for interactions with viem or other ABI-consuming libraries. + * It ensures that only properties expected by `AbiParameter` are included. + * + * @param param The internal `FunctionParameter` object. + * @returns An `AbiParameter` object compatible with viem. + */ +function mapSchemaParameterToAbiParameter(param: FunctionParameter): AbiParameter { + // Handle tuple types specifically, as `AbiParameter` for tuples requires a `components` array. + if (param.type.startsWith('tuple') && param.components && param.components.length > 0) { + return { + name: param.name || undefined, // ABI parameter names can be undefined (e.g., for return values). + type: param.type as `tuple${string}`, // Cast to satisfy viem's specific tuple type string. + // Recursively map nested components back to AbiParameter format. + components: param.components.map(mapSchemaParameterToAbiParameter), + }; + } + // For non-tuple types, return a simpler AbiParameter structure. + return { + name: param.name || undefined, + type: param.type, + // `internalType` is not part of our `FunctionParameter` model, so it's not added back here. + // Other ABI-specific fields like `indexed` (for events) are also not relevant here as + // this function is focused on function parameters for `AbiFunction`. + }; +} + +/** + * Private helper to convert internal `ContractFunction` details (our model) + * back into a viem `AbiFunction` object. + * This is useful when interacting with libraries like viem that expect a standard ABI format. + * Ensures that the generated AbiFunction conforms to viem's type definitions. + * + * @param functionDetails The `ContractFunction` object from our internal schema. + * @returns An `AbiFunction` object. + */ +export function createAbiFunctionItem(functionDetails: ContractFunction): AbiFunction { + return { + name: functionDetails.name, + type: 'function', + inputs: functionDetails.inputs.map(mapSchemaParameterToAbiParameter), + outputs: functionDetails.outputs?.map(mapSchemaParameterToAbiParameter) || [], + stateMutability: (functionDetails.stateMutability ?? 'view') as AbiStateMutability, + }; +} diff --git a/packages/adapter-evm/src/adapter.ts b/packages/adapter-evm/src/adapter.ts new file mode 100644 index 00000000..ca9c9830 --- /dev/null +++ b/packages/adapter-evm/src/adapter.ts @@ -0,0 +1,274 @@ +import type { GetAccountReturnType } from '@wagmi/core'; +import { type TransactionReceipt } from 'viem'; + +import type { + Connector, + ContractAdapter, + ContractFunction, + ContractSchema, + EvmNetworkConfig, + ExecutionConfig, + ExecutionMethodDetail, + FieldType, + FormFieldType, + FunctionParameter, +} from '@openzeppelin/transaction-form-types'; +import { isEvmNetworkConfig } from '@openzeppelin/transaction-form-types'; + +import { WagmiWalletImplementation } from './wallet/wagmi-implementation'; + +import { loadEvmContract } from './abi'; +import { + getEvmExplorerAddressUrl, + getEvmExplorerTxUrl, + getEvmSupportedExecutionMethods, + validateEvmExecutionConfig, +} from './configuration'; +import { + generateEvmDefaultField, + getEvmCompatibleFieldTypes, + mapEvmParamTypeToFieldType, +} from './mapping'; +import { isEvmViewFunction, queryEvmViewFunction } from './query'; +import { + formatEvmTransactionData, + signAndBroadcastEvmTransaction, + waitForEvmTransactionConfirmation, +} from './transaction'; +import { formatEvmFunctionResult } from './transform'; +import type { WriteContractParameters } from './types'; +import { isValidEvmAddress } from './utils'; +import { + connectAndEnsureCorrectNetwork, + disconnectEvmWallet, + evmSupportsWalletConnection, + getEvmAvailableConnectors, + getEvmWalletConnectionStatus, + onEvmWalletConnectionChange, +} from './wallet'; + +/** + * EVM-specific adapter implementation + */ +export class EvmAdapter implements ContractAdapter { + private walletImplementation: WagmiWalletImplementation; + readonly networkConfig: EvmNetworkConfig; + + constructor(networkConfig: EvmNetworkConfig) { + if (!isEvmNetworkConfig(networkConfig)) { + throw new Error('EvmAdapter requires a valid EVM network configuration.'); + } + this.networkConfig = networkConfig; + this.walletImplementation = new WagmiWalletImplementation(); + + console.log( + 'EvmAdapter initialized for network:', + `${networkConfig.name} (ID: ${networkConfig.id})` + ); + } + + /** + * @inheritdoc + */ + async loadContract(source: string): Promise { + return loadEvmContract(source, this.networkConfig); + } + + /** + * @inheritdoc + */ + mapParameterTypeToFieldType(parameterType: string): FieldType { + return mapEvmParamTypeToFieldType(parameterType); + } + + /** + * @inheritdoc + */ + getCompatibleFieldTypes(parameterType: string): FieldType[] { + return getEvmCompatibleFieldTypes(parameterType); + } + + /** + * @inheritdoc + */ + generateDefaultField( + parameter: FunctionParameter + ): FormFieldType { + return generateEvmDefaultField(parameter); + } + + /** + * @inheritdoc + */ + formatTransactionData( + contractSchema: ContractSchema, + functionId: string, + submittedInputs: Record, + allFieldsConfig: FormFieldType[] + ): unknown { + return formatEvmTransactionData(contractSchema, functionId, submittedInputs, allFieldsConfig); + } + + /** + * @inheritdoc + */ + async signAndBroadcast(transactionData: unknown): Promise<{ txHash: string }> { + return signAndBroadcastEvmTransaction( + transactionData as WriteContractParameters, + this.walletImplementation, + this.networkConfig.chainId + ); + } + + /** + * @inheritdoc + */ + getWritableFunctions(contractSchema: ContractSchema): ContractSchema['functions'] { + return contractSchema.functions.filter((fn) => fn.modifiesState); + } + + /** + * @inheritdoc + */ + isValidAddress(address: string): boolean { + return isValidEvmAddress(address); + } + + /** + * @inheritdoc + */ + public async getSupportedExecutionMethods(): Promise { + return getEvmSupportedExecutionMethods(); + } + + /** + * @inheritdoc + */ + public async validateExecutionConfig(config: ExecutionConfig): Promise { + return validateEvmExecutionConfig(config); + } + + /** + * @inheritdoc + */ + isViewFunction(functionDetails: ContractFunction): boolean { + return isEvmViewFunction(functionDetails); + } + + /** + * @inheritdoc + */ + async queryViewFunction( + contractAddress: string, + functionId: string, + params: unknown[] = [], + contractSchema?: ContractSchema + ): Promise { + return queryEvmViewFunction( + contractAddress, + functionId, + this.networkConfig, + params, + contractSchema, + this.walletImplementation, + (src) => this.loadContract(src) + ); + } + + /** + * @inheritdoc + */ + formatFunctionResult(decodedValue: unknown, functionDetails: ContractFunction): string { + return formatEvmFunctionResult(decodedValue, functionDetails); + } + + /** + * @inheritdoc + */ + supportsWalletConnection(): boolean { + return evmSupportsWalletConnection(); + } + + /** + * @inheritdoc + */ + async getAvailableConnectors(): Promise { + return getEvmAvailableConnectors(this.walletImplementation); + } + + /** + * @inheritdoc + */ + async connectWallet( + connectorId: string + ): Promise<{ connected: boolean; address?: string; error?: string }> { + const result = await connectAndEnsureCorrectNetwork( + connectorId, + this.walletImplementation, + this.networkConfig.chainId + ); + + if (result.connected && result.address) { + return { connected: true, address: result.address }; + } else { + return { + connected: false, + error: result.error || 'Connection failed for an unknown reason.', + }; + } + } + + /** + * @inheritdoc + */ + async disconnectWallet(): Promise<{ disconnected: boolean; error?: string }> { + return disconnectEvmWallet(this.walletImplementation); + } + + /** + * @inheritdoc + */ + getWalletConnectionStatus(): { isConnected: boolean; address?: string; chainId?: string } { + return getEvmWalletConnectionStatus(this.walletImplementation); + } + + /** + * @inheritdoc + */ + onWalletConnectionChange( + callback: (account: GetAccountReturnType, prevAccount: GetAccountReturnType) => void + ): () => void { + return onEvmWalletConnectionChange(this.walletImplementation, callback); + } + + /** + * @inheritdoc + */ + getExplorerUrl(address: string): string | null { + return getEvmExplorerAddressUrl(address, this.networkConfig); + } + + /** + * @inheritdoc + */ + getExplorerTxUrl?(txHash: string): string | null { + if (getEvmExplorerTxUrl) { + return getEvmExplorerTxUrl(txHash, this.networkConfig); + } + return null; + } + + /** + * @inheritdoc + */ + async waitForTransactionConfirmation(txHash: string): Promise<{ + status: 'success' | 'error'; + receipt?: TransactionReceipt; + error?: Error; + }> { + return waitForEvmTransactionConfirmation(txHash, this.walletImplementation); + } +} + +// Also export as default to ensure compatibility with various import styles +export default EvmAdapter; diff --git a/packages/core/src/adapters/evm/config.ts b/packages/adapter-evm/src/config.ts similarity index 80% rename from packages/core/src/adapters/evm/config.ts rename to packages/adapter-evm/src/config.ts index 626a155f..726778d6 100644 --- a/packages/core/src/adapters/evm/config.ts +++ b/packages/adapter-evm/src/config.ts @@ -1,5 +1,3 @@ -import type { AdapterConfig } from '../../core/types/AdapterTypes'; - /** * Configuration for the EVM adapter * @@ -7,7 +5,7 @@ import type { AdapterConfig } from '../../core/types/AdapterTypes'; * when generating exported projects. It follows the AdapterConfig * interface to provide a structured approach to dependency management. */ -export const evmAdapterConfig: AdapterConfig = { +export const evmAdapterConfig = { /** * Dependencies required by the EVM adapter * These will be included in exported projects that use this adapter @@ -18,7 +16,10 @@ export const evmAdapterConfig: AdapterConfig = { // Runtime dependencies runtime: { // Core EVM libraries - ethers: '^6.13.5', + ethers: '^6.13.5', // TODO: Replace with viem? + // Wallet connection libraries + wagmi: '^2.15.0', + viem: '^2.28.0', // Utility library lodash: '^4.17.21', }, diff --git a/packages/adapter-evm/src/configuration/__tests__/rpc.test.ts b/packages/adapter-evm/src/configuration/__tests__/rpc.test.ts new file mode 100644 index 00000000..c8c189e7 --- /dev/null +++ b/packages/adapter-evm/src/configuration/__tests__/rpc.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { EvmNetworkConfig } from '@openzeppelin/transaction-form-types'; + +import { resolveRpcUrl } from '../rpc'; + +// Adjust path as needed + +// Helper to create a mock EvmNetworkConfig +const createMockConfig = (id: string, rpcUrl?: string): EvmNetworkConfig => ({ + id, + name: `Test ${id}`, + ecosystem: 'evm', + network: 'test-network', + type: 'testnet', + isTestnet: true, + exportConstName: id.replace(/-([a-z])/g, (g) => g[1].toUpperCase()), + chainId: 1337, + rpcUrl: rpcUrl || 'https://default-public.rpc.com', // Default public RPC for the mock + nativeCurrency: { name: 'TETH', symbol: 'TETH', decimals: 18 }, + apiUrl: 'https://api.etherscan.io/api', + icon: 'ethereum', +}); + +describe('resolveRpcUrl', () => { + afterEach(() => { + // Clean up any environment stubs after each test + vi.unstubAllEnvs(); + }); + + it('should use VITE_RPC_URL_ if set', () => { + const networkId = 'ethereum-mainnet'; + const envRpcUrl = 'https://env-override.rpc.com'; + vi.stubEnv(`VITE_RPC_URL_ETHEREUM_MAINNET`, envRpcUrl); + + const config = createMockConfig(networkId, 'https://config.rpc.com'); + expect(resolveRpcUrl(config)).toBe(envRpcUrl); + }); + + it('should correctly format NETWORK_ID with hyphens for env var lookup', () => { + const networkId = 'some-test-network'; + const envRpcUrl = 'https://hyphen-test.rpc.com'; + vi.stubEnv(`VITE_RPC_URL_SOME_TEST_NETWORK`, envRpcUrl); + + const config = createMockConfig(networkId, 'https://config.rpc.com'); + expect(resolveRpcUrl(config)).toBe(envRpcUrl); + }); + + it('should use networkConfig.rpcUrl if no specific environment variable is set', () => { + const networkId = 'ethereum-sepolia'; + const configRpcUrl = 'https://sepolia-public.rpc.com'; + const config = createMockConfig(networkId, configRpcUrl); + + // No need to delete, unstubAllEnvs handles cleanup + expect(resolveRpcUrl(config)).toBe(configRpcUrl); + }); + + it('should throw an error if rpcUrl is missing in config and no env var is set', () => { + const networkId = 'missing-rpc-config'; + // Create a config where rpcUrl is explicitly undefined, cast to bypass type check for test + const config = { + ...createMockConfig(networkId, 'http://dummy.com'), // provide a dummy for base object creation + rpcUrl: undefined as unknown as string, // Then force undefined + }; + + expect(() => resolveRpcUrl(config)).toThrowError( + `Could not resolve RPC URL for network: ${config.name}. Please ensure networkConfig.rpcUrl is set or provide the VITE_RPC_URL_MISSING_RPC_CONFIG environment variable.` + ); + }); + + it('should handle network IDs with different casings for env var lookup (e.g. all caps)', () => { + const networkIdInConfig = 'allcapsnet-lower'; // e.g., from a file + const networkIdForEnv = 'ALLCAPSNET_LOWER'; // The key format + const envRpcUrl = 'https://allcaps.rpc.com'; + vi.stubEnv(`VITE_RPC_URL_${networkIdForEnv}`, envRpcUrl); + + // networkConfig.id is used to derive the env var key, so it should match the intended lookup pattern + const config = createMockConfig(networkIdInConfig, 'https://config.rpc.com'); + expect(resolveRpcUrl(config)).toBe(envRpcUrl); + }); +}); diff --git a/packages/adapter-evm/src/configuration/execution.ts b/packages/adapter-evm/src/configuration/execution.ts new file mode 100644 index 00000000..dd3c3d34 --- /dev/null +++ b/packages/adapter-evm/src/configuration/execution.ts @@ -0,0 +1,64 @@ +import type { ExecutionConfig, ExecutionMethodDetail } from '@openzeppelin/transaction-form-types'; + +import { isValidEvmAddress } from '../utils'; + +/** + * Returns details for execution methods supported by the EVM adapter. + */ +export async function getEvmSupportedExecutionMethods(): Promise { + console.warn('getEvmSupportedExecutionMethods is using placeholder implementation.'); + // TODO: Implement actual supported methods for EVM (e.g., EOA, Safe). + return Promise.resolve([ + { + type: 'eoa', + name: 'EOA (External Account)', + description: 'Execute using a standard wallet address.', + }, + { + type: 'multisig', + name: 'Safe Multisig', // Example for future + description: 'Execute via a Safe multisignature wallet.', + disabled: false, + }, + { + type: 'relayer', + name: 'Relayer (Placeholder)', + description: 'Execute via a OpenZeppelin transaction relayer (not yet implemented).', + disabled: false, + }, + ]); +} + +/** + * Validates the complete execution configuration object against the + * requirements and capabilities of the EVM adapter. + */ +export async function validateEvmExecutionConfig(config: ExecutionConfig): Promise { + console.warn('validateEvmExecutionConfig is using placeholder implementation.'); + // TODO: Implement actual validation logic for EVM execution configs. + switch (config.method) { + case 'eoa': { + if (!config.allowAny) { + if (!config.specificAddress) { + return 'Specific EOA address is required.'; + } + // Use the imported utility for validation + if (!isValidEvmAddress(config.specificAddress)) { + return 'Invalid EOA address format.'; + } + } + return true; + } + case 'multisig': { + // Placeholder: Accept multisig config for now + return true; + } + case 'relayer': { + // Placeholder: Accept relayer config for now + return true; + } + default: { + return `Unsupported execution method type: ${(config as ExecutionConfig).method}`; + } + } +} diff --git a/packages/adapter-evm/src/configuration/explorer.ts b/packages/adapter-evm/src/configuration/explorer.ts new file mode 100644 index 00000000..f57f051e --- /dev/null +++ b/packages/adapter-evm/src/configuration/explorer.ts @@ -0,0 +1,32 @@ +import { NetworkConfig } from '@openzeppelin/transaction-form-types'; + +import { isValidEvmAddress } from '../utils'; + +/** + * Gets a blockchain explorer URL for an EVM address. + * Uses the explorerUrl from the network configuration. + */ +export function getEvmExplorerAddressUrl( + address: string, + networkConfig: NetworkConfig +): string | null { + if (!isValidEvmAddress(address) || !networkConfig?.explorerUrl) { + return null; + } + // Construct the URL using the explorerUrl from the config + const baseUrl = networkConfig.explorerUrl.replace(/\/+$/, ''); + return `${baseUrl}/address/${address}`; +} + +/** + * Gets a blockchain explorer URL for an EVM transaction. + * Uses the explorerUrl from the network configuration. + */ +export function getEvmExplorerTxUrl(txHash: string, networkConfig: NetworkConfig): string | null { + if (!txHash || !networkConfig?.explorerUrl) { + return null; + } + // Construct the URL using the explorerUrl from the config + const baseUrl = networkConfig.explorerUrl.replace(/\/+$/, ''); + return `${baseUrl}/tx/${txHash}`; +} diff --git a/packages/adapter-evm/src/configuration/index.ts b/packages/adapter-evm/src/configuration/index.ts new file mode 100644 index 00000000..90112054 --- /dev/null +++ b/packages/adapter-evm/src/configuration/index.ts @@ -0,0 +1,4 @@ +// Barrel file for config module +export * from './execution'; +export * from './explorer'; +export * from './rpc'; diff --git a/packages/adapter-evm/src/configuration/rpc.ts b/packages/adapter-evm/src/configuration/rpc.ts new file mode 100644 index 00000000..a0124616 --- /dev/null +++ b/packages/adapter-evm/src/configuration/rpc.ts @@ -0,0 +1,48 @@ +import type { EvmNetworkConfig } from '@openzeppelin/transaction-form-types'; + +// No longer need PUBLIC_RPC_FALLBACKS array or individual constants here + +/** + * Resolves the RPC URL to use based on environment variables and network config. + * + * Priority: + * 1. VITE_RPC_URL_ (e.g., VITE_RPC_URL_ETHEREUM_SEPOLIA) + * 2. networkConfig.rpcUrl (which should be a public RPC for that specific network). + * + * @param networkConfig The network configuration object containing a default public rpcUrl. + * @returns The resolved RPC URL string. + * @throws If networkConfig.rpcUrl is missing and no override is found (should not happen with proper config). + */ +export function resolveRpcUrl(networkConfig: EvmNetworkConfig): string { + const env = import.meta.env; + const networkIdUpper = networkConfig.id.toUpperCase().replace(/-/g, '_'); + const envVarKey = `VITE_RPC_URL_${networkIdUpper}`; + + console.log(`[resolveRpcUrl] Received config for resolving RPC: ${networkConfig.id}`); + console.log('[resolveRpcUrl] Env Var Checked:', { + [envVarKey]: env[envVarKey], + }); + + // 1. Specific Network ID Env Var (e.g., VITE_RPC_URL_ETHEREUM_SEPOLIA) + const specificNetworkEnvVar = env[envVarKey]; + if (specificNetworkEnvVar) { + console.debug(`Using RPC URL from env var ${envVarKey}: ${specificNetworkEnvVar}`); + return specificNetworkEnvVar; + } + + // 2. Fallback to the rpcUrl defined in the networkConfig itself + if (networkConfig.rpcUrl) { + console.debug( + `Using RPC URL from networkConfig for ${networkConfig.name}: ${networkConfig.rpcUrl}` + ); + return networkConfig.rpcUrl; + } + + // This should ideally not be reached if networkConfigs always have a valid public rpcUrl + console.error( + `RPC URL is missing in networkConfig and no override env var (${envVarKey}) is set for ${networkConfig.name}` + ); + throw new Error( + `Could not resolve RPC URL for network: ${networkConfig.name}. Please ensure networkConfig.rpcUrl is set or provide the ${envVarKey} environment variable.` + ); +} diff --git a/packages/adapter-evm/src/index.ts b/packages/adapter-evm/src/index.ts new file mode 100644 index 00000000..1a7561c7 --- /dev/null +++ b/packages/adapter-evm/src/index.ts @@ -0,0 +1,21 @@ +import EvmAdapter from './adapter'; + +// Re-export the main adapter class +export { EvmAdapter }; + +// Optionally re-export types if they need to be accessible directly +// export * from './types'; + +export { + evmNetworks, + evmMainnetNetworks, + evmTestnetNetworks, + // Individual networks + ethereumMainnet, + polygonMainnet, + ethereumSepolia, + polygonAmoy, + // ... other individual network exports +} from './networks'; + +// Export other adapter-specific items if any diff --git a/packages/adapter-evm/src/mapping/constants.ts b/packages/adapter-evm/src/mapping/constants.ts new file mode 100644 index 00000000..90c70837 --- /dev/null +++ b/packages/adapter-evm/src/mapping/constants.ts @@ -0,0 +1,26 @@ +import type { FieldType } from '@openzeppelin/transaction-form-types'; + +/** + * EVM-specific type mapping to default form field types. + */ +export const EVM_TYPE_TO_FIELD_TYPE: Record = { + address: 'blockchain-address', + string: 'text', + uint: 'number', + uint8: 'number', + uint16: 'number', + uint32: 'number', + uint64: 'number', + uint128: 'number', + uint256: 'number', + int: 'number', + int8: 'number', + int16: 'number', + int32: 'number', + int64: 'number', + int128: 'number', + int256: 'number', + bool: 'checkbox', + bytes: 'textarea', + bytes32: 'text', +}; diff --git a/packages/adapter-evm/src/mapping/field-generator.ts b/packages/adapter-evm/src/mapping/field-generator.ts new file mode 100644 index 00000000..b5d0be79 --- /dev/null +++ b/packages/adapter-evm/src/mapping/field-generator.ts @@ -0,0 +1,75 @@ +import { startCase } from 'lodash'; + +import type { + FieldType, + FieldValidation, + FieldValue, + FormFieldType, + FunctionParameter, +} from '@openzeppelin/transaction-form-types'; + +import { isValidEvmAddress } from '../utils'; + +import { mapEvmParamTypeToFieldType } from './type-mapper'; + +/** + * Get a default value for a field type + */ +function getDefaultValueForType(fieldType: T): FieldValue { + switch (fieldType) { + case 'checkbox': + return false as FieldValue; + case 'number': + case 'amount': + return 0 as FieldValue; + case 'blockchain-address': + return '' as FieldValue; + default: + return '' as FieldValue; + } +} + +/** + * Get default validation rules for a parameter type + */ +function getDefaultValidationForType(parameterType: string): FieldValidation { + const validation: FieldValidation = { required: true }; + + // Add specific validation rules based on the parameter type + if (parameterType === 'blockchain-address') { + return { + ...validation, + // Use the imported isValidEvmAddress method for direct validation + // NOTE: FieldValidation type doesn't officially support `custom`. This relies + // on React Hook Form's `validate` prop potentially picking this up downstream. + // Consider alternative validation approaches if this proves problematic. + custom: (value: unknown): boolean | string => { + if (value === '') return true; // Empty values handled by required + if (typeof value !== 'string') return 'Address must be a string'; + return isValidEvmAddress(value) ? true : 'Invalid address format'; + }, + } as FieldValidation & { custom?: (value: unknown) => boolean | string }; // Cast to include custom + } + + return validation; +} + +/** + * Generate default field configuration for an EVM function parameter. + */ +export function generateEvmDefaultField( + parameter: FunctionParameter +): FormFieldType { + const fieldType = mapEvmParamTypeToFieldType(parameter.type) as T; + return { + id: `field-${Math.random().toString(36).substring(2, 9)}`, + name: parameter.name || parameter.type, // Use type if name missing + label: startCase(parameter.displayName || parameter.name || parameter.type), + type: fieldType, + placeholder: `Enter ${parameter.displayName || parameter.name || parameter.type}`, + helperText: parameter.description || '', + defaultValue: getDefaultValueForType(fieldType) as FieldValue, + validation: getDefaultValidationForType(parameter.type), + width: 'full', + }; +} diff --git a/packages/adapter-evm/src/mapping/index.ts b/packages/adapter-evm/src/mapping/index.ts new file mode 100644 index 00000000..c667bb63 --- /dev/null +++ b/packages/adapter-evm/src/mapping/index.ts @@ -0,0 +1,4 @@ +// Barrel file for mapping module +export * from './constants'; +export * from './type-mapper'; +export * from './field-generator'; diff --git a/packages/adapter-evm/src/mapping/type-mapper.ts b/packages/adapter-evm/src/mapping/type-mapper.ts new file mode 100644 index 00000000..27c2a7cf --- /dev/null +++ b/packages/adapter-evm/src/mapping/type-mapper.ts @@ -0,0 +1,68 @@ +import type { FieldType } from '@openzeppelin/transaction-form-types'; + +import { EVM_TYPE_TO_FIELD_TYPE } from './constants'; + +/** + * Map a blockchain-specific parameter type to a default form field type. + * @param parameterType The blockchain parameter type (e.g., 'uint256', 'address', 'tuple') + * @returns The appropriate default form field type (e.g., 'number', 'blockchain-address', 'textarea') + */ +export function mapEvmParamTypeToFieldType(parameterType: string): FieldType { + // Check if this is an array type (ends with [] or [number]) + if (parameterType.match(/\[\d*\]$/)) { + // All array types should use textarea for JSON input + return 'textarea'; + } + + // Extract the base type from array types (e.g., uint256[] -> uint256) + const baseType = parameterType.replace(/\[\d*\]/g, ''); + + // Handle tuples (structs) - use textarea for JSON input + if (baseType.startsWith('tuple')) { + return 'textarea'; + } + + // Map common EVM types to appropriate field types + return EVM_TYPE_TO_FIELD_TYPE[baseType] || 'text'; // Default to 'text' +} + +/** + * Get field types compatible with a specific parameter type. + * @param parameterType The blockchain parameter type. + * @returns Array of compatible form field types. + */ +export function getEvmCompatibleFieldTypes(parameterType: string): FieldType[] { + // Handle array and tuple types - allow JSON input via textarea or basic text + if (parameterType.match(/\[\d*\]$/)) { + return ['textarea', 'text']; + } + const baseType = parameterType.replace(/\[\d*\]/g, ''); + if (baseType.startsWith('tuple')) { + return ['textarea', 'text']; + } + + // Define compatibility map for base types + const compatibilityMap: Record = { + address: ['blockchain-address', 'text'], + uint: ['number', 'amount', 'text'], + uint8: ['number', 'amount', 'text'], + uint16: ['number', 'amount', 'text'], + uint32: ['number', 'amount', 'text'], + uint64: ['number', 'amount', 'text'], + uint128: ['number', 'amount', 'text'], + uint256: ['number', 'amount', 'text'], + int: ['number', 'text'], + int8: ['number', 'text'], + int16: ['number', 'text'], + int32: ['number', 'text'], + int64: ['number', 'text'], + int128: ['number', 'text'], + int256: ['number', 'text'], + bool: ['checkbox', 'select', 'radio', 'text'], + string: ['text', 'textarea', 'email', 'password'], + bytes: ['textarea', 'text'], + bytes32: ['text', 'textarea'], + }; + + return compatibilityMap[baseType] || ['text']; // Default to 'text' +} diff --git a/packages/adapter-evm/src/networks/index.ts b/packages/adapter-evm/src/networks/index.ts new file mode 100644 index 00000000..720c5e8a --- /dev/null +++ b/packages/adapter-evm/src/networks/index.ts @@ -0,0 +1,30 @@ +import { EvmNetworkConfig } from '@openzeppelin/transaction-form-types'; + +import { ethereumMainnet, polygonMainnet /*, other mainnets */ } from './mainnet'; +import { ethereumSepolia, polygonAmoy /*, other testnets */ } from './testnet'; + +// All mainnet networks +export const evmMainnetNetworks: EvmNetworkConfig[] = [ + ethereumMainnet, + polygonMainnet, + // Other mainnet networks... +]; + +// All testnet networks +export const evmTestnetNetworks: EvmNetworkConfig[] = [ + ethereumSepolia, + polygonAmoy, + // Other testnet networks... +]; + +// All EVM networks +export const evmNetworks: EvmNetworkConfig[] = [...evmMainnetNetworks, ...evmTestnetNetworks]; + +// Export individual networks as well for direct import if needed +export { + ethereumMainnet, + polygonMainnet, + ethereumSepolia, + polygonAmoy, + // Other networks... +}; diff --git a/packages/adapter-evm/src/networks/mainnet.ts b/packages/adapter-evm/src/networks/mainnet.ts new file mode 100644 index 00000000..89d9edbe --- /dev/null +++ b/packages/adapter-evm/src/networks/mainnet.ts @@ -0,0 +1,47 @@ +import { mainnet as viemMainnet, polygon as viemPolygon } from 'viem/chains'; + +import { EvmNetworkConfig } from '@openzeppelin/transaction-form-types'; + +export const ethereumMainnet: EvmNetworkConfig = { + id: 'ethereum-mainnet', + exportConstName: 'ethereumMainnet', + name: 'Ethereum', + ecosystem: 'evm', + network: 'ethereum', + type: 'mainnet', + isTestnet: false, + chainId: 1, + rpcUrl: 'https://eth.llamarpc.com', // Public RPC for Ethereum Mainnet + explorerUrl: 'https://etherscan.io', + apiUrl: 'https://api.etherscan.io/api', + icon: 'ethereum', + nativeCurrency: { + name: 'Ether', + symbol: 'ETH', + decimals: 18, + }, + viemChain: viemMainnet, +}; + +export const polygonMainnet: EvmNetworkConfig = { + id: 'polygon-mainnet', + exportConstName: 'polygonMainnet', + name: 'Polygon', + ecosystem: 'evm', + network: 'polygon', + type: 'mainnet', + isTestnet: false, + chainId: 137, + rpcUrl: 'https://polygon-rpc.com', // Public RPC for Polygon Mainnet + explorerUrl: 'https://polygonscan.com', + apiUrl: 'https://api.polygonscan.com/api', + icon: 'polygon', + nativeCurrency: { + name: 'Matic', + symbol: 'MATIC', + decimals: 18, + }, + viemChain: viemPolygon, +}; + +// TODO: Add other EVM mainnet networks with their public RPCs and viemChain objects diff --git a/packages/adapter-evm/src/networks/testnet.ts b/packages/adapter-evm/src/networks/testnet.ts new file mode 100644 index 00000000..018c60c8 --- /dev/null +++ b/packages/adapter-evm/src/networks/testnet.ts @@ -0,0 +1,47 @@ +import { polygonAmoy as viemPolygonAmoy, sepolia as viemSepolia } from 'viem/chains'; + +import { EvmNetworkConfig } from '@openzeppelin/transaction-form-types'; + +export const ethereumSepolia: EvmNetworkConfig = { + id: 'ethereum-sepolia', + exportConstName: 'ethereumSepolia', + name: 'Ethereum Sepolia', + ecosystem: 'evm', + network: 'ethereum', + type: 'testnet', + isTestnet: true, + chainId: 11155111, + rpcUrl: 'https://ethereum-sepolia.rpc.subquery.network/public', // Public RPC for Sepolia + explorerUrl: 'https://sepolia.etherscan.io', + apiUrl: 'https://api-sepolia.etherscan.io/api', + icon: 'ethereum', + nativeCurrency: { + name: 'Sepolia Ether', + symbol: 'ETH', + decimals: 18, + }, + viemChain: viemSepolia, +}; + +export const polygonAmoy: EvmNetworkConfig = { + id: 'polygon-amoy', + exportConstName: 'polygonAmoy', + name: 'Polygon Amoy', + ecosystem: 'evm', + network: 'polygon', + type: 'testnet', + isTestnet: true, + chainId: 80002, + rpcUrl: 'https://rpc-amoy.polygon.technology', // Public RPC for Polygon Amoy + explorerUrl: 'https://www.oklink.com/amoy', // Amoy explorer + apiUrl: 'https://api-amoy.polygonscan.com/api', + icon: 'polygon', + nativeCurrency: { + name: 'Matic', + symbol: 'MATIC', + decimals: 18, + }, + viemChain: viemPolygonAmoy, +}; + +// TODO: Add other EVM testnet networks as needed (e.g., Arbitrum Sepolia) diff --git a/packages/adapter-evm/src/query/handler.ts b/packages/adapter-evm/src/query/handler.ts new file mode 100644 index 00000000..6da60b37 --- /dev/null +++ b/packages/adapter-evm/src/query/handler.ts @@ -0,0 +1,196 @@ +import { type Chain, type PublicClient, createPublicClient, http, isAddress } from 'viem'; + +import type { + ContractSchema, + EvmNetworkConfig, + FunctionParameter, +} from '@openzeppelin/transaction-form-types'; + +import { createAbiFunctionItem } from '../abi'; +import { resolveRpcUrl } from '../configuration'; +import { parseEvmInput } from '../transform'; +import type { WagmiWalletImplementation } from '../wallet/wagmi-implementation'; + +import { isEvmViewFunction } from './view-checker'; + +/** + * Private helper to get a PublicClient instance for view queries. + * Prioritizes connected wallet client if on the correct chain. + * Otherwise, creates a dedicated client using the resolved RPC URL for the target network. + */ +function getPublicClientForQuery( + walletImplementation: WagmiWalletImplementation, + networkConfig: EvmNetworkConfig +): PublicClient { + const accountStatus = walletImplementation.getWalletConnectionStatus(); + const walletChainId = accountStatus.chainId ? Number(accountStatus.chainId) : undefined; + const isConnectedToCorrectChain = + accountStatus.isConnected && walletChainId === networkConfig.chainId; + + if (isConnectedToCorrectChain) { + // Use wallet's client only if connected to the *correct* chain + const clientFromWallet = walletImplementation.getPublicClient(); + if (clientFromWallet) { + // Check if client was successfully obtained + console.log( + `Using connected wallet's public client (Chain ID: ${walletChainId}) for query on ${networkConfig.name}.` + ); + return clientFromWallet; + } else { + console.warn( + `Could not get public client from connected wallet for chain ${walletChainId}. Falling back.` + ); + } + } + + // Fallback: Create a dedicated client using the resolved RPC URL + const resolvedRpc = resolveRpcUrl(networkConfig); + console.log( + `Wallet not connected/on wrong chain OR failed to get wallet client. Creating dedicated public client for query on ${networkConfig.name} using RPC: ${resolvedRpc}` + ); + + let chainForViem: Chain; + if (networkConfig.viemChain) { + chainForViem = networkConfig.viemChain; + } else { + console.warn( + `Viem chain object (viemChain) not provided in EvmNetworkConfig for ${networkConfig.name} (query). Creating a minimal one.` + ); + if (!networkConfig.rpcUrl) { + // Used for minimal object + throw new Error( + `RPC URL is missing in networkConfig for ${networkConfig.name} and viemChain is not set for query client.` + ); + } + chainForViem = { + id: networkConfig.chainId, + name: networkConfig.name, + nativeCurrency: networkConfig.nativeCurrency, + rpcUrls: { + default: { http: [networkConfig.rpcUrl] }, + public: { http: [networkConfig.rpcUrl] }, + }, + blockExplorers: networkConfig.explorerUrl + ? { default: { name: `${networkConfig.name} Explorer`, url: networkConfig.explorerUrl } } + : undefined, + }; + } + + try { + const publicClient = createPublicClient({ + chain: chainForViem, + transport: http(resolvedRpc), + }); + return publicClient; + } catch (error) { + console.error('Failed to create network-specific public client for query:', error); + throw new Error( + `Failed to create network-specific public client for query: ${(error as Error).message}` + ); + } +} + +/** + * Core logic for querying an EVM view function. + * + * @param contractAddress Address of the contract. + * @param functionId ID of the function to query. + * @param networkConfig The specific network configuration. + * @param params Raw parameters for the function call. + * @param contractSchema Optional pre-loaded contract schema. + * @param walletImplementation Wallet implementation instance. + * @param loadContractFn Function reference to load contract schema if not provided. + * @returns The decoded result of the view function call. + */ +export async function queryEvmViewFunction( + contractAddress: string, + functionId: string, + networkConfig: EvmNetworkConfig, + params: unknown[], + contractSchema: ContractSchema | undefined, + walletImplementation: WagmiWalletImplementation, + loadContractFn: (source: string) => Promise +): Promise { + console.log( + `Querying view function: ${functionId} on ${contractAddress} (${networkConfig.name})`, + { + params, + } + ); + try { + // --- Validate Address --- // + if (!contractAddress || !isAddress(contractAddress)) { + throw new Error(`Invalid contract address provided: ${contractAddress}`); + } + + // --- Get Public Client --- // + const publicClient = getPublicClientForQuery(walletImplementation, networkConfig); + + // --- Get Schema & Function Details --- // + // loadContractFn (bound to adapter instance) uses internal networkConfig + const schema = contractSchema || (await loadContractFn(contractAddress)); + const functionDetails = schema.functions.find((fn) => fn.id === functionId); + if (!functionDetails) { + throw new Error(`Function with ID ${functionId} not found in contract schema.`); + } + if (!isEvmViewFunction(functionDetails)) { + throw new Error(`Function ${functionDetails.name} is not a view function.`); + } + + // --- Parse Input Parameters --- // + const expectedInputs: readonly FunctionParameter[] = functionDetails.inputs; + if (params.length !== expectedInputs.length) { + throw new Error( + `Incorrect number of parameters provided for ${functionDetails.name}. Expected ${expectedInputs.length}, got ${params.length}.` + ); + } + const args = expectedInputs.map((inputParam: FunctionParameter, index: number) => { + const rawValue = params[index]; + return parseEvmInput(inputParam, rawValue, false); + }); + console.log('Parsed Args for readContract:', args); + + // --- Construct ABI Item --- // + const functionAbiItem = createAbiFunctionItem(functionDetails); + + console.log( + `[Query ${functionDetails.name}] Calling readContract with ABI:`, + functionAbiItem, + 'Args:', + args + ); + + // --- Call readContract --- // + let decodedResult: unknown; + try { + decodedResult = await publicClient.readContract({ + address: contractAddress as `0x${string}`, + abi: [functionAbiItem], + functionName: functionDetails.name, + args: args, + }); + } catch (readError) { + console.error( + `[Query ${functionDetails.name}] publicClient.readContract specific error:`, + readError + ); + throw new Error( + `Viem readContract failed for ${functionDetails.name}: ${(readError as Error).message}` + ); + } + + console.log(`[Query ${functionDetails.name}] Raw decoded result:`, decodedResult); + + return decodedResult; + } catch (error) { + const errorMessage = `Failed to query view function ${functionId} on network ${networkConfig.name}: ${(error as Error).message}`; + console.error(`queryEvmViewFunction Error: ${errorMessage}`, { + contractAddress, + functionId, + params, + networkConfig, + error, + }); + throw new Error(errorMessage); + } +} diff --git a/packages/adapter-evm/src/query/index.ts b/packages/adapter-evm/src/query/index.ts new file mode 100644 index 00000000..6dcdb04a --- /dev/null +++ b/packages/adapter-evm/src/query/index.ts @@ -0,0 +1,3 @@ +// Barrel file for query module +export * from './view-checker'; +export * from './handler'; diff --git a/packages/adapter-evm/src/query/view-checker.ts b/packages/adapter-evm/src/query/view-checker.ts new file mode 100644 index 00000000..e9f9403a --- /dev/null +++ b/packages/adapter-evm/src/query/view-checker.ts @@ -0,0 +1,10 @@ +import type { ContractFunction } from '@openzeppelin/transaction-form-types'; + +/** + * Determines if a function is a view/pure function (read-only). + * @param functionDetails The function details from the contract schema. + * @returns True if the function is read-only, false otherwise. + */ +export function isEvmViewFunction(functionDetails: ContractFunction): boolean { + return functionDetails.stateMutability === 'view' || functionDetails.stateMutability === 'pure'; +} diff --git a/packages/adapter-evm/src/transaction/formatter.ts b/packages/adapter-evm/src/transaction/formatter.ts new file mode 100644 index 00000000..caf98a3f --- /dev/null +++ b/packages/adapter-evm/src/transaction/formatter.ts @@ -0,0 +1,91 @@ +import { isAddress } from 'viem'; +import type { Abi } from 'viem'; + +import type { ContractSchema, FormFieldType } from '@openzeppelin/transaction-form-types'; + +import { createAbiFunctionItem } from '../abi'; +import { parseEvmInput } from '../transform'; + +// Define structure locally or import from shared types +interface WriteContractParameters { + address: `0x${string}`; + abi: Abi; + functionName: string; + args: unknown[]; + value?: bigint; +} + +/** + * Formats transaction data for EVM chains based on parsed inputs. + * + * @param contractSchema The contract schema. + * @param functionId The ID of the function being called. + * @param submittedInputs The raw data submitted from the form. + * @param allFieldsConfig The configuration for all fields. + * @returns The formatted data payload suitable for signAndBroadcast. + */ +export function formatEvmTransactionData( + contractSchema: ContractSchema, + functionId: string, + submittedInputs: Record, + allFieldsConfig: FormFieldType[] +): WriteContractParameters { + console.log(`Formatting EVM transaction data for function: ${functionId}`); + + // --- Step 1: Determine Argument Order --- // + const functionDetails = contractSchema.functions.find((fn) => fn.id === functionId); + if (!functionDetails) { + throw new Error(`Function definition for ${functionId} not found in provided contract schema.`); + } + const expectedArgs = functionDetails.inputs; + + // --- Step 2: Iterate and Select Values --- // + const orderedRawValues: unknown[] = []; + for (const expectedArg of expectedArgs) { + const fieldConfig = allFieldsConfig.find((field) => field.name === expectedArg.name); + if (!fieldConfig) { + throw new Error(`Configuration missing for argument: ${expectedArg.name}`); + } + let value: unknown; + if (fieldConfig.isHardcoded) { + value = fieldConfig.hardcodedValue; + } else if (fieldConfig.isHidden) { + throw new Error(`Field '${fieldConfig.name}' cannot be hidden without being hardcoded.`); + } else { + if (!(fieldConfig.name in submittedInputs)) { + throw new Error(`Missing submitted input for required field: ${fieldConfig.name}`); + } + value = submittedInputs[fieldConfig.name]; + } + orderedRawValues.push(value); + } + + // --- Step 3: Parse/Transform Values using the imported parser --- // + const transformedArgs = expectedArgs.map((param, index) => { + const rawValue = orderedRawValues[index]; + return parseEvmInput(param, rawValue, false); + }); + + // --- Step 4 & 5: Prepare Return Object --- // + const isPayable = functionDetails.stateMutability === 'payable'; + let transactionValue = 0n; // Use BigInt zero + if (isPayable) { + console.warn('Payable function detected, but sending 0 ETH. Implement value input.'); + // TODO: Read value from submittedInputs or config when payable input is implemented + } + + const functionAbiItem = createAbiFunctionItem(functionDetails); + + if (!contractSchema.address || !isAddress(contractSchema.address)) { + throw new Error('Contract address is missing or invalid in the provided schema.'); + } + + const paramsForSignAndBroadcast: WriteContractParameters = { + address: contractSchema.address, + abi: [functionAbiItem], + functionName: functionDetails.name, + args: transformedArgs, + value: transactionValue, // Pass BigInt value + }; + return paramsForSignAndBroadcast; +} diff --git a/packages/adapter-evm/src/transaction/index.ts b/packages/adapter-evm/src/transaction/index.ts new file mode 100644 index 00000000..a3bc4e38 --- /dev/null +++ b/packages/adapter-evm/src/transaction/index.ts @@ -0,0 +1,3 @@ +// Barrel file for transaction module +export * from './formatter'; +export * from './sender'; diff --git a/packages/adapter-evm/src/transaction/sender.ts b/packages/adapter-evm/src/transaction/sender.ts new file mode 100644 index 00000000..55d7ae11 --- /dev/null +++ b/packages/adapter-evm/src/transaction/sender.ts @@ -0,0 +1,140 @@ +import type { TransactionReceipt } from 'viem'; + +import type { WriteContractParameters } from '../types'; +import type { WagmiWalletImplementation } from '../wallet/wagmi-implementation'; + +/** + * Signs and broadcasts a transaction using the connected wallet. + */ +export async function signAndBroadcastEvmTransaction( + transactionData: WriteContractParameters, + walletImplementation: WagmiWalletImplementation, + targetChainId: number +): Promise<{ txHash: string }> { + console.log('Attempting to sign and broadcast EVM transaction:', transactionData); + console.log('Target chain ID for transaction:', targetChainId); + + // 0. Check and switch network if necessary + const initialAccountStatus = walletImplementation.getWalletConnectionStatus(); + if (!initialAccountStatus.isConnected || !initialAccountStatus.chainId) { + console.error( + 'signAndBroadcast: Wallet not connected or chainId unavailable before network check.' + ); + throw new Error('Wallet not connected or chain ID is unavailable.'); + } + + if (initialAccountStatus.chainId !== targetChainId) { + console.info( + `Wallet is on chain ${initialAccountStatus.chainId}, but transaction targets chain ${targetChainId}. Attempting to switch.` + ); + try { + await walletImplementation.switchNetwork(targetChainId); + // After attempting switch, re-check the status + const postSwitchAccountStatus = walletImplementation.getWalletConnectionStatus(); + if (postSwitchAccountStatus.chainId !== targetChainId) { + // This case should ideally be caught by switchNetwork throwing an error, but double check. + console.error( + `Failed to switch to target chain ${targetChainId}. Current chain: ${postSwitchAccountStatus.chainId}` + ); + throw new Error( + `Failed to switch to the required network (target: ${targetChainId}). Please switch manually.` + ); + } + console.info(`Successfully switched to target chain ${targetChainId}.`); + } catch (error) { + console.error('Network switch failed:', error); + // Re-throw the error from switchNetwork which might include "User rejected" + throw error; + } + } + + // 1. Get the Wallet Client + const walletClient = await walletImplementation.getWalletClient(); + if (!walletClient) { + console.error('signAndBroadcast: Wallet client not available. Is wallet connected?'); + throw new Error('Wallet is not connected or client is unavailable.'); + } + + // 2. Get the connected account + const accountStatus = walletImplementation.getWalletConnectionStatus(); + if (!accountStatus.isConnected || !accountStatus.address) { + console.error('signAndBroadcast: Account not available. Is wallet connected?'); + throw new Error('Wallet is not connected or account address is unavailable.'); + } + + try { + // 3. Call viem's writeContract + console.log('Calling walletClient.writeContract with:', { + account: accountStatus.address, + address: transactionData.address, + abi: transactionData.abi, + functionName: transactionData.functionName, + args: transactionData.args, + value: transactionData.value, + chain: walletClient.chain, + }); + + const hash = await walletClient.writeContract({ + account: accountStatus.address, + address: transactionData.address, + abi: transactionData.abi, + functionName: transactionData.functionName, + args: transactionData.args, + value: transactionData.value, + chain: walletClient.chain, + }); + + console.log('Transaction initiated successfully. Hash:', hash); + return { txHash: hash }; + } catch (error: unknown) { + console.error('Error during writeContract call:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown transaction error'; + throw new Error(`Transaction failed: ${errorMessage}`); + } +} + +/** + * Waits for a transaction to be confirmed on the blockchain. + */ +export async function waitForEvmTransactionConfirmation( + txHash: string, + walletImplementation: WagmiWalletImplementation +): Promise<{ + status: 'success' | 'error'; + receipt?: TransactionReceipt; + error?: Error; +}> { + console.info('waitForEvmTransactionConfirmation', `Waiting for tx: ${txHash}`); + try { + // Get the public client + const publicClient = walletImplementation.getPublicClient(); + if (!publicClient) { + throw new Error('Public client not available to wait for transaction.'); + } + + // Wait for the transaction receipt + const receipt = await publicClient.waitForTransactionReceipt({ + hash: txHash as `0x${string}`, + }); + + console.info('waitForEvmTransactionConfirmation', 'Received receipt:', receipt); + + // Check the status field in the receipt + if (receipt.status === 'success') { + return { status: 'success', receipt }; + } else { + console.error('waitForEvmTransactionConfirmation', 'Transaction reverted:', receipt); + return { status: 'error', receipt, error: new Error('Transaction reverted.') }; + } + } catch (error) { + console.error( + 'waitForEvmTransactionConfirmation', + 'Error waiting for transaction confirmation:', + error + ); + return { + status: 'error', + error: error instanceof Error ? error : new Error(String(error)), + }; + } +} diff --git a/packages/adapter-evm/src/transform/index.ts b/packages/adapter-evm/src/transform/index.ts new file mode 100644 index 00000000..afa37e21 --- /dev/null +++ b/packages/adapter-evm/src/transform/index.ts @@ -0,0 +1,3 @@ +// Barrel file for transform module +export * from './input-parser'; +export * from './output-formatter'; diff --git a/packages/adapter-evm/src/transform/input-parser.ts b/packages/adapter-evm/src/transform/input-parser.ts new file mode 100644 index 00000000..80a722d6 --- /dev/null +++ b/packages/adapter-evm/src/transform/input-parser.ts @@ -0,0 +1,173 @@ +import { getAddress, isAddress } from 'viem'; + +import type { FunctionParameter } from '@openzeppelin/transaction-form-types'; + +/** + * Recursively parses a raw input value based on its expected ABI type definition. + * + * @param param The ABI parameter definition ({ name, type, components?, ... }) + * @param rawValue The raw value obtained from the form input or hardcoded config. + * @param isRecursive Internal flag to indicate if the call is nested. + * @returns The parsed and typed value suitable for ABI encoding. + * @throws {Error} If parsing or type validation fails. + */ +export function parseEvmInput( + param: FunctionParameter, + rawValue: unknown, + isRecursive = false +): unknown { + const { type, name } = param; + const baseType = type.replace(/\[\d*\]$/, ''); // Remove array indicators like `[]` or `[2]` + const isArray = type.endsWith(']'); + + try { + // --- Handle Arrays --- // + if (isArray) { + // Only expect string at the top level, recursive calls get arrays directly + let parsedArray: unknown[]; + if (!isRecursive) { + if (typeof rawValue !== 'string') { + throw new Error('Array input must be a JSON string representation.'); + } + try { + parsedArray = JSON.parse(rawValue); + } catch (e) { + throw new Error(`Invalid JSON for array: ${(e as Error).message}`); + } + } else { + // If recursive, rawValue should already be an array + if (!Array.isArray(rawValue)) { + throw new Error('Internal error: Expected array in recursive call.'); + } + parsedArray = rawValue; + } + + if (!Array.isArray(parsedArray)) { + // Double check after parsing/assignment + throw new Error('Parsed JSON is not an array.'); + } + + // Recursively parse each element + const itemAbiParam = { ...param, type: baseType }; // Create a dummy param for the base type + return parsedArray.map((item) => parseEvmInput(itemAbiParam, item, true)); // Pass isRecursive=true + } + + // --- Handle Tuples --- // + if (baseType === 'tuple') { + if (!param.components) { + throw new Error(`ABI definition missing 'components' for tuple parameter '${name}'.`); + } + // Only expect string at the top level, recursive calls get objects directly + let parsedObject: Record; + if (!isRecursive) { + if (typeof rawValue !== 'string') { + throw new Error('Tuple input must be a JSON string representation of an object.'); + } + try { + parsedObject = JSON.parse(rawValue); + } catch (e) { + throw new Error(`Invalid JSON for tuple: ${(e as Error).message}`); + } + } else { + // If recursive, rawValue should already be an object + if (typeof rawValue !== 'object' || rawValue === null || Array.isArray(rawValue)) { + throw new Error('Internal error: Expected object in recursive tuple call.'); + } + parsedObject = rawValue as Record; // Cast needed + } + + if ( + typeof parsedObject !== 'object' || + parsedObject === null || + Array.isArray(parsedObject) + ) { + // Double check + throw new Error('Parsed JSON is not an object for tuple.'); + } + + // Recursively parse each component + const resultObject: Record = {}; + for (const component of param.components) { + if (!(component.name in parsedObject)) { + throw new Error(`Missing component '${component.name}' in tuple JSON.`); + } + resultObject[component.name] = parseEvmInput( + component, + parsedObject[component.name], + true // Pass isRecursive=true + ); + } + // Check for extra, unexpected keys in the provided JSON object + if (Object.keys(parsedObject).length !== param.components.length) { + const expectedKeys = param.components.map((c) => c.name).join(', '); + const actualKeys = Object.keys(parsedObject).join(', '); + throw new Error( + `Tuple object has incorrect number of keys. Expected ${param.components.length} (${expectedKeys}), but got ${Object.keys(parsedObject).length} (${actualKeys}).` + ); + } + return resultObject; + } + + // --- Handle Bytes --- // + if (baseType.startsWith('bytes')) { + if (typeof rawValue !== 'string') { + throw new Error('Bytes input must be a string.'); + } + if (!/^0x([0-9a-fA-F]{2})*$/.test(rawValue)) { + throw new Error( + `Invalid hex string format for ${type}: must start with 0x and contain only hex characters.` + ); + } + // Check byte length for fixed-size bytes? (e.g., bytes32) + const fixedSizeMatch = baseType.match(/^bytes(\d+)$/); + if (fixedSizeMatch) { + const expectedBytes = parseInt(fixedSizeMatch[1], 10); + const actualBytes = (rawValue.length - 2) / 2; + if (actualBytes !== expectedBytes) { + throw new Error( + `Invalid length for ${type}: expected ${expectedBytes} bytes (${expectedBytes * 2} hex chars), got ${actualBytes} bytes.` + ); + } + } + return rawValue as `0x${string}`; // Already validated, cast to viem type + } + + // --- Handle Simple Types --- // + if (baseType.startsWith('uint') || baseType.startsWith('int')) { + if (rawValue === '' || rawValue === null || rawValue === undefined) + throw new Error('Numeric value cannot be empty.'); + try { + // Use BigInt for all integer types + return BigInt(rawValue as string | number | bigint); + } catch { + throw new Error(`Invalid numeric value: '${rawValue}'.`); + } + } else if (baseType === 'address') { + if (typeof rawValue !== 'string' || !rawValue) + throw new Error('Address value must be a non-empty string.'); + if (!isAddress(rawValue)) throw new Error(`Invalid address format: '${rawValue}'.`); + return getAddress(rawValue); // Return checksummed address + } else if (baseType === 'bool') { + if (typeof rawValue === 'boolean') return rawValue; + if (typeof rawValue === 'string') { + const lowerVal = rawValue.toLowerCase().trim(); + if (lowerVal === 'true') return true; + if (lowerVal === 'false') return false; + } + // Try simple truthy/falsy conversion as fallback + return Boolean(rawValue); + } else if (baseType === 'string') { + // Ensure it's treated as a string + return String(rawValue); + } + + // --- Fallback for unknown types --- // + console.warn(`Unknown EVM parameter type encountered: '${type}'. Using raw value.`); + return rawValue; + } catch (error) { + // Add parameter context to the error message + throw new Error( + `Failed to parse value for parameter '${name || '(unnamed)'}' (type '${type}'): ${(error as Error).message}` + ); + } +} diff --git a/packages/adapter-evm/src/transform/output-formatter.ts b/packages/adapter-evm/src/transform/output-formatter.ts new file mode 100644 index 00000000..9de8eeec --- /dev/null +++ b/packages/adapter-evm/src/transform/output-formatter.ts @@ -0,0 +1,62 @@ +import type { ContractFunction } from '@openzeppelin/transaction-form-types'; + +import { stringifyWithBigInt } from '../utils'; + +/** + * Formats the decoded result of an EVM view function call into a user-friendly string. + * + * @param decodedValue The decoded value (can be primitive, array, object, BigInt). + * @param functionDetails The ABI details of the function called. + * @returns A string representation suitable for display. + */ +export function formatEvmFunctionResult( + decodedValue: unknown, + functionDetails: ContractFunction +): string { + if (!functionDetails.outputs || !Array.isArray(functionDetails.outputs)) { + console.warn( + `formatEvmFunctionResult: Output ABI definition missing or invalid for function ${functionDetails.name}.` + ); + return '[Error: Output ABI definition missing]'; + } + + try { + let valueToFormat: unknown; + // Handle potential array wrapping for single returns from viem + if (Array.isArray(decodedValue)) { + if (decodedValue.length === 1) { + valueToFormat = decodedValue[0]; // Single output, format the inner value + } else { + // Multiple outputs, format the whole array as JSON + valueToFormat = decodedValue; + } + } else { + // Not an array, could be a single value (like from a struct return) or undefined + valueToFormat = decodedValue; + } + + // Format based on type + if (typeof valueToFormat === 'bigint') { + return valueToFormat.toString(); + } else if ( + typeof valueToFormat === 'string' || + typeof valueToFormat === 'number' || + typeof valueToFormat === 'boolean' + ) { + return String(valueToFormat); + } else if (valueToFormat === null || valueToFormat === undefined) { + return '(null)'; // Represent null/undefined clearly + } else { + // Handles arrays with multiple elements or objects (structs) by stringifying + return stringifyWithBigInt(valueToFormat, 2); // Pretty print with 2 spaces + } + } catch (error) { + const errorMessage = `Error formatting result for ${functionDetails.name}: ${(error as Error).message}`; + console.error(`formatEvmFunctionResult Error: ${errorMessage}`, { + functionName: functionDetails.name, + decodedValue, + error, + }); + return `[${errorMessage}]`; + } +} diff --git a/packages/core/src/adapters/evm/types.ts b/packages/adapter-evm/src/types.ts similarity index 68% rename from packages/core/src/adapters/evm/types.ts rename to packages/adapter-evm/src/types.ts index ab78fe26..cbb5275f 100644 --- a/packages/core/src/adapters/evm/types.ts +++ b/packages/adapter-evm/src/types.ts @@ -1,3 +1,5 @@ +import type { Abi } from 'viem'; + /** * EVM-specific type definitions */ @@ -58,3 +60,18 @@ export enum EVMChainType { BSC = 'bsc', AVALANCHE = 'avalanche', } +// Import Viem's Abi type + +/** + * Defines the structure for parameters required to execute a contract write operation via viem. + */ +export interface WriteContractParameters { + address: `0x${string}`; // Ensure address is a valid hex string type + abi: Abi; + functionName: string; + args: unknown[]; + value?: bigint; + // Add other potential viem parameters if needed (e.g., gas) +} + +// Add other adapter-specific internal types here if necessary diff --git a/packages/adapter-evm/src/utils/formatting.ts b/packages/adapter-evm/src/utils/formatting.ts new file mode 100644 index 00000000..1af59754 --- /dev/null +++ b/packages/adapter-evm/src/utils/formatting.ts @@ -0,0 +1,25 @@ +/** + * Format a method name for display (e.g., from camelCase to Title Case). + */ +export function formatMethodName(name: string): string { + if (!name) return ''; + return name + .replace(/([A-Z])/g, ' $1') + .replace(/^./, (str) => str.toUpperCase()) + .trim(); +} + +/** + * Format an input name for display (e.g., from camelCase or snake_case to Title Case). + * Provides a default name based on type if the original name is empty. + */ +export function formatInputName(name: string, type: string): string { + if (!name || name === '') { + return `Parameter (${type})`; // Use type if name is missing + } + return name + .replace(/([A-Z])/g, ' $1') // Add space before capitals + .replace(/_/g, ' ') // Replace underscores with spaces + .replace(/^./, (str) => str.toUpperCase()) // Capitalize first letter + .trim(); +} diff --git a/packages/adapter-evm/src/utils/index.ts b/packages/adapter-evm/src/utils/index.ts new file mode 100644 index 00000000..d46b087c --- /dev/null +++ b/packages/adapter-evm/src/utils/index.ts @@ -0,0 +1,4 @@ +// Barrel file for utils module +export * from './json'; +export * from './formatting'; +export * from './validation'; diff --git a/packages/adapter-evm/src/utils/json.ts b/packages/adapter-evm/src/utils/json.ts new file mode 100644 index 00000000..a97f96f0 --- /dev/null +++ b/packages/adapter-evm/src/utils/json.ts @@ -0,0 +1,19 @@ +/** + * Custom JSON stringifier that handles BigInt values by converting them to strings. + * @param value The value to stringify. + * @param space Adds indentation, white space, and line break characters to the return-value JSON text for readability. + * @returns A JSON string representing the given value. + */ +export function stringifyWithBigInt(value: unknown, space?: number | string): string { + const replacer = (_key: string, val: unknown) => { + // Check if the value is a BigInt + if (typeof val === 'bigint') { + // Convert BigInt to string + return val.toString(); + } + // Return the value unchanged for other types + return val; + }; + + return JSON.stringify(value, replacer, space); +} diff --git a/packages/adapter-evm/src/utils/validation.ts b/packages/adapter-evm/src/utils/validation.ts new file mode 100644 index 00000000..767a01dd --- /dev/null +++ b/packages/adapter-evm/src/utils/validation.ts @@ -0,0 +1,10 @@ +import { isAddress } from 'viem'; + +/** + * Validates if a string is a valid EVM address. + * @param address The address string to validate. + * @returns True if the address is valid, false otherwise. + */ +export function isValidEvmAddress(address: string): boolean { + return isAddress(address); +} diff --git a/packages/adapter-evm/src/wallet/connection.ts b/packages/adapter-evm/src/wallet/connection.ts new file mode 100644 index 00000000..7f339236 --- /dev/null +++ b/packages/adapter-evm/src/wallet/connection.ts @@ -0,0 +1,144 @@ +import type { GetAccountReturnType } from '@wagmi/core'; + +import type { Connector } from '@openzeppelin/transaction-form-types'; + +import type { WagmiWalletImplementation } from './wagmi-implementation'; + +/** + * Indicates if this adapter implementation supports wallet connection. + */ +export function evmSupportsWalletConnection(): boolean { + // Currently hardcoded for Wagmi implementation + return true; +} + +/** + * Gets the list of available wallet connectors supported by this adapter's implementation. + */ +export async function getEvmAvailableConnectors( + walletImplementation: WagmiWalletImplementation +): Promise { + return walletImplementation.getAvailableConnectors(); +} + +/** + * Connects to a wallet using the specified connector and ensures the wallet + * is switched to the target network if necessary. + * + * @param connectorId The ID of the connector to use. + * @param walletImplementation The wallet interaction implementation (e.g., Wagmi). + * @param targetChainId The desired chain ID for the connection. + * @returns A promise with connection status, address, chainId, error, and network switch status. + */ +export async function connectAndEnsureCorrectNetwork( + connectorId: string, + walletImplementation: WagmiWalletImplementation, + targetChainId: number +): Promise<{ + connected: boolean; + address?: string; + chainId?: number; + error?: string; + switchedNetwork?: boolean; +}> { + const connectResult = await walletImplementation.connect(connectorId); + + if (connectResult.connected && connectResult.address && connectResult.chainId) { + // Wallet connected successfully, now check if it's on the correct network. + if (connectResult.chainId !== targetChainId) { + console.info( + `connectAndEnsureCorrectNetwork: Wallet connected to chain ${connectResult.chainId}, but target is ${targetChainId}. Attempting switch.` + ); + try { + await walletImplementation.switchNetwork(targetChainId); + // After successful switch, re-fetch connection status to confirm the new chainId. + const postSwitchStatus = walletImplementation.getWalletConnectionStatus(); + if (postSwitchStatus.chainId === targetChainId) { + console.info( + `connectAndEnsureCorrectNetwork: Successfully switched to target chain ${targetChainId}.` + ); + return { + connected: true, + address: connectResult.address, // Address remains the same from initial connect + chainId: postSwitchStatus.chainId, // Reflect the new chainId + switchedNetwork: true, + }; + } else { + console.warn( + `connectAndEnsureCorrectNetwork: Network switch appeared to succeed but wallet is still on chain ${postSwitchStatus.chainId}.` + ); + await walletImplementation.disconnect(); // Disconnect as state is inconsistent + return { + connected: false, + error: `Failed to confirm switch to the required network (target: ${targetChainId}). Connection aborted.`, + switchedNetwork: true, // Switch was attempted + }; + } + } catch (switchError) { + console.error('connectAndEnsureCorrectNetwork: Network switch failed:', switchError); + await walletImplementation.disconnect(); // Disconnect if switch fails + return { + connected: false, + error: `Wallet connected, but failed to switch to the required network (target: ${targetChainId}). Connection aborted. Reason: ${switchError instanceof Error ? switchError.message : 'Unknown error'}`, + switchedNetwork: false, // Switch attempted but failed + }; + } + } else { + // Already on the correct network + console.info( + `connectAndEnsureCorrectNetwork: Wallet connected and already on the target chain ${targetChainId}.` + ); + return { + connected: true, + address: connectResult.address, + chainId: connectResult.chainId, + switchedNetwork: false, + }; + } + } else if (connectResult.error) { + // Initial connection failed + return { connected: false, error: connectResult.error, switchedNetwork: false }; + } + + // Fallback for unexpected scenarios (e.g., connected but no address/chainId from initial connect) + return { + connected: false, + error: 'Wallet connection attempt resulted in an unexpected state.', + switchedNetwork: false, + }; +} + +/** + * Disconnects the currently connected wallet. + */ +export async function disconnectEvmWallet( + walletImplementation: WagmiWalletImplementation +): Promise<{ disconnected: boolean; error?: string }> { + return walletImplementation.disconnect(); +} + +/** + * Gets the current wallet connection status. + */ +export function getEvmWalletConnectionStatus(walletImplementation: WagmiWalletImplementation): { + isConnected: boolean; + address?: string; + chainId?: string; +} { + const status = walletImplementation.getWalletConnectionStatus(); + return { + isConnected: status.isConnected, + address: status.address, + chainId: status.chainId?.toString(), // Ensure string format for interface + }; +} + +/** + * Subscribes to wallet connection changes. + */ +export function onEvmWalletConnectionChange( + walletImplementation: WagmiWalletImplementation, + callback: (account: GetAccountReturnType, prevAccount: GetAccountReturnType) => void +): () => void { + return walletImplementation.onWalletConnectionChange(callback); +} diff --git a/packages/adapter-evm/src/wallet/index.ts b/packages/adapter-evm/src/wallet/index.ts new file mode 100644 index 00000000..46da1467 --- /dev/null +++ b/packages/adapter-evm/src/wallet/index.ts @@ -0,0 +1,3 @@ +// Barrel file for wallet module +export * from './connection'; +// Keep wagmi-implementation internal for now diff --git a/packages/adapter-evm/src/wallet/wagmi-implementation.ts b/packages/adapter-evm/src/wallet/wagmi-implementation.ts new file mode 100644 index 00000000..3b4c7960 --- /dev/null +++ b/packages/adapter-evm/src/wallet/wagmi-implementation.ts @@ -0,0 +1,252 @@ +/** + * Private Wagmi implementation for EVM wallet connection + * + * This file contains the internal implementation of Wagmi and Viem for wallet connection. + * It's encapsulated within the EVM adapter and not exposed to the rest of the application. + */ +import { injected, metaMask, safe, walletConnect } from '@wagmi/connectors'; +// Import functions from @wagmi/core +import { + type Config, + type GetAccountReturnType, + connect, + createConfig, + disconnect, + getAccount, + getPublicClient as getWagmiPublicClient, + getWalletClient as getWagmiWalletClient, + switchChain, + watchAccount, +} from '@wagmi/core'; +// Import types and http from viem +import { type PublicClient, type WalletClient, http } from 'viem'; +// Only import http directly if not re-exported +import { base, mainnet, optimism, sepolia } from 'viem/chains'; + +import { type Connector } from '@openzeppelin/transaction-form-types'; + +// TODO: Make chains configurable, potentially passed from adapter instantiation +const supportedChains = [mainnet, base, optimism, sepolia] as const; // Use 'as const' for stricter typing + +// Get WalletConnect Project ID from environment variables +const WALLETCONNECT_PROJECT_ID = import.meta.env?.VITE_WALLETCONNECT_PROJECT_ID; + +if (!WALLETCONNECT_PROJECT_ID) { + console.error( + 'WagmiWalletImplementation', + 'WalletConnect Project ID is not set. Please provide a valid ID via VITE_WALLETCONNECT_PROJECT_ID environment variable.' + ); +} + +/** + * Class responsible for encapsulating Wagmi core logic for wallet interactions. + * This class should not be used directly by UI components. The EvmAdapter + * exposes a standardized interface. + */ +export class WagmiWalletImplementation { + private config: Config; + private unsubscribe?: ReturnType; + + constructor() { + this.config = createConfig({ + chains: supportedChains, + connectors: [ + injected(), + walletConnect({ projectId: WALLETCONNECT_PROJECT_ID }), + metaMask(), // Recommended to include MetaMask explicitly + safe(), // For Safe{Wallet} users + ], + transports: supportedChains.reduce( + (acc, chain) => { + acc[chain.id] = http(); // Use http transport for all supported chains + return acc; + }, + {} as Record> // Type assertion for accumulator + ), + // TODO: Add storage option for persistence? e.g., createStorage({ storage: window.localStorage }) + }); + } + + /** + * Retrieves the current Wagmi configuration. + * Used by WalletConnectionProvider to initialize WagmiProvider. + */ + public getConfig(): Config { + return this.config; + } + + /** + * Gets the Viem Wallet Client instance for the currently connected account and chain. + * Returns null if not connected. + * + * @returns A promise resolving to the Viem WalletClient or null. + */ + public async getWalletClient(): Promise { + const accountStatus = this.getWalletConnectionStatus(); + if (!accountStatus.isConnected || !accountStatus.chainId || !accountStatus.address) { + return null; + } + return getWagmiWalletClient(this.config, { + chainId: accountStatus.chainId, + account: accountStatus.address, + }); + } + + /** + * Gets the Viem Public Client instance for the currently connected chain. + * + * @returns A promise resolving to the Viem PublicClient or null. + */ + public getPublicClient(): PublicClient | null { + const accountStatus = this.getWalletConnectionStatus(); + if (!accountStatus.chainId) { + return null; + } + // Note: getPublicClient is synchronous in wagmi v2 + // Explicitly cast the return type. Addresses a TS error ("Two different types with this name exist...") + // that can occur in pnpm monorepos where TypeScript might resolve the PublicClient type + // from both the direct 'viem' import and the instance potentially used internally by '@wagmi/core'. + // This cast asserts their compatibility. + return getWagmiPublicClient(this.config, { + chainId: accountStatus.chainId, + }) as PublicClient; + } + + /** + * Gets the list of available wallet connectors configured in Wagmi. + * @returns A promise resolving to an array of available connectors. + */ + public async getAvailableConnectors(): Promise { + const connectors = this.config.connectors; + return connectors.map((conn) => ({ + id: conn.uid, + name: conn.name, + })); + } + + /** + * Initiates the connection process for a specific connector. + * @param connectorId The ID or name of the connector to use. + * @returns Connection result object including chainId if successful. + */ + public async connect( + connectorId: string + ): Promise<{ connected: boolean; address?: string; chainId?: number; error?: string }> { + try { + const connectors = this.config.connectors; + + let connector = connectors.find((c) => c.uid === connectorId); + + if (!connector) { + connector = connectors.find((c) => c.name.toLowerCase() === connectorId.toLowerCase()); + } + + if (!connector) { + const availableConnectorNames = connectors.map((c) => c.name).join(', '); + console.error( + 'WagmiWalletImplementation', + `Wallet connector "${connectorId}" not found. Available connectors: ${availableConnectorNames}` + ); + return { + connected: false, + error: `Wallet connector "${connectorId}" not found. Available connectors: ${availableConnectorNames}`, + }; + } + + const result = await connect(this.config, { connector }); + // result is of type ConnectReturnType, which includes accounts and chainId + return { connected: true, address: result.accounts[0], chainId: result.chainId }; + } catch (error: unknown) { + console.error('WagmiWalletImplementation', 'Wagmi connect error:', error); + return { + connected: false, + error: error instanceof Error ? error.message : 'Unknown connection error', + }; + } + } + + /** + * Disconnects the currently connected wallet. + * @returns Disconnection result object. + */ + public async disconnect(): Promise<{ disconnected: boolean; error?: string }> { + try { + await disconnect(this.config); + return { disconnected: true }; + } catch (error) { + console.error('WagmiWalletImplementation', 'Wagmi disconnect error:', error); + return { + disconnected: false, + error: error instanceof Error ? error.message : 'Unknown disconnection error', + }; + } + } + + /** + * Gets the current connection status and account details. + * @returns Account status object. + */ + public getWalletConnectionStatus(): GetAccountReturnType { + return getAccount(this.config); + } + + /** + * Subscribes to account connection changes. + * @param callback Function to call when connection status changes. + * @returns Cleanup function to unsubscribe. + */ + public onWalletConnectionChange( + callback: (account: GetAccountReturnType, prevAccount: GetAccountReturnType) => void + ): () => void { + this.unsubscribe?.(); + + this.unsubscribe = watchAccount(this.config, { + onChange: callback, + }); + + return this.unsubscribe; + } + + /** + * Cleans up the account watcher when the instance is no longer needed. + */ + public cleanup(): void { + this.unsubscribe?.(); + } + + /** + * Prompts the user to switch to the specified network (chain). + * @param chainId The target chain ID to switch to. + * @returns A promise that resolves if the switch is successful, or rejects with an error. + */ + public async switchNetwork(chainId: number): Promise { + try { + // First, check if we are already on the correct chain. + // The getAccount() method returns the current chainId if connected. + const currentAccount = this.getWalletConnectionStatus(); + if (currentAccount.isConnected && currentAccount.chainId === chainId) { + console.info('WagmiWalletImplementation', `Already on target chain ID: ${chainId}`); + return; // Already on the correct network + } + + console.info('WagmiWalletImplementation', `Attempting to switch to chain ID: ${chainId}`); + await switchChain(this.config, { chainId }); + console.info('WagmiWalletImplementation', `Successfully switched to chain ID: ${chainId}`); + } catch (error: unknown) { + console.error( + 'WagmiWalletImplementation', + `Error switching network to chain ID ${chainId}:`, + error + ); + // Wagmi's switchChain can throw specific error types, e.g., UserRejectedRequestError + // For simplicity, rethrow a generic error message. + // More specific error handling could be added here based on error.name or instanceof checks. + if (error instanceof Error && error.name === 'UserRejectedRequestError') { + throw new Error('Network switch rejected by user.'); + } + throw new Error( + `Failed to switch network: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + } +} diff --git a/packages/adapter-evm/tsconfig.json b/packages/adapter-evm/tsconfig.json new file mode 100644 index 00000000..c8042e6e --- /dev/null +++ b/packages/adapter-evm/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "tsBuildInfoFile": "./tsconfig.tsbuildinfo", + "types": ["vite/client", "node"], + "resolveJsonModule": true, + "esModuleInterop": true + // Add specific compiler options for EVM if needed + }, + "include": ["src/**/*", "src/**/*.json"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/packages/adapter-midnight/README.md b/packages/adapter-midnight/README.md new file mode 100644 index 00000000..6079cdc1 --- /dev/null +++ b/packages/adapter-midnight/README.md @@ -0,0 +1,50 @@ +# Midnight Adapter (`@openzeppelin/transaction-form-adapter-midnight`) + +This package provides the `ContractAdapter` implementation for the Midnight Network for the Transaction Form Builder. + +**Note:** While the basic structure is in place, including network configuration definitions, the core adapter logic for Midnight-specific operations is currently a placeholder. Functionality will be added in future development phases as the Midnight Network and its tooling evolve. + +It is intended to be responsible for: + +- Implementing the `ContractAdapter` interface from `@openzeppelin/transaction-form-types`. +- Defining and exporting specific Midnight network configurations as `MidnightNetworkConfig` objects. These are located in `src/networks/` and will include details relevant to Midnight (e.g., node endpoints, specific chain parameters, explorer URLs). +- Loading Midnight contract metadata (details TBD based on Midnight's tooling). +- Mapping Midnight-specific data types to the form field types. +- Parsing user input into Midnight-compatible transactions, according to the `MidnightNetworkConfig`. +- Formatting results from contract queries. +- Interacting with Midnight wallets for signing and sending transactions on the configured network. +- Providing other Midnight-specific configurations and validation. + +## Usage + +Once fully implemented, the `MidnightAdapter` class will be instantiated with a specific `MidnightNetworkConfig` object: + +```typescript +import { MidnightAdapter } from '@openzeppelin/transaction-form-adapter-midnight'; +// Example: import { midnightDevnet } from '@openzeppelin/transaction-form-adapter-midnight'; +import { MidnightNetworkConfig } from '@openzeppelin/transaction-form-types'; + +// For type access if needed + +// Placeholder: Actual network config objects would be imported from './networks' +const placeholderNetworkConfig: MidnightNetworkConfig = { + id: 'midnight-devnet', + name: 'Midnight Devnet', + ecosystem: 'midnight', + network: 'midnight', + type: 'devnet', + isTestnet: true, + explorerUrl: 'https://explorer.midnight.network/devnet', // Hypothetical URL + // ... any other MidnightNetworkConfig fields (e.g., nodeEndpoint) +}; + +const midnightAdapter = new MidnightAdapter(placeholderNetworkConfig); + +// Use midnightAdapter for operations on the configured Midnight network +``` + +Network configurations for Midnight networks will be defined and exported from `src/networks/index.ts` within this package. The full list will be exported as `midnightNetworks`. + +## Internal Structure + +This adapter will follow the standard module structure outlined in the main project [Adapter Architecture Guide](../../docs/ADAPTER_ARCHITECTURE.md), with the `src/networks/` directory for managing its network configurations. diff --git a/packages/adapter-midnight/package.json b/packages/adapter-midnight/package.json new file mode 100644 index 00000000..805d0c07 --- /dev/null +++ b/packages/adapter-midnight/package.json @@ -0,0 +1,56 @@ +{ + "name": "@openzeppelin/transaction-form-adapter-midnight", + "version": "0.0.1", + "private": true, + "description": "Midnight Adapter for Transaction Form Builder", + "keywords": [ + "openzeppelin", + "transaction", + "form", + "builder", + "adapter", + "midnight" + ], + "author": "Aleksandr Pasevin ", + "homepage": "https://github.com/OpenZeppelin/transaction-form-builder#readme", + "license": "MIT", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "files": [ + "dist", + "src" + ], + "publishConfig": { + "access": "public", + "provenance": true + }, + "repository": { + "type": "git", + "url": "https://github.com/OpenZeppelin/transaction-form-builder.git", + "directory": "packages/adapter-midnight" + }, + "bugs": { + "url": "https://github.com/OpenZeppelin/transaction-form-builder/issues" + }, + "scripts": { + "build": "tsc -b", + "clean": "rm -rf dist tsconfig.tsbuildinfo", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "lint:adapters": "node ../../lint-adapters.cjs", + "prepublishOnly": "pnpm build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@openzeppelin/transaction-form-types": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.8.2", + "eslint": "^9.22.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } +} diff --git a/packages/adapter-midnight/src/adapter.ts b/packages/adapter-midnight/src/adapter.ts new file mode 100644 index 00000000..8afe8fc0 --- /dev/null +++ b/packages/adapter-midnight/src/adapter.ts @@ -0,0 +1,164 @@ +import type { + Connector, + ContractAdapter, + ContractFunction, + ContractSchema, + ExecutionConfig, + ExecutionMethodDetail, + FieldType, + FormFieldType, + FunctionParameter, + MidnightNetworkConfig, +} from '@openzeppelin/transaction-form-types'; +import { isMidnightNetworkConfig } from '@openzeppelin/transaction-form-types'; + +// Import functions from modules +import { + getMidnightSupportedExecutionMethods, + validateMidnightExecutionConfig, +} from './configuration/execution'; +import { getMidnightExplorerAddressUrl, getMidnightExplorerTxUrl } from './configuration/explorer'; + +import { loadMidnightContract } from './definition'; +import { + generateMidnightDefaultField, + getMidnightCompatibleFieldTypes, + mapMidnightParameterTypeToFieldType, +} from './mapping'; +import { isMidnightViewFunction, queryMidnightViewFunction } from './query'; +import { formatMidnightTransactionData, signAndBroadcastMidnightTransaction } from './transaction'; +import { formatMidnightFunctionResult } from './transform'; +import { isValidAddress as isMidnightValidAddress } from './utils'; +import { + connectMidnightWallet, + disconnectMidnightWallet, + getMidnightAvailableConnectors, + getMidnightWalletConnectionStatus, + supportsMidnightWalletConnection, +} from './wallet'; + +/** + * Midnight-specific adapter implementation using explicit method delegation. + * + * NOTE: Contains placeholder implementations for most functionalities. + */ +export class MidnightAdapter implements ContractAdapter { + readonly networkConfig: MidnightNetworkConfig; + + constructor(networkConfig: MidnightNetworkConfig) { + if (!isMidnightNetworkConfig(networkConfig)) { + throw new Error('MidnightAdapter requires a valid Midnight network configuration.'); + } + this.networkConfig = networkConfig; + console.log(`MidnightAdapter initialized for network: ${this.networkConfig.name}`); + } + + // --- Contract Loading --- // + async loadContract(source: string): Promise { + return loadMidnightContract(source); + } + + getWritableFunctions(contractSchema: ContractSchema): ContractSchema['functions'] { + return contractSchema.functions.filter((fn) => fn.modifiesState); + } + + // --- Type Mapping & Field Generation --- // + mapParameterTypeToFieldType(parameterType: string): FieldType { + return mapMidnightParameterTypeToFieldType(parameterType); + } + getCompatibleFieldTypes(parameterType: string): FieldType[] { + return getMidnightCompatibleFieldTypes(parameterType); + } + generateDefaultField( + parameter: FunctionParameter + ): FormFieldType { + return generateMidnightDefaultField(parameter); + } + + // --- Transaction Formatting & Execution --- // + formatTransactionData( + contractSchema: ContractSchema, + functionId: string, + submittedInputs: Record, + allFieldsConfig: FormFieldType[] + ): unknown { + return formatMidnightTransactionData( + contractSchema, + functionId, + submittedInputs, + allFieldsConfig + ); + } + async signAndBroadcast(transactionData: unknown): Promise<{ txHash: string }> { + return signAndBroadcastMidnightTransaction(transactionData); + } + + // Optional method: waitForTransactionConfirmation? is omitted as imported function is undefined + + // --- View Function Querying --- // + isViewFunction(functionDetails: ContractFunction): boolean { + return isMidnightViewFunction(functionDetails); + } + async queryViewFunction( + contractAddress: string, + functionId: string, + params: unknown[] = [], + contractSchema?: ContractSchema + ): Promise { + return queryMidnightViewFunction( + contractAddress, + functionId, + this.networkConfig, + params, + contractSchema + ); + } + formatFunctionResult(decodedValue: unknown, functionDetails: ContractFunction): string { + return formatMidnightFunctionResult(decodedValue, functionDetails); + } + + // --- Wallet Interaction --- // + supportsWalletConnection(): boolean { + return supportsMidnightWalletConnection(); + } + async getAvailableConnectors(): Promise { + return getMidnightAvailableConnectors(); + } + async connectWallet( + connectorId: string + ): Promise<{ connected: boolean; address?: string; error?: string }> { + return connectMidnightWallet(connectorId); + } + async disconnectWallet(): Promise<{ disconnected: boolean; error?: string }> { + return disconnectMidnightWallet(); + } + getWalletConnectionStatus(): { isConnected: boolean; address?: string; chainId?: string } { + return getMidnightWalletConnectionStatus(); + } + // Optional method: onWalletConnectionChange? is omitted as imported function is undefined + + // --- Configuration & Metadata --- // + async getSupportedExecutionMethods(): Promise { + return getMidnightSupportedExecutionMethods(); + } + async validateExecutionConfig(config: ExecutionConfig): Promise { + return validateMidnightExecutionConfig(config); + } + getExplorerUrl(address: string): string | null { + return getMidnightExplorerAddressUrl(address, this.networkConfig); + } + getExplorerTxUrl?(txHash: string): string | null { + if (getMidnightExplorerTxUrl) { + return getMidnightExplorerTxUrl(txHash, this.networkConfig); + } + return null; + } + + // --- Validation --- // + isValidAddress(address: string): boolean { + return isMidnightValidAddress(address); + } +} + +// Also export as default +export default MidnightAdapter; diff --git a/packages/core/src/adapters/midnight/config.ts b/packages/adapter-midnight/src/config.ts similarity index 90% rename from packages/core/src/adapters/midnight/config.ts rename to packages/adapter-midnight/src/config.ts index d017a291..60736da5 100644 --- a/packages/core/src/adapters/midnight/config.ts +++ b/packages/adapter-midnight/src/config.ts @@ -1,5 +1,3 @@ -import type { AdapterConfig } from '../../core/types/AdapterTypes'; - /** * Configuration for the Midnight adapter * @@ -7,7 +5,7 @@ import type { AdapterConfig } from '../../core/types/AdapterTypes'; * when generating exported projects. It follows the AdapterConfig * interface to provide a structured approach to dependency management. */ -export const midnightAdapterConfig: AdapterConfig = { +export const midnightAdapterConfig = { /** * Dependencies required by the Midnight adapter * These will be included in exported projects that use this adapter diff --git a/packages/adapter-midnight/src/configuration/execution.ts b/packages/adapter-midnight/src/configuration/execution.ts new file mode 100644 index 00000000..2cfacb18 --- /dev/null +++ b/packages/adapter-midnight/src/configuration/execution.ts @@ -0,0 +1,46 @@ +import type { ExecutionConfig, ExecutionMethodDetail } from '@openzeppelin/transaction-form-types'; + +import { isValidAddress } from '../utils'; + +/** + * @inheritdoc + * TODO: Implement actual supported methods for Midnight. + */ +export function getMidnightSupportedExecutionMethods(): Promise { + // Placeholder: Assume only EOA is supported for now + console.warn('MidnightAdapter.getSupportedExecutionMethods is using placeholder implementation.'); + return Promise.resolve([ + { + type: 'eoa', + name: 'EOA (Midnight Account)', + description: 'Execute using a standard Midnight account.', + }, + ]); +} + +/** + * @inheritdoc + * TODO: Implement actual validation logic for Midnight execution configs. + */ +export function validateMidnightExecutionConfig(config: ExecutionConfig): Promise { + // Placeholder: Basic validation + console.warn('MidnightAdapter.validateExecutionConfig is using placeholder implementation.'); + if (config.method === 'eoa') { + if (!config.allowAny && !config.specificAddress) { + return Promise.resolve('Specific EOA address is required.'); + } + if ( + !config.allowAny && + config.specificAddress && + !isValidAddress(config.specificAddress) // Assuming isValidAddress is moved to utils + ) { + return Promise.resolve('Invalid EOA address format for Midnight.'); + } + return Promise.resolve(true); + } else { + // For now, consider other methods unsupported by this placeholder + return Promise.resolve( + `Execution method '${config.method}' is not yet supported by this adapter implementation.` + ); + } +} diff --git a/packages/adapter-midnight/src/configuration/explorer.ts b/packages/adapter-midnight/src/configuration/explorer.ts new file mode 100644 index 00000000..e2b1c9b3 --- /dev/null +++ b/packages/adapter-midnight/src/configuration/explorer.ts @@ -0,0 +1,43 @@ +import { NetworkConfig } from '@openzeppelin/transaction-form-types'; + +/** + * Gets a blockchain explorer URL for an address on Midnight. + * Uses the explorerUrl from the network configuration. + * + * @param address The address to get the explorer URL for + * @param networkConfig The network configuration object. + * @returns A URL to view the address on the configured Midnight explorer, or null. + */ +export function getMidnightExplorerAddressUrl( + address: string, + networkConfig: NetworkConfig +): string | null { + // Placeholder: Implement logic using networkConfig.explorerUrl if available + if (!address || !networkConfig.explorerUrl) { + return null; + } + // Example construction (adjust path as needed for Midnight) + const baseUrl = networkConfig.explorerUrl.replace(/\/+$/, ''); + return `${baseUrl}/address/${address}`; // Assuming similar path to others +} + +/** + * Gets a blockchain explorer URL for a transaction on Midnight. + * Uses the explorerUrl from the network configuration. + * + * @param txHash - The hash of the transaction to get the explorer URL for + * @param networkConfig The network configuration object. + * @returns A URL to view the transaction on the configured Midnight explorer, or null. + */ +export function getMidnightExplorerTxUrl( + txHash: string, + networkConfig: NetworkConfig +): string | null { + // Placeholder: Implement logic using networkConfig.explorerUrl if available + if (!txHash || !networkConfig.explorerUrl) { + return null; + } + // Example construction (adjust path as needed for Midnight) + const baseUrl = networkConfig.explorerUrl.replace(/\/+$/, ''); + return `${baseUrl}/tx/${txHash}`; // Assuming similar path to others +} diff --git a/packages/adapter-midnight/src/configuration/index.ts b/packages/adapter-midnight/src/configuration/index.ts new file mode 100644 index 00000000..3da7c139 --- /dev/null +++ b/packages/adapter-midnight/src/configuration/index.ts @@ -0,0 +1,4 @@ +// Barrel file + +export * from './execution'; +export * from './explorer'; diff --git a/packages/adapter-midnight/src/definition/index.ts b/packages/adapter-midnight/src/definition/index.ts new file mode 100644 index 00000000..f5fbb0b9 --- /dev/null +++ b/packages/adapter-midnight/src/definition/index.ts @@ -0,0 +1,3 @@ +// Barrel file + +export * from './loader'; diff --git a/packages/adapter-midnight/src/definition/loader.ts b/packages/adapter-midnight/src/definition/loader.ts new file mode 100644 index 00000000..505890eb --- /dev/null +++ b/packages/adapter-midnight/src/definition/loader.ts @@ -0,0 +1,18 @@ +import type { ContractSchema } from '@openzeppelin/transaction-form-types'; + +/** + * Load a contract from a file or address + * + * TODO: Implement actual Midnight contract loading logic in future phases + */ +export function loadMidnightContract(source: string): Promise { + console.log(`[PLACEHOLDER] Loading Midnight contract from: ${source}`); + + // Return a minimal placeholder contract schema + return Promise.resolve({ + ecosystem: 'midnight', + name: 'Placeholder Contract', + address: source, + functions: [], + }); +} diff --git a/packages/adapter-midnight/src/definition/transformer.ts b/packages/adapter-midnight/src/definition/transformer.ts new file mode 100644 index 00000000..10051c76 --- /dev/null +++ b/packages/adapter-midnight/src/definition/transformer.ts @@ -0,0 +1 @@ +// Placeholder diff --git a/packages/adapter-midnight/src/index.ts b/packages/adapter-midnight/src/index.ts new file mode 100644 index 00000000..5f43dde2 --- /dev/null +++ b/packages/adapter-midnight/src/index.ts @@ -0,0 +1,15 @@ +export * from './adapter'; +export { default } from './adapter'; // Default export for convenience + +// Optionally re-export types if needed +// export * from './types'; // No types.ts in Midnight adapter yet + +export { MidnightAdapter } from './adapter'; +export { + midnightNetworks, + midnightMainnetNetworks, + midnightTestnetNetworks, + // Individual networks + midnightMainnet, + midnightDevnet, +} from './networks'; diff --git a/packages/adapter-midnight/src/mapping/constants.ts b/packages/adapter-midnight/src/mapping/constants.ts new file mode 100644 index 00000000..10051c76 --- /dev/null +++ b/packages/adapter-midnight/src/mapping/constants.ts @@ -0,0 +1 @@ +// Placeholder diff --git a/packages/adapter-midnight/src/mapping/field-generator.ts b/packages/adapter-midnight/src/mapping/field-generator.ts new file mode 100644 index 00000000..2f578721 --- /dev/null +++ b/packages/adapter-midnight/src/mapping/field-generator.ts @@ -0,0 +1,30 @@ +import type { + FieldType, + FieldValue, + FormFieldType, + FunctionParameter, +} from '@openzeppelin/transaction-form-types'; + +/** + * Generate default field configuration for a Midnight function parameter + * + * TODO: Implement proper Midnight field generation in future phases + */ +export function generateMidnightDefaultField( + parameter: FunctionParameter +): FormFieldType { + // Default to text fields for now + const fieldType = 'text' as T; + + return { + id: Math.random().toString(36).substring(2, 11), + name: parameter.name || 'placeholder', + label: parameter.displayName || parameter.name || 'Placeholder Field', + type: fieldType, + placeholder: 'Placeholder - not implemented yet', + helperText: 'Midnight adapter is not fully implemented yet', + defaultValue: '' as FieldValue, + validation: { required: true }, + width: 'full', + }; +} diff --git a/packages/adapter-midnight/src/mapping/index.ts b/packages/adapter-midnight/src/mapping/index.ts new file mode 100644 index 00000000..8269c0f2 --- /dev/null +++ b/packages/adapter-midnight/src/mapping/index.ts @@ -0,0 +1,4 @@ +// Barrel file + +export * from './type-mapper'; +export * from './field-generator'; diff --git a/packages/adapter-midnight/src/mapping/type-mapper.ts b/packages/adapter-midnight/src/mapping/type-mapper.ts new file mode 100644 index 00000000..d3ba8848 --- /dev/null +++ b/packages/adapter-midnight/src/mapping/type-mapper.ts @@ -0,0 +1,36 @@ +import type { FieldType } from '@openzeppelin/transaction-form-types'; + +/** + * Map a Midnight-specific parameter type to a form field type + * + * TODO: Implement proper Midnight type mapping in future phases + */ +export function mapMidnightParameterTypeToFieldType(_parameterType: string): FieldType { + // Placeholder implementation that defaults everything to text fields + return 'text'; +} + +/** + * Get field types compatible with a specific parameter type + * @param _parameterType The blockchain parameter type + * @returns Array of compatible field types + * + * TODO: Implement proper Midnight field type compatibility in future phases + */ +export function getMidnightCompatibleFieldTypes(_parameterType: string): FieldType[] { + // Placeholder implementation that returns all field types + return [ + 'text', + 'number', + 'checkbox', + 'radio', + 'select', + 'textarea', + 'date', + 'email', + 'password', + 'blockchain-address', + 'amount', + 'hidden', + ]; +} diff --git a/packages/adapter-midnight/src/networks/index.ts b/packages/adapter-midnight/src/networks/index.ts new file mode 100644 index 00000000..292f43ef --- /dev/null +++ b/packages/adapter-midnight/src/networks/index.ts @@ -0,0 +1,19 @@ +import { MidnightNetworkConfig } from '@openzeppelin/transaction-form-types'; + +import { midnightMainnet } from './mainnet'; +import { midnightDevnet } from './testnet'; + +// All mainnet networks +export const midnightMainnetNetworks: MidnightNetworkConfig[] = [midnightMainnet]; + +// All testnet/devnet networks +export const midnightTestnetNetworks: MidnightNetworkConfig[] = [midnightDevnet]; + +// All Midnight networks +export const midnightNetworks: MidnightNetworkConfig[] = [ + ...midnightMainnetNetworks, + ...midnightTestnetNetworks, +]; + +// Export individual networks as well +export { midnightMainnet, midnightDevnet }; diff --git a/packages/adapter-midnight/src/networks/mainnet.ts b/packages/adapter-midnight/src/networks/mainnet.ts new file mode 100644 index 00000000..0ccc42f5 --- /dev/null +++ b/packages/adapter-midnight/src/networks/mainnet.ts @@ -0,0 +1,14 @@ +import { MidnightNetworkConfig } from '@openzeppelin/transaction-form-types'; + +// Placeholder for a potential Midnight Mainnet +export const midnightMainnet: MidnightNetworkConfig = { + id: 'midnight-mainnet', + exportConstName: 'midnightMainnet', + name: 'Midnight', + ecosystem: 'midnight', + network: 'midnight', + type: 'mainnet', + isTestnet: false, + // Add Midnight-specific fields here when known + // explorerUrl: '...', +}; diff --git a/packages/adapter-midnight/src/networks/testnet.ts b/packages/adapter-midnight/src/networks/testnet.ts new file mode 100644 index 00000000..e788bd67 --- /dev/null +++ b/packages/adapter-midnight/src/networks/testnet.ts @@ -0,0 +1,28 @@ +import { MidnightNetworkConfig } from '@openzeppelin/transaction-form-types'; + +// Placeholder for Midnight Devnet (or Testnet) +export const midnightDevnet: MidnightNetworkConfig = { + id: 'midnight-devnet', + exportConstName: 'midnightDevnet', + name: 'Midnight Devnet (Placeholder)', + ecosystem: 'midnight', + network: 'midnight', + type: 'devnet', // Assuming 'devnet' initially + isTestnet: true, + // Add Midnight-specific fields here when known + // explorerUrl: '...', +}; + +// Placeholder for a potential Midnight Testnet +export const midnightTestnet: MidnightNetworkConfig = { + id: 'midnight-testnet', + exportConstName: 'midnightTestnet', + name: 'Midnight Testnet (Placeholder)', + ecosystem: 'midnight', + network: 'midnight', + type: 'testnet', + isTestnet: true, + // Add Midnight-specific fields here when known + // explorerUrl: '...', + // apiUrl: '...', +}; diff --git a/packages/adapter-midnight/src/query/handler.ts b/packages/adapter-midnight/src/query/handler.ts new file mode 100644 index 00000000..46812b33 --- /dev/null +++ b/packages/adapter-midnight/src/query/handler.ts @@ -0,0 +1,28 @@ +import type { + ContractSchema, + MidnightNetworkConfig, + NetworkConfig, +} from '@openzeppelin/transaction-form-types'; + +/** + * Queries a view function on a contract + */ +export async function queryMidnightViewFunction( + _contractAddress: string, + _functionId: string, + networkConfig: NetworkConfig, + _params: unknown[] = [], + _contractSchema?: ContractSchema +): Promise { + if (networkConfig.ecosystem !== 'midnight') { + throw new Error('Invalid network configuration for Midnight query.'); + } + const midnightConfig = networkConfig as MidnightNetworkConfig; + + // TODO: Implement Midnight contract query functionality using: + // - midnightConfig properties (e.g., RPC endpoint if applicable) + // - _contractAddress, _functionId, _params, _contractSchema + // - Potentially use Midnight SDK + console.warn(`queryMidnightViewFunction not implemented for network: ${midnightConfig.name}`); + throw new Error('Midnight view function queries not yet implemented'); +} diff --git a/packages/adapter-midnight/src/query/index.ts b/packages/adapter-midnight/src/query/index.ts new file mode 100644 index 00000000..2f70e21f --- /dev/null +++ b/packages/adapter-midnight/src/query/index.ts @@ -0,0 +1,4 @@ +// Barrel file + +export * from './handler'; +export * from './view-checker'; diff --git a/packages/adapter-midnight/src/query/view-checker.ts b/packages/adapter-midnight/src/query/view-checker.ts new file mode 100644 index 00000000..10b151ae --- /dev/null +++ b/packages/adapter-midnight/src/query/view-checker.ts @@ -0,0 +1,9 @@ +import type { ContractFunction } from '@openzeppelin/transaction-form-types'; + +/** + * Determines if a function is a view/pure function (read-only) + */ +export function isMidnightViewFunction(_functionDetails: ContractFunction): boolean { + // TODO: Implement properly based on Midnight contract types + return false; // Temporary placeholder +} diff --git a/packages/adapter-midnight/src/transaction/formatter.ts b/packages/adapter-midnight/src/transaction/formatter.ts new file mode 100644 index 00000000..072650d2 --- /dev/null +++ b/packages/adapter-midnight/src/transaction/formatter.ts @@ -0,0 +1,17 @@ +import type { ContractSchema, FormFieldType } from '@openzeppelin/transaction-form-types'; + +/** + * @inheritdoc + */ +export function formatMidnightTransactionData( + _contractSchema: ContractSchema, + _functionId: string, + _submittedInputs: Record, + _allFieldsConfig: FormFieldType[] +): unknown { + console.warn( + 'MidnightAdapter.formatTransactionData not implemented, returning placeholder data.' + ); + // Placeholder implementation + return { data: 'midnight_formatted_placeholder' }; +} diff --git a/packages/adapter-midnight/src/transaction/index.ts b/packages/adapter-midnight/src/transaction/index.ts new file mode 100644 index 00000000..c074c0b8 --- /dev/null +++ b/packages/adapter-midnight/src/transaction/index.ts @@ -0,0 +1,4 @@ +// Barrel file + +export * from './formatter'; +export * from './sender'; diff --git a/packages/adapter-midnight/src/transaction/sender.ts b/packages/adapter-midnight/src/transaction/sender.ts new file mode 100644 index 00000000..df1dcf22 --- /dev/null +++ b/packages/adapter-midnight/src/transaction/sender.ts @@ -0,0 +1,19 @@ +/** + * Sign and broadcast a transaction + * + * TODO: Implement proper Midnight transaction signing in future phases + */ +export function signAndBroadcastMidnightTransaction( + _transactionData: unknown +): Promise<{ txHash: string }> { + return Promise.resolve({ txHash: 'midnight_placeholder_tx' }); +} + +/** + * (Optional) Waits for a transaction to be confirmed on the blockchain. + * + * @param _txHash - The hash of the transaction to wait for. + * @returns A promise resolving to the final status and receipt/error. + */ +export const waitForMidnightTransactionConfirmation: undefined = undefined; +// Optional methods can be implemented later if needed, for now export undefined diff --git a/packages/adapter-midnight/src/transform/index.ts b/packages/adapter-midnight/src/transform/index.ts new file mode 100644 index 00000000..639a0195 --- /dev/null +++ b/packages/adapter-midnight/src/transform/index.ts @@ -0,0 +1,4 @@ +// Barrel file + +export * from './input-parser'; +export * from './output-formatter'; diff --git a/packages/adapter-midnight/src/transform/input-parser.ts b/packages/adapter-midnight/src/transform/input-parser.ts new file mode 100644 index 00000000..ab3b7d58 --- /dev/null +++ b/packages/adapter-midnight/src/transform/input-parser.ts @@ -0,0 +1,11 @@ +// TODO: Implement Midnight-specific input parsing logic if needed. + +// Placeholder function - adapt as needed +export function parseMidnightInput( + _fieldType: string, + _value: unknown, + _parameterType: string +): unknown { + console.warn('MidnightAdapter.parseMidnightInput not implemented, returning raw value.'); + return _value; // Placeholder: return value as is +} diff --git a/packages/adapter-midnight/src/transform/output-formatter.ts b/packages/adapter-midnight/src/transform/output-formatter.ts new file mode 100644 index 00000000..fecc28be --- /dev/null +++ b/packages/adapter-midnight/src/transform/output-formatter.ts @@ -0,0 +1,17 @@ +import type { ContractFunction } from '@openzeppelin/transaction-form-types'; + +/** + * Formats a function result for display + */ +export function formatMidnightFunctionResult( + result: unknown, + _functionDetails: ContractFunction +): string { + // TODO: Implement Midnight-specific result formatting + if (result === null || result === undefined) { + return 'No data'; + } + + // Placeholder: Return simple string representation + return String(result); +} diff --git a/packages/adapter-midnight/src/utils/formatting.ts b/packages/adapter-midnight/src/utils/formatting.ts new file mode 100644 index 00000000..10051c76 --- /dev/null +++ b/packages/adapter-midnight/src/utils/formatting.ts @@ -0,0 +1 @@ +// Placeholder diff --git a/packages/adapter-midnight/src/utils/index.ts b/packages/adapter-midnight/src/utils/index.ts new file mode 100644 index 00000000..d8824e39 --- /dev/null +++ b/packages/adapter-midnight/src/utils/index.ts @@ -0,0 +1,3 @@ +// Barrel file + +export * from './validator'; diff --git a/packages/adapter-midnight/src/utils/validator.ts b/packages/adapter-midnight/src/utils/validator.ts new file mode 100644 index 00000000..6adca9ef --- /dev/null +++ b/packages/adapter-midnight/src/utils/validator.ts @@ -0,0 +1,11 @@ +/** + * Validate a Midnight blockchain address + * @param _address The address to validate + * @returns Whether the address is a valid Midnight address + */ +export function isValidAddress(_address: string): boolean { + // TODO: Implement Midnight address validation when chain specs are available + // For now, return true to avoid blocking development + console.warn('isValidAddress for Midnight is using placeholder implementation.'); + return true; +} diff --git a/packages/adapter-midnight/src/wallet/connection.ts b/packages/adapter-midnight/src/wallet/connection.ts new file mode 100644 index 00000000..166f59ef --- /dev/null +++ b/packages/adapter-midnight/src/wallet/connection.ts @@ -0,0 +1,41 @@ +import type { Connector } from '@openzeppelin/transaction-form-types'; + +/** + * Indicates if this adapter supports wallet connection + * @returns Whether wallet connection is supported by this adapter + */ +export function supportsMidnightWalletConnection(): boolean { + return false; // Midnight adapter does not support wallet connection yet +} + +export async function getMidnightAvailableConnectors(): Promise { + return []; +} + +export async function connectMidnightWallet( + _connectorId: string +): Promise<{ connected: boolean; address?: string; error?: string }> { + return { connected: false, error: 'Midnight adapter does not support wallet connection.' }; +} + +export async function disconnectMidnightWallet(): Promise<{ + disconnected: boolean; + error?: string; +}> { + return { disconnected: false, error: 'Midnight adapter does not support wallet connection.' }; +} + +/** + * @inheritdoc + */ +export function getMidnightWalletConnectionStatus(): { + isConnected: boolean; + address?: string; + chainId?: string; +} { + // Stub implementation: Always return disconnected status + return { isConnected: false }; +} + +// Placeholder for optional connection change listener +export const onMidnightWalletConnectionChange: undefined = undefined; diff --git a/packages/adapter-midnight/src/wallet/index.ts b/packages/adapter-midnight/src/wallet/index.ts new file mode 100644 index 00000000..0250e3d2 --- /dev/null +++ b/packages/adapter-midnight/src/wallet/index.ts @@ -0,0 +1,3 @@ +// Barrel file + +export * from './connection'; diff --git a/packages/adapter-midnight/tsconfig.json b/packages/adapter-midnight/tsconfig.json new file mode 100644 index 00000000..e5246b34 --- /dev/null +++ b/packages/adapter-midnight/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "tsBuildInfoFile": "./tsconfig.tsbuildinfo" + // Add specific compiler options for Midnight if needed + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/packages/adapter-solana/README.md b/packages/adapter-solana/README.md new file mode 100644 index 00000000..95c0a151 --- /dev/null +++ b/packages/adapter-solana/README.md @@ -0,0 +1,52 @@ +# Solana Adapter (`@openzeppelin/transaction-form-adapter-solana`) + +This package provides the `ContractAdapter` implementation for the Solana blockchain for the Transaction Form Builder. + +**Note:** While the basic structure is in place, including network configuration definitions, the core adapter logic for Solana-specific operations is currently a placeholder and will be implemented in future development phases. + +It is intended to be responsible for: + +- Implementing the `ContractAdapter` interface from `@openzeppelin/transaction-form-types`. +- Defining and exporting specific Solana network configurations (e.g., Mainnet Beta, Devnet, Testnet) as `SolanaNetworkConfig` objects. These are located in `src/networks/` and include details like RPC endpoints, cluster information, explorer URLs, and commitment levels. +- Loading Solana program IDLs (Instruction Description Language). +- Mapping Solana-specific data types to the form field types. +- Parsing user input into Solana-compatible transaction instructions, according to the `SolanaNetworkConfig`. +- Formatting results from on-chain program queries. +- Interacting with Solana wallets (e.g., via `@solana/wallet-adapter-base`) for signing and sending transactions on the configured network. +- Providing other Solana-specific configurations and validation. + +## Usage + +Once fully implemented, the `SolanaAdapter` class will be instantiated with a specific `SolanaNetworkConfig` object: + +```typescript +import { SolanaAdapter } from '@openzeppelin/transaction-form-adapter-solana'; +// Example: import { solanaMainnetBeta } from '@openzeppelin/transaction-form-adapter-solana'; +import { SolanaNetworkConfig } from '@openzeppelin/transaction-form-types'; + +// For type access if needed + +// Placeholder: Actual network config objects would be imported from './networks' +const placeholderNetworkConfig: SolanaNetworkConfig = { + id: 'solana-devnet', + name: 'Solana Devnet', + ecosystem: 'solana', + network: 'solana', + type: 'devnet', + isTestnet: true, + rpcEndpoint: 'https://api.devnet.solana.com', + explorerUrl: 'https://explorer.solana.com/?cluster=devnet', + commitment: 'confirmed', + // ... any other SolanaNetworkConfig fields +}; + +const solanaAdapter = new SolanaAdapter(placeholderNetworkConfig); + +// Use solanaAdapter for operations on the configured Solana network +``` + +Network configurations for Solana networks (e.g., `solanaMainnetBeta`, `solanaDevnet`) are defined and exported from `src/networks/index.ts` within this package. The full list is exported as `solanaNetworks`. + +## Internal Structure + +This adapter will follow the standard module structure outlined in the main project [Adapter Architecture Guide](../../docs/ADAPTER_ARCHITECTURE.md), with the `src/networks/` directory for managing its network configurations. diff --git a/packages/adapter-solana/package.json b/packages/adapter-solana/package.json new file mode 100644 index 00000000..7b26048f --- /dev/null +++ b/packages/adapter-solana/package.json @@ -0,0 +1,62 @@ +{ + "name": "@openzeppelin/transaction-form-adapter-solana", + "version": "0.0.1", + "private": true, + "description": "Solana Adapter for Transaction Form Builder", + "keywords": [ + "openzeppelin", + "transaction", + "form", + "builder", + "adapter", + "solana" + ], + "author": "Aleksandr Pasevin ", + "homepage": "https://github.com/OpenZeppelin/transaction-form-builder#readme", + "license": "MIT", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "files": [ + "dist", + "src" + ], + "publishConfig": { + "access": "public", + "provenance": true + }, + "repository": { + "type": "git", + "url": "https://github.com/OpenZeppelin/transaction-form-builder.git", + "directory": "packages/adapter-solana" + }, + "bugs": { + "url": "https://github.com/OpenZeppelin/transaction-form-builder/issues" + }, + "scripts": { + "build": "tsc -b", + "clean": "rm -rf dist tsconfig.tsbuildinfo", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "lint:adapters": "node ../../lint-adapters.cjs", + "prepublishOnly": "pnpm build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@openzeppelin/transaction-form-types": "workspace:*", + "@solana/web3.js": "^1.78.5", + "@solana/spl-token": "^0.3.8", + "@solana/wallet-adapter-react": "^0.15.35", + "@solana/wallet-adapter-base": "^0.9.23", + "bs58": "^5.0.0", + "@project-serum/anchor": "^0.26.0" + }, + "devDependencies": { + "typescript": "^5.8.2", + "eslint": "^9.22.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } +} diff --git a/packages/adapter-solana/src/adapter.ts b/packages/adapter-solana/src/adapter.ts new file mode 100644 index 00000000..d5cb06a0 --- /dev/null +++ b/packages/adapter-solana/src/adapter.ts @@ -0,0 +1,198 @@ +import type { + Connector, + ContractAdapter, + ContractFunction, + ContractSchema, + ExecutionConfig, + ExecutionMethodDetail, + FieldType, + FormFieldType, + FunctionParameter, + NetworkConfig, + SolanaNetworkConfig, +} from '@openzeppelin/transaction-form-types'; +import { isSolanaNetworkConfig } from '@openzeppelin/transaction-form-types'; + +import { + getSolanaExplorerAddressUrl, + getSolanaExplorerTxUrl, + getSolanaSupportedExecutionMethods, + validateSolanaExecutionConfig, +} from './configuration'; +// Import implementations from modules +import { loadSolanaContract } from './definition'; +import { + generateSolanaDefaultField, + getSolanaCompatibleFieldTypes, + mapSolanaParamTypeToFieldType, +} from './mapping'; +import { isSolanaViewFunction, querySolanaViewFunction } from './query'; +import { + formatSolanaTransactionData, + signAndBroadcastSolanaTransaction, + waitForSolanaTransactionConfirmation, +} from './transaction'; +import { formatSolanaFunctionResult } from './transform'; +import { isValidSolanaAddress } from './utils'; +import { + connectSolanaWallet, + disconnectSolanaWallet, + getSolanaAvailableConnectors, + getSolanaWalletConnectionStatus, + onSolanaWalletConnectionChange, + solanaSupportsWalletConnection, +} from './wallet'; + +/** + * Solana-specific adapter implementation + */ +export class SolanaAdapter implements ContractAdapter { + readonly networkConfig: SolanaNetworkConfig; + // private walletImplementation: SolanaWalletImplementation; // Example + + constructor(networkConfig: SolanaNetworkConfig) { + if (!isSolanaNetworkConfig(networkConfig)) { + throw new Error('SolanaAdapter requires a valid Solana network configuration.'); + } + this.networkConfig = networkConfig; + console.log(`SolanaAdapter initialized for network: ${this.networkConfig.name}`); + } + + async loadContract(source: string, _networkConfig?: NetworkConfig): Promise { + return loadSolanaContract(source); + } + + getWritableFunctions(contractSchema: ContractSchema): ContractSchema['functions'] { + // Simple filtering can stay here or be moved to a query util if complex + return contractSchema.functions.filter((fn) => fn.modifiesState); + } + + mapParameterTypeToFieldType(parameterType: string): FieldType { + return mapSolanaParamTypeToFieldType(parameterType); + } + + getCompatibleFieldTypes(parameterType: string): FieldType[] { + return getSolanaCompatibleFieldTypes(parameterType); + } + + generateDefaultField( + parameter: FunctionParameter + ): FormFieldType { + return generateSolanaDefaultField(parameter); + } + + formatTransactionData( + contractSchema: ContractSchema, + functionId: string, + submittedInputs: Record, + allFieldsConfig: FormFieldType[] + ): unknown { + return formatSolanaTransactionData( + contractSchema, + functionId, + submittedInputs, + allFieldsConfig + ); + } + + async signAndBroadcast(transactionData: unknown): Promise<{ txHash: string }> { + return signAndBroadcastSolanaTransaction(transactionData); + } + + isValidAddress(address: string): boolean { + return isValidSolanaAddress(address); + } + + async getSupportedExecutionMethods(): Promise { + return getSolanaSupportedExecutionMethods(); + } + + async validateExecutionConfig(config: ExecutionConfig): Promise { + return validateSolanaExecutionConfig(config); + } + + isViewFunction(functionDetails: ContractFunction): boolean { + return isSolanaViewFunction(functionDetails); + } + + async queryViewFunction( + contractAddress: string, + functionId: string, + params: unknown[] = [], + contractSchema?: ContractSchema + ): Promise { + return querySolanaViewFunction( + contractAddress, + functionId, + this.networkConfig, + params, + contractSchema, + undefined, + (src) => this.loadContract(src) + ); + } + + formatFunctionResult(decodedValue: unknown, functionDetails: ContractFunction): string { + return formatSolanaFunctionResult(decodedValue, functionDetails); + } + + supportsWalletConnection(): boolean { + return solanaSupportsWalletConnection(); + } + + async getAvailableConnectors(): Promise { + return getSolanaAvailableConnectors(/* this.walletImplementation */); + } + + async connectWallet( + connectorId: string + ): Promise<{ connected: boolean; address?: string; error?: string }> { + return connectSolanaWallet(connectorId /*, undefined */); + } + + async disconnectWallet(): Promise<{ disconnected: boolean; error?: string }> { + return disconnectSolanaWallet(/* undefined */); + } + + getWalletConnectionStatus(): { isConnected: boolean; address?: string; chainId?: string } { + return getSolanaWalletConnectionStatus(/* undefined */); + } + + onWalletConnectionChange?( + callback: (status: { isConnected: boolean; address?: string }) => void + ): () => void { + // Optional methods need careful handling during delegation + if (onSolanaWalletConnectionChange) { + return onSolanaWalletConnectionChange(/* this.walletImplementation, */ callback); + } + return () => {}; // Default no-op cleanup + } + + getExplorerUrl(address: string): string | null { + return getSolanaExplorerAddressUrl(address, this.networkConfig); + } + + getExplorerTxUrl?(txHash: string): string | null { + if (getSolanaExplorerTxUrl) { + return getSolanaExplorerTxUrl(txHash, this.networkConfig); + } + return null; + } + + async waitForTransactionConfirmation?(txHash: string): Promise<{ + status: 'success' | 'error'; + receipt?: unknown; + error?: Error; + }> { + // Optional methods need careful handling during delegation + if (waitForSolanaTransactionConfirmation) { + return waitForSolanaTransactionConfirmation(txHash /*, this.walletImplementation */); + } + // If function doesn't exist in module, maybe return success immediately or throw? + // Throwing is safer if the interface implies support when implemented. + // Let's return success for placeholder. + return { status: 'success' }; + } +} + +export default SolanaAdapter; diff --git a/packages/core/src/adapters/solana/config.ts b/packages/adapter-solana/src/config.ts similarity index 90% rename from packages/core/src/adapters/solana/config.ts rename to packages/adapter-solana/src/config.ts index 9f9f2a00..4a982044 100644 --- a/packages/core/src/adapters/solana/config.ts +++ b/packages/adapter-solana/src/config.ts @@ -1,4 +1,4 @@ -import type { AdapterConfig } from '../../core/types/AdapterTypes'; +// import type { AdapterConfig } from '../../core/types/AdapterTypes'; /** * Configuration for the Solana adapter @@ -7,7 +7,7 @@ import type { AdapterConfig } from '../../core/types/AdapterTypes'; * when generating exported projects. It follows the AdapterConfig * interface to provide a structured approach to dependency management. */ -export const solanaAdapterConfig: AdapterConfig = { +export const solanaAdapterConfig = { /** * Dependencies required by the Solana adapter * These will be included in exported projects that use this adapter diff --git a/packages/adapter-solana/src/configuration/execution.ts b/packages/adapter-solana/src/configuration/execution.ts new file mode 100644 index 00000000..2a438842 --- /dev/null +++ b/packages/adapter-solana/src/configuration/execution.ts @@ -0,0 +1,11 @@ +import type { ExecutionConfig, ExecutionMethodDetail } from '@openzeppelin/transaction-form-types'; + +// Placeholders +export async function getSolanaSupportedExecutionMethods(): Promise { + return [{ type: 'eoa', name: 'EOA', description: 'Placeholder' }]; +} +export async function validateSolanaExecutionConfig( + _config: ExecutionConfig +): Promise { + return true; +} diff --git a/packages/adapter-solana/src/configuration/explorer.ts b/packages/adapter-solana/src/configuration/explorer.ts new file mode 100644 index 00000000..d109f001 --- /dev/null +++ b/packages/adapter-solana/src/configuration/explorer.ts @@ -0,0 +1,34 @@ +import { NetworkConfig } from '@openzeppelin/transaction-form-types'; + +/** + * Gets a blockchain explorer URL for a Solana address. + * Uses the explorerUrl from the network configuration. + */ +export function getSolanaExplorerAddressUrl( + address: string, + networkConfig: NetworkConfig +): string | null { + if (!networkConfig.explorerUrl) { + return null; + } + // Construct the URL, assuming a standard /address/ path + // Handle potential trailing slashes in explorerUrl + const baseUrl = networkConfig.explorerUrl.replace(/\/+$/, ''); + return `${baseUrl}/address/${address}`; +} + +/** + * Gets a blockchain explorer URL for a Solana transaction. + * Uses the explorerUrl from the network configuration. + */ +export function getSolanaExplorerTxUrl( + txHash: string, + networkConfig: NetworkConfig +): string | null { + if (!networkConfig.explorerUrl) { + return null; + } + // Construct the URL, assuming a standard /tx/ path + const baseUrl = networkConfig.explorerUrl.replace(/\/+$/, ''); + return `${baseUrl}/tx/${txHash}`; +} diff --git a/packages/adapter-solana/src/configuration/index.ts b/packages/adapter-solana/src/configuration/index.ts new file mode 100644 index 00000000..fb95193b --- /dev/null +++ b/packages/adapter-solana/src/configuration/index.ts @@ -0,0 +1,3 @@ +// Barrel file for configuration module +export * from './execution'; +export * from './explorer'; diff --git a/packages/adapter-solana/src/definition/index.ts b/packages/adapter-solana/src/definition/index.ts new file mode 100644 index 00000000..922d2d8a --- /dev/null +++ b/packages/adapter-solana/src/definition/index.ts @@ -0,0 +1,3 @@ +// Barrel file +export * from './loader'; +export * from './transformer'; // Assuming transformer might be needed later diff --git a/packages/adapter-solana/src/definition/loader.ts b/packages/adapter-solana/src/definition/loader.ts new file mode 100644 index 00000000..d87ae265 --- /dev/null +++ b/packages/adapter-solana/src/definition/loader.ts @@ -0,0 +1,13 @@ +import type { ContractSchema } from '@openzeppelin/transaction-form-types'; + +// Placeholder +export async function loadSolanaContract(source: string): Promise { + console.warn('loadSolanaContract not implemented'); + // Return a minimal valid schema to avoid breaking types further down + return { + ecosystem: 'solana', + name: 'PlaceholderSolanaContract', + address: source, + functions: [], + }; +} diff --git a/packages/adapter-solana/src/definition/transformer.ts b/packages/adapter-solana/src/definition/transformer.ts new file mode 100644 index 00000000..b547eda3 --- /dev/null +++ b/packages/adapter-solana/src/definition/transformer.ts @@ -0,0 +1,6 @@ +// Placeholder for IDL transformation logic +// Example export +export function transformIdlToSchema() { + throw new Error('transformIdlToSchema not implemented'); +} +export {}; // Keep TS happy if function above removed diff --git a/packages/adapter-solana/src/index.ts b/packages/adapter-solana/src/index.ts new file mode 100644 index 00000000..96e64c34 --- /dev/null +++ b/packages/adapter-solana/src/index.ts @@ -0,0 +1,15 @@ +// Re-export the main adapter class +export { SolanaAdapter } from './adapter'; + +// Optionally re-export types if needed +// export * from './types'; // No types.ts in Solana adapter yet + +export { + solanaNetworks, + solanaMainnetNetworks, + solanaTestnetNetworks, + // Individual networks + solanaMainnetBeta, + solanaDevnet, + solanaTestnet, +} from './networks'; diff --git a/packages/adapter-solana/src/mapping/constants.ts b/packages/adapter-solana/src/mapping/constants.ts new file mode 100644 index 00000000..67f8bf9d --- /dev/null +++ b/packages/adapter-solana/src/mapping/constants.ts @@ -0,0 +1,9 @@ +import type { FieldType } from '@openzeppelin/transaction-form-types'; + +// Placeholder +export const SOLANA_TYPE_TO_FIELD_TYPE: Record = { + string: 'text', + u64: 'number', + publicKey: 'blockchain-address', + // Add more Solana types +}; diff --git a/packages/adapter-solana/src/mapping/field-generator.ts b/packages/adapter-solana/src/mapping/field-generator.ts new file mode 100644 index 00000000..47158314 --- /dev/null +++ b/packages/adapter-solana/src/mapping/field-generator.ts @@ -0,0 +1,48 @@ +import { startCase } from 'lodash'; + +import type { + FieldType, + FieldValidation, + FieldValue, + FormFieldType, + FunctionParameter, +} from '@openzeppelin/transaction-form-types'; + +import { mapSolanaParamTypeToFieldType } from './type-mapper'; + +// Placeholder - Needs specific logic +function getDefaultValueForType(fieldType: T): FieldValue { + // Return a default value compatible with most basic types (string/number/bool etc.) + // Specific adapters might refine this. + switch (fieldType) { + case 'checkbox': + return false as FieldValue; + case 'number': + return 0 as FieldValue; + default: + return '' as FieldValue; + } +} + +// Placeholder - Needs specific logic +function getDefaultValidationForType(_parameterType: string): FieldValidation { + return { required: true }; +} + +// Placeholder +export function generateSolanaDefaultField( + parameter: FunctionParameter +): FormFieldType { + console.warn('generateSolanaDefaultField not implemented'); + const fieldType = mapSolanaParamTypeToFieldType(parameter.type) as T; + return { + id: `field-${Math.random().toString(36).substring(2, 9)}`, + name: parameter.name || parameter.type, + label: startCase(parameter.displayName || parameter.name || parameter.type), + type: fieldType, + placeholder: `Enter ${parameter.displayName || parameter.name || parameter.type}`, + defaultValue: getDefaultValueForType(fieldType), + validation: getDefaultValidationForType(parameter.type), + width: 'full', + }; +} diff --git a/packages/adapter-solana/src/mapping/index.ts b/packages/adapter-solana/src/mapping/index.ts new file mode 100644 index 00000000..c667bb63 --- /dev/null +++ b/packages/adapter-solana/src/mapping/index.ts @@ -0,0 +1,4 @@ +// Barrel file for mapping module +export * from './constants'; +export * from './type-mapper'; +export * from './field-generator'; diff --git a/packages/adapter-solana/src/mapping/type-mapper.ts b/packages/adapter-solana/src/mapping/type-mapper.ts new file mode 100644 index 00000000..b3e173fe --- /dev/null +++ b/packages/adapter-solana/src/mapping/type-mapper.ts @@ -0,0 +1,18 @@ +import type { FieldType } from '@openzeppelin/transaction-form-types'; + +import { SOLANA_TYPE_TO_FIELD_TYPE } from './constants'; + +// Placeholder +export function mapSolanaParamTypeToFieldType(parameterType: string): FieldType { + console.warn('mapSolanaParamTypeToFieldType not implemented'); + return SOLANA_TYPE_TO_FIELD_TYPE[parameterType] || 'text'; +} + +// Placeholder +export function getSolanaCompatibleFieldTypes(parameterType: string): FieldType[] { + console.warn('getSolanaCompatibleFieldTypes not implemented'); + // Allow basic types for now + if (parameterType === 'publicKey') return ['blockchain-address', 'text']; + if (parameterType.startsWith('u') || parameterType.startsWith('i')) return ['number', 'text']; + return ['text', 'textarea']; +} diff --git a/packages/adapter-solana/src/networks/index.ts b/packages/adapter-solana/src/networks/index.ts new file mode 100644 index 00000000..fbbc7be8 --- /dev/null +++ b/packages/adapter-solana/src/networks/index.ts @@ -0,0 +1,19 @@ +import { SolanaNetworkConfig } from '@openzeppelin/transaction-form-types'; + +import { solanaMainnetBeta } from './mainnet'; +import { solanaDevnet, solanaTestnet } from './testnet'; + +// All mainnet networks +export const solanaMainnetNetworks: SolanaNetworkConfig[] = [solanaMainnetBeta]; + +// All testnet/devnet networks +export const solanaTestnetNetworks: SolanaNetworkConfig[] = [solanaDevnet, solanaTestnet]; + +// All Solana networks +export const solanaNetworks: SolanaNetworkConfig[] = [ + ...solanaMainnetNetworks, + ...solanaTestnetNetworks, +]; + +// Export individual networks as well +export { solanaMainnetBeta, solanaDevnet, solanaTestnet }; diff --git a/packages/adapter-solana/src/networks/mainnet.ts b/packages/adapter-solana/src/networks/mainnet.ts new file mode 100644 index 00000000..f0193301 --- /dev/null +++ b/packages/adapter-solana/src/networks/mainnet.ts @@ -0,0 +1,18 @@ +import { SolanaNetworkConfig } from '@openzeppelin/transaction-form-types'; + +// Placeholder for Solana Mainnet Beta +export const solanaMainnetBeta: SolanaNetworkConfig = { + id: 'solana-mainnet-beta', + exportConstName: 'solanaMainnetBeta', + name: 'Solana', + ecosystem: 'solana', + network: 'solana', + type: 'mainnet', + isTestnet: false, + rpcEndpoint: 'https://api.mainnet-beta.solana.com', + commitment: 'confirmed', + explorerUrl: 'https://explorer.solana.com', + icon: 'solana', +}; + +// Add other Solana mainnet networks if applicable diff --git a/packages/adapter-solana/src/networks/testnet.ts b/packages/adapter-solana/src/networks/testnet.ts new file mode 100644 index 00000000..7fb7036c --- /dev/null +++ b/packages/adapter-solana/src/networks/testnet.ts @@ -0,0 +1,33 @@ +import { SolanaNetworkConfig } from '@openzeppelin/transaction-form-types'; + +// Placeholder for Solana Devnet +export const solanaDevnet: SolanaNetworkConfig = { + id: 'solana-devnet', + exportConstName: 'solanaDevnet', + name: 'Solana Devnet', + ecosystem: 'solana', + network: 'solana', + type: 'devnet', // Solana uses 'devnet' commonly + isTestnet: true, + rpcEndpoint: 'https://api.devnet.solana.com', + commitment: 'confirmed', + explorerUrl: 'https://explorer.solana.com/?cluster=devnet', + icon: 'solana', +}; + +// Placeholder for Solana Testnet +export const solanaTestnet: SolanaNetworkConfig = { + id: 'solana-testnet', + exportConstName: 'solanaTestnet', + name: 'Solana Testnet', + ecosystem: 'solana', + network: 'solana', + type: 'testnet', + isTestnet: true, + rpcEndpoint: 'https://api.testnet.solana.com', + commitment: 'confirmed', + explorerUrl: 'https://explorer.solana.com/?cluster=testnet', + icon: 'solana', +}; + +// Add other Solana testnet/devnet networks if applicable diff --git a/packages/adapter-solana/src/query/handler.ts b/packages/adapter-solana/src/query/handler.ts new file mode 100644 index 00000000..9f1ff86a --- /dev/null +++ b/packages/adapter-solana/src/query/handler.ts @@ -0,0 +1,35 @@ +import type { + ContractSchema, + NetworkConfig, + SolanaNetworkConfig, +} from '@openzeppelin/transaction-form-types'; + +// Assuming we might reuse some types temporarily +// Placeholder type for wallet implementation +type SolanaWalletImplementation = unknown; + +// Placeholder - updated to accept networkConfig +export async function querySolanaViewFunction( + _contractAddress: string, + _functionId: string, + networkConfig: NetworkConfig, + _params: unknown[], + _contractSchema: ContractSchema | undefined, + _walletImplementation: SolanaWalletImplementation | undefined, // Use placeholder type + _loadContractFn: (source: string, networkConfig?: NetworkConfig) => Promise // Update signature +): Promise { + // Basic validation + if (networkConfig.ecosystem !== 'solana') { + throw new Error('Invalid network configuration for Solana query.'); + } + const solanaConfig = networkConfig as SolanaNetworkConfig; + + // TODO: Implement actual Solana view function query using: + // - solanaConfig.rpcEndpoint + // - _contractAddress, _functionId, _params, _contractSchema + // - Potentially use a Solana library like @solana/web3.js + console.warn( + `querySolanaViewFunction not fully implemented for network: ${solanaConfig.name} (RPC: ${solanaConfig.rpcEndpoint})` + ); + return undefined; +} diff --git a/packages/adapter-solana/src/query/index.ts b/packages/adapter-solana/src/query/index.ts new file mode 100644 index 00000000..6dcdb04a --- /dev/null +++ b/packages/adapter-solana/src/query/index.ts @@ -0,0 +1,3 @@ +// Barrel file for query module +export * from './view-checker'; +export * from './handler'; diff --git a/packages/adapter-solana/src/query/view-checker.ts b/packages/adapter-solana/src/query/view-checker.ts new file mode 100644 index 00000000..22773baa --- /dev/null +++ b/packages/adapter-solana/src/query/view-checker.ts @@ -0,0 +1,7 @@ +import type { ContractFunction } from '@openzeppelin/transaction-form-types'; + +// Placeholder +export function isSolanaViewFunction(_functionDetails: ContractFunction): boolean { + console.warn('isSolanaViewFunction not implemented'); + return false; // Placeholder +} diff --git a/packages/adapter-solana/src/transaction/formatter.ts b/packages/adapter-solana/src/transaction/formatter.ts new file mode 100644 index 00000000..5ead070f --- /dev/null +++ b/packages/adapter-solana/src/transaction/formatter.ts @@ -0,0 +1,12 @@ +import type { ContractSchema, FormFieldType } from '@openzeppelin/transaction-form-types'; + +// Placeholder +export function formatSolanaTransactionData( + _contractSchema: ContractSchema, // Use underscore prefix for unused placeholder args + _functionId: string, + _submittedInputs: Record, + _allFieldsConfig: FormFieldType[] +): unknown { + console.warn('formatSolanaTransactionData not implemented'); + return {}; +} diff --git a/packages/adapter-solana/src/transaction/index.ts b/packages/adapter-solana/src/transaction/index.ts new file mode 100644 index 00000000..a3bc4e38 --- /dev/null +++ b/packages/adapter-solana/src/transaction/index.ts @@ -0,0 +1,3 @@ +// Barrel file for transaction module +export * from './formatter'; +export * from './sender'; diff --git a/packages/adapter-solana/src/transaction/sender.ts b/packages/adapter-solana/src/transaction/sender.ts new file mode 100644 index 00000000..06d23c2c --- /dev/null +++ b/packages/adapter-solana/src/transaction/sender.ts @@ -0,0 +1,17 @@ +// Placeholder +export async function signAndBroadcastSolanaTransaction( + _transactionData: unknown +): Promise<{ txHash: string }> { + console.warn('signAndBroadcastSolanaTransaction not implemented'); + return { txHash: 'solana_placeholder_tx' }; +} + +// Placeholder - Note: Optional methods aren't typically defined this way as standalone functions. +// The adapter class itself would implement the optional method from the interface. +// For refactoring, we'll define it here, but it might be better integrated differently. +export async function waitForSolanaTransactionConfirmation( + _txHash: string +): Promise<{ status: 'success' | 'error'; receipt?: unknown; error?: Error }> { + console.warn('waitForSolanaTransactionConfirmation not implemented'); + return { status: 'success' }; // Assume success for placeholder +} diff --git a/packages/adapter-solana/src/transform/index.ts b/packages/adapter-solana/src/transform/index.ts new file mode 100644 index 00000000..afa37e21 --- /dev/null +++ b/packages/adapter-solana/src/transform/index.ts @@ -0,0 +1,3 @@ +// Barrel file for transform module +export * from './input-parser'; +export * from './output-formatter'; diff --git a/packages/adapter-solana/src/transform/input-parser.ts b/packages/adapter-solana/src/transform/input-parser.ts new file mode 100644 index 00000000..133f0c3b --- /dev/null +++ b/packages/adapter-solana/src/transform/input-parser.ts @@ -0,0 +1,11 @@ +import type { FunctionParameter } from '@openzeppelin/transaction-form-types'; + +// Placeholder +export function parseSolanaInput( + _param: FunctionParameter, + rawValue: unknown, + _isRecursive = false +): unknown { + console.warn('parseSolanaInput not implemented'); + return rawValue; // Passthrough for now +} diff --git a/packages/adapter-solana/src/transform/output-formatter.ts b/packages/adapter-solana/src/transform/output-formatter.ts new file mode 100644 index 00000000..00638ba7 --- /dev/null +++ b/packages/adapter-solana/src/transform/output-formatter.ts @@ -0,0 +1,12 @@ +import type { ContractFunction } from '@openzeppelin/transaction-form-types'; + +// Placeholder +export function formatSolanaFunctionResult( + decodedValue: unknown, + _functionDetails: ContractFunction +): string { + console.warn('formatSolanaFunctionResult not implemented'); + if (decodedValue === null || decodedValue === undefined) return '(null)'; + // Basic string formatting for now + return String(decodedValue); +} diff --git a/packages/adapter-solana/src/utils/formatting.ts b/packages/adapter-solana/src/utils/formatting.ts new file mode 100644 index 00000000..10051c76 --- /dev/null +++ b/packages/adapter-solana/src/utils/formatting.ts @@ -0,0 +1 @@ +// Placeholder diff --git a/packages/adapter-solana/src/utils/index.ts b/packages/adapter-solana/src/utils/index.ts new file mode 100644 index 00000000..b71737dc --- /dev/null +++ b/packages/adapter-solana/src/utils/index.ts @@ -0,0 +1,3 @@ +// Barrel file for utils module +export * from './validation'; +// Add other utils like formatting later if needed diff --git a/packages/adapter-solana/src/utils/validation.ts b/packages/adapter-solana/src/utils/validation.ts new file mode 100644 index 00000000..e0c4a294 --- /dev/null +++ b/packages/adapter-solana/src/utils/validation.ts @@ -0,0 +1,6 @@ +// Placeholder validation utility for Solana +export function isValidSolanaAddress(address: string): boolean { + console.warn('isValidSolanaAddress not implemented robustly'); + // Basic placeholder check + return /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(address); +} diff --git a/packages/adapter-solana/src/wallet/connection.ts b/packages/adapter-solana/src/wallet/connection.ts new file mode 100644 index 00000000..bb3a3075 --- /dev/null +++ b/packages/adapter-solana/src/wallet/connection.ts @@ -0,0 +1,45 @@ +import type { GetAccountReturnType } from '@wagmi/core'; + +// Keep for callback type consistency? +import type { Connector } from '@openzeppelin/transaction-form-types'; + +// Assuming a Solana Wallet Implementation type might exist later +// import type { SolanaWalletImplementation } from './implementation'; + +// Placeholders +export function solanaSupportsWalletConnection(): boolean { + return false; +} +export async function getSolanaAvailableConnectors(/* walletImplementation: SolanaWalletImplementation */): Promise< + Connector[] +> { + return []; +} +export async function connectSolanaWallet( + _connectorId: string + /* walletImplementation: SolanaWalletImplementation */ +): Promise<{ connected: boolean; error?: string }> { + return { connected: false, error: 'Not implemented' }; +} +export async function disconnectSolanaWallet(/* walletImplementation: SolanaWalletImplementation */): Promise<{ + disconnected: boolean; + error?: string; +}> { + return { disconnected: true }; +} +export function getSolanaWalletConnectionStatus(/* walletImplementation: SolanaWalletImplementation */): { + isConnected: boolean; + address?: string; + chainId?: string; +} { + return { isConnected: false }; +} +export function onSolanaWalletConnectionChange( + /* walletImplementation: SolanaWalletImplementation, */ + _callback: ( + account: /* Replace with Solana specific type */ GetAccountReturnType, + prevAccount: GetAccountReturnType + ) => void +): () => void { + return () => {}; +} diff --git a/packages/adapter-solana/src/wallet/index.ts b/packages/adapter-solana/src/wallet/index.ts new file mode 100644 index 00000000..e6f4d566 --- /dev/null +++ b/packages/adapter-solana/src/wallet/index.ts @@ -0,0 +1,3 @@ +// Barrel file for wallet module +export * from './connection'; +// export * from './implementation'; // If needed later diff --git a/packages/adapter-solana/tsconfig.json b/packages/adapter-solana/tsconfig.json new file mode 100644 index 00000000..b0f33240 --- /dev/null +++ b/packages/adapter-solana/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "tsBuildInfoFile": "./tsconfig.tsbuildinfo" + // Add specific compiler options for Solana if needed + // e.g., "skipLibCheck": true might be needed if @solana/web3.js has type issues + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/packages/adapter-stellar/README.md b/packages/adapter-stellar/README.md new file mode 100644 index 00000000..d9a5523e --- /dev/null +++ b/packages/adapter-stellar/README.md @@ -0,0 +1,52 @@ +# Stellar Adapter (`@openzeppelin/transaction-form-adapter-stellar`) + +This package provides the `ContractAdapter` implementation for the Stellar network for the Transaction Form Builder. + +**Note:** While the basic structure is in place, including network configuration definitions, the core adapter logic for Stellar-specific operations is currently a placeholder and will be implemented in future development phases. + +It is intended to be responsible for: + +- Implementing the `ContractAdapter` interface from `@openzeppelin/transaction-form-types`. +- Defining and exporting specific Stellar network configurations (e.g., Public Network, Testnet) as `StellarNetworkConfig` objects. These are located in `src/networks/` and include details like Horizon URLs, network passphrases, and explorer URLs. +- Loading Stellar contract metadata (e.g., from XDR for Soroban contracts). +- Mapping Stellar-specific data types (e.g., Soroban types) to the form field types. +- Parsing user input into Stellar operations/transactions, according to the `StellarNetworkConfig`. +- Formatting results from Horizon API queries or contract state. +- Interacting with Stellar wallets (e.g., via Freighter, Albedo) for signing and submitting transactions on the configured network. +- Providing other Stellar-specific configurations and validation. + +## Usage + +Once fully implemented, the `StellarAdapter` class will be instantiated with a specific `StellarNetworkConfig` object: + +```typescript +import { StellarAdapter } from '@openzeppelin/transaction-form-adapter-stellar'; +// Example: import { stellarPubnet } from '@openzeppelin/transaction-form-adapter-stellar'; +import { StellarNetworkConfig } from '@openzeppelin/transaction-form-types'; + +// For type access if needed + +// Placeholder: Actual network config objects would be imported from './networks' +const placeholderNetworkConfig: StellarNetworkConfig = { + id: 'stellar-testnet', + name: 'Stellar Testnet', + ecosystem: 'stellar', + network: 'stellar', + type: 'testnet', + isTestnet: true, + horizonUrl: 'https://horizon-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', + explorerUrl: 'https://stellar.expert/explorer/testnet', + // ... any other StellarNetworkConfig fields +}; + +const stellarAdapter = new StellarAdapter(placeholderNetworkConfig); + +// Use stellarAdapter for operations on the configured Stellar network +``` + +Network configurations for Stellar networks (e.g., `stellarPubnet`, `stellarTestnet`) are defined and exported from `src/networks/index.ts` within this package. The full list is exported as `stellarNetworks`. + +## Internal Structure + +This adapter will follow the standard module structure outlined in the main project [Adapter Architecture Guide](../../docs/ADAPTER_ARCHITECTURE.md), with the `src/networks/` directory for managing its network configurations. diff --git a/packages/adapter-stellar/package.json b/packages/adapter-stellar/package.json new file mode 100644 index 00000000..049a15c7 --- /dev/null +++ b/packages/adapter-stellar/package.json @@ -0,0 +1,56 @@ +{ + "name": "@openzeppelin/transaction-form-adapter-stellar", + "version": "0.0.1", + "private": true, + "description": "Stellar Adapter for Transaction Form Builder", + "keywords": [ + "openzeppelin", + "transaction", + "form", + "builder", + "adapter", + "stellar" + ], + "author": "Aleksandr Pasevin ", + "homepage": "https://github.com/OpenZeppelin/transaction-form-builder#readme", + "license": "MIT", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "files": [ + "dist", + "src" + ], + "publishConfig": { + "access": "public", + "provenance": true + }, + "repository": { + "type": "git", + "url": "https://github.com/OpenZeppelin/transaction-form-builder.git", + "directory": "packages/adapter-stellar" + }, + "bugs": { + "url": "https://github.com/OpenZeppelin/transaction-form-builder/issues" + }, + "scripts": { + "build": "tsc -b", + "clean": "rm -rf dist tsconfig.tsbuildinfo", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "lint:adapters": "node ../../lint-adapters.cjs", + "prepublishOnly": "pnpm build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@openzeppelin/transaction-form-types": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.8.2", + "eslint": "^9.22.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } +} diff --git a/packages/adapter-stellar/src/adapter.ts b/packages/adapter-stellar/src/adapter.ts new file mode 100644 index 00000000..cbebf404 --- /dev/null +++ b/packages/adapter-stellar/src/adapter.ts @@ -0,0 +1,175 @@ +import type { + Connector, + ContractAdapter, + ContractFunction, + ContractSchema, + ExecutionConfig, + ExecutionMethodDetail, + FieldType, + FormFieldType, + FunctionParameter, + StellarNetworkConfig, +} from '@openzeppelin/transaction-form-types'; +import { isStellarNetworkConfig } from '@openzeppelin/transaction-form-types'; + +// Import functions from modules +import { + getStellarSupportedExecutionMethods, + validateStellarExecutionConfig, +} from './configuration/execution'; +import { getStellarExplorerAddressUrl, getStellarExplorerTxUrl } from './configuration/explorer'; + +import { loadStellarContract } from './definition'; +import { + generateStellarDefaultField, + getStellarCompatibleFieldTypes, + mapStellarParameterTypeToFieldType, +} from './mapping'; +import { isStellarViewFunction, queryStellarViewFunction } from './query'; +import { formatStellarTransactionData, signAndBroadcastStellarTransaction } from './transaction'; +import { formatStellarFunctionResult } from './transform'; +import { isValidAddress as isStellarValidAddress } from './utils'; +import { + connectStellarWallet, + disconnectStellarWallet, + getStellarAvailableConnectors, + getStellarWalletConnectionStatus, + supportsStellarWalletConnection, + // onStellarWalletConnectionChange, // Placeholder if needed later +} from './wallet'; + +/** + * Stellar-specific adapter implementation using explicit method delegation. + * + * NOTE: Contains placeholder implementations for most functionalities. + */ +export class StellarAdapter implements ContractAdapter { + readonly networkConfig: StellarNetworkConfig; + + constructor(networkConfig: StellarNetworkConfig) { + if (!isStellarNetworkConfig(networkConfig)) { + throw new Error('StellarAdapter requires a valid Stellar network configuration.'); + } + this.networkConfig = networkConfig; + console.log(`StellarAdapter initialized for network: ${this.networkConfig.name}`); + } + + // --- Contract Loading --- // + async loadContract(source: string): Promise { + return loadStellarContract(source); + } + + getWritableFunctions(contractSchema: ContractSchema): ContractSchema['functions'] { + return contractSchema.functions.filter((fn: ContractFunction) => fn.modifiesState); + } + + // --- Type Mapping & Field Generation --- // + mapParameterTypeToFieldType(parameterType: string): FieldType { + return mapStellarParameterTypeToFieldType(parameterType); + } + getCompatibleFieldTypes(parameterType: string): FieldType[] { + return getStellarCompatibleFieldTypes(parameterType); + } + generateDefaultField( + parameter: FunctionParameter + ): FormFieldType { + return generateStellarDefaultField(parameter); + } + + // --- Transaction Formatting & Execution --- // + formatTransactionData( + contractSchema: ContractSchema, + functionId: string, + submittedInputs: Record, + allFieldsConfig: FormFieldType[] + ): unknown { + return formatStellarTransactionData( + contractSchema, + functionId, + submittedInputs, + allFieldsConfig + ); + } + async signAndBroadcast(transactionData: unknown): Promise<{ txHash: string }> { + return signAndBroadcastStellarTransaction(transactionData); + } + + // NOTE: waitForTransactionConfirmation? is optional in the interface. + // Since the imported function is currently undefined, we omit the method here. + // If implemented in ./transaction/sender.ts later, add the method back: + // async waitForTransactionConfirmation?(...) { ... } + + // --- View Function Querying --- // + isViewFunction(functionDetails: ContractFunction): boolean { + return isStellarViewFunction(functionDetails); + } + + // Implement queryViewFunction with the correct signature from ContractAdapter + async queryViewFunction( + contractAddress: string, + functionId: string, + params: unknown[] = [], + contractSchema?: ContractSchema + ): Promise { + return queryStellarViewFunction( + contractAddress, + functionId, + this.networkConfig, + params, + contractSchema + ); + } + + formatFunctionResult(decodedValue: unknown, functionDetails: ContractFunction): string { + return formatStellarFunctionResult(decodedValue, functionDetails); + } + + // --- Wallet Interaction --- // + supportsWalletConnection(): boolean { + return supportsStellarWalletConnection(); + } + async getAvailableConnectors(): Promise { + return getStellarAvailableConnectors(); + } + async connectWallet( + connectorId: string + ): Promise<{ connected: boolean; address?: string; error?: string }> { + return connectStellarWallet(connectorId); + } + async disconnectWallet(): Promise<{ disconnected: boolean; error?: string }> { + return disconnectStellarWallet(); + } + getWalletConnectionStatus(): { isConnected: boolean; address?: string; chainId?: string } { + return getStellarWalletConnectionStatus(); + } + // Optional: onWalletConnectionChange(...) implementation would go here + + // --- Configuration & Metadata --- // + async getSupportedExecutionMethods(): Promise { + return getStellarSupportedExecutionMethods(); + } + async validateExecutionConfig(config: ExecutionConfig): Promise { + return validateStellarExecutionConfig(config); + } + + // Implement getExplorerUrl with the correct signature from ContractAdapter + getExplorerUrl(address: string): string | null { + return getStellarExplorerAddressUrl(address, this.networkConfig); + } + + // Implement getExplorerTxUrl with the correct signature from ContractAdapter + getExplorerTxUrl?(txHash: string): string | null { + if (getStellarExplorerTxUrl) { + return getStellarExplorerTxUrl(txHash, this.networkConfig); + } + return null; + } + + // --- Validation --- // + isValidAddress(address: string): boolean { + return isStellarValidAddress(address); + } +} + +// Also export as default to ensure compatibility with various import styles +export default StellarAdapter; diff --git a/packages/core/src/adapters/stellar/config.ts b/packages/adapter-stellar/src/config.ts similarity index 79% rename from packages/core/src/adapters/stellar/config.ts rename to packages/adapter-stellar/src/config.ts index e5ff371d..0f2be68b 100644 --- a/packages/core/src/adapters/stellar/config.ts +++ b/packages/adapter-stellar/src/config.ts @@ -1,5 +1,3 @@ -import type { AdapterConfig } from '../../core/types/AdapterTypes'; - /** * Configuration for the Stellar adapter * @@ -7,7 +5,7 @@ import type { AdapterConfig } from '../../core/types/AdapterTypes'; * when generating exported projects. It follows the AdapterConfig * interface to provide a structured approach to dependency management. */ -export const stellarAdapterConfig: AdapterConfig = { +export const stellarAdapterConfig = { /** * Dependencies required by the Stellar adapter * These will be included in exported projects that use this adapter @@ -23,7 +21,7 @@ export const stellarAdapterConfig: AdapterConfig = { // Stellar wallet integration '@stellar/design-system': '^0.5.1', - '@stellar/wallet-sdk': '^0.6.0', + '@stellar/wallet-sdk': '^0.11.2', // Utilities for Stellar development 'bignumber.js': '^9.1.1', @@ -33,10 +31,10 @@ export const stellarAdapterConfig: AdapterConfig = { // Development dependencies dev: { // Testing utilities for Stellar - '@stellar/typescript-wallet-sdk': '^0.2.0', + '@stellar/typescript-wallet-sdk': '^1.9.0', // Soroban contract SDK for Stellar - '@stellar/soroban-sdk': '^0.7.0', + // '@stellar/soroban-sdk': '^0.7.0', }, }, }; diff --git a/packages/adapter-stellar/src/configuration/execution.ts b/packages/adapter-stellar/src/configuration/execution.ts new file mode 100644 index 00000000..02c2fb6a --- /dev/null +++ b/packages/adapter-stellar/src/configuration/execution.ts @@ -0,0 +1,46 @@ +import type { ExecutionConfig, ExecutionMethodDetail } from '@openzeppelin/transaction-form-types'; + +import { isValidAddress } from '../utils'; + +/** + * Get supported execution methods for Stellar. + * TODO: Implement actual supported methods for Stellar. + */ +export function getStellarSupportedExecutionMethods(): Promise { + // Placeholder: Assume only EOA is supported for now + console.warn('StellarAdapter.getSupportedExecutionMethods is using placeholder implementation.'); + return Promise.resolve([ + { + type: 'eoa', + name: 'Stellar Account', + description: 'Execute using a standard Stellar account address.', + }, + ]); +} + +/** + * Validate execution config for Stellar. + * TODO: Implement actual validation logic for Stellar execution configs. + */ +export function validateStellarExecutionConfig(config: ExecutionConfig): Promise { + // Placeholder: Basic validation + console.warn('StellarAdapter.validateExecutionConfig is using placeholder implementation.'); + if (config.method === 'eoa') { + if (!config.allowAny && !config.specificAddress) { + return Promise.resolve('Specific Stellar account address is required.'); + } + if ( + !config.allowAny && + config.specificAddress && + !isValidAddress(config.specificAddress) // Assuming isValidAddress is moved to utils + ) { + return Promise.resolve('Invalid account address format for Stellar.'); + } + return Promise.resolve(true); + } else { + // For now, consider other methods unsupported by this placeholder + return Promise.resolve( + `Execution method '${config.method}' is not yet supported by this adapter implementation.` + ); + } +} diff --git a/packages/adapter-stellar/src/configuration/explorer.ts b/packages/adapter-stellar/src/configuration/explorer.ts new file mode 100644 index 00000000..233a33d4 --- /dev/null +++ b/packages/adapter-stellar/src/configuration/explorer.ts @@ -0,0 +1,41 @@ +import { NetworkConfig } from '@openzeppelin/transaction-form-types'; + +/** + * Gets a blockchain explorer URL for an address on Stellar. + * Uses the explorerUrl from the network configuration. + * + * @param address The address to get the explorer URL for + * @param networkConfig The network configuration object. + * @returns A URL to view the address on the configured Stellar explorer, or null. + */ +export function getStellarExplorerAddressUrl( + address: string, + networkConfig: NetworkConfig +): string | null { + if (!address || !networkConfig.explorerUrl) { + return null; + } + // Construct the URL, assuming a standard /account/ path for Stellar explorers + const baseUrl = networkConfig.explorerUrl.replace(/\/+$/, ''); + return `${baseUrl}/account/${address}`; +} + +/** + * Gets a blockchain explorer URL for a transaction on Stellar. + * Uses the explorerUrl from the network configuration. + * + * @param txHash - The hash of the transaction to get the explorer URL for + * @param networkConfig The network configuration object. + * @returns A URL to view the transaction on the configured Stellar explorer, or null. + */ +export function getStellarExplorerTxUrl( + txHash: string, + networkConfig: NetworkConfig +): string | null { + if (!txHash || !networkConfig.explorerUrl) { + return null; + } + // Construct the URL, assuming a standard /tx/ path for Stellar explorers + const baseUrl = networkConfig.explorerUrl.replace(/\/+$/, ''); + return `${baseUrl}/tx/${txHash}`; +} diff --git a/packages/adapter-stellar/src/configuration/index.ts b/packages/adapter-stellar/src/configuration/index.ts new file mode 100644 index 00000000..3da7c139 --- /dev/null +++ b/packages/adapter-stellar/src/configuration/index.ts @@ -0,0 +1,4 @@ +// Barrel file + +export * from './execution'; +export * from './explorer'; diff --git a/packages/adapter-stellar/src/definition/index.ts b/packages/adapter-stellar/src/definition/index.ts new file mode 100644 index 00000000..f5fbb0b9 --- /dev/null +++ b/packages/adapter-stellar/src/definition/index.ts @@ -0,0 +1,3 @@ +// Barrel file + +export * from './loader'; diff --git a/packages/adapter-stellar/src/definition/loader.ts b/packages/adapter-stellar/src/definition/loader.ts new file mode 100644 index 00000000..27746040 --- /dev/null +++ b/packages/adapter-stellar/src/definition/loader.ts @@ -0,0 +1,18 @@ +import type { ContractSchema } from '@openzeppelin/transaction-form-types'; + +/** + * Load a contract from a file or address + * + * TODO: Implement actual Stellar contract loading logic in future phases + */ +export function loadStellarContract(source: string): Promise { + console.log(`[PLACEHOLDER] Loading Stellar contract from: ${source}`); + + // Return a minimal placeholder contract schema + return Promise.resolve({ + ecosystem: 'stellar', + name: 'Placeholder Contract', + address: source, + functions: [], + }); +} diff --git a/packages/adapter-stellar/src/definition/transformer.ts b/packages/adapter-stellar/src/definition/transformer.ts new file mode 100644 index 00000000..10051c76 --- /dev/null +++ b/packages/adapter-stellar/src/definition/transformer.ts @@ -0,0 +1 @@ +// Placeholder diff --git a/packages/adapter-stellar/src/index.ts b/packages/adapter-stellar/src/index.ts new file mode 100644 index 00000000..f12e8060 --- /dev/null +++ b/packages/adapter-stellar/src/index.ts @@ -0,0 +1,14 @@ +// Re-export the main adapter class +export { StellarAdapter } from './adapter'; + +// Optionally re-export types if needed +// export * from './types'; // No types.ts in Stellar adapter yet + +export { + stellarNetworks, + stellarMainnetNetworks, + stellarTestnetNetworks, + // Individual networks + stellarPublic, + stellarTestnet, +} from './networks'; diff --git a/packages/adapter-stellar/src/mapping/constants.ts b/packages/adapter-stellar/src/mapping/constants.ts new file mode 100644 index 00000000..10051c76 --- /dev/null +++ b/packages/adapter-stellar/src/mapping/constants.ts @@ -0,0 +1 @@ +// Placeholder diff --git a/packages/adapter-stellar/src/mapping/field-generator.ts b/packages/adapter-stellar/src/mapping/field-generator.ts new file mode 100644 index 00000000..0535188b --- /dev/null +++ b/packages/adapter-stellar/src/mapping/field-generator.ts @@ -0,0 +1,30 @@ +import type { + FieldType, + FieldValue, + FormFieldType, + FunctionParameter, +} from '@openzeppelin/transaction-form-types'; + +/** + * Generate default field configuration for a Stellar function parameter + * + * TODO: Implement proper Stellar field generation in future phases + */ +export function generateStellarDefaultField( + parameter: FunctionParameter +): FormFieldType { + // Default to text fields for now as a placeholder + const fieldType = 'text' as T; + + return { + id: Math.random().toString(36).substring(2, 11), + name: parameter.name || 'placeholder', + label: parameter.displayName || parameter.name || 'Placeholder Field', + type: fieldType, + placeholder: 'Placeholder - Stellar adapter not fully implemented yet', + helperText: 'Stellar adapter is not fully implemented yet', + defaultValue: '' as FieldValue, + validation: { required: true }, + width: 'full', + }; +} diff --git a/packages/adapter-stellar/src/mapping/index.ts b/packages/adapter-stellar/src/mapping/index.ts new file mode 100644 index 00000000..8269c0f2 --- /dev/null +++ b/packages/adapter-stellar/src/mapping/index.ts @@ -0,0 +1,4 @@ +// Barrel file + +export * from './type-mapper'; +export * from './field-generator'; diff --git a/packages/adapter-stellar/src/mapping/type-mapper.ts b/packages/adapter-stellar/src/mapping/type-mapper.ts new file mode 100644 index 00000000..6dcd6977 --- /dev/null +++ b/packages/adapter-stellar/src/mapping/type-mapper.ts @@ -0,0 +1,36 @@ +import type { FieldType } from '@openzeppelin/transaction-form-types'; + +/** + * Map a Stellar-specific parameter type to a form field type + * + * TODO: Implement proper Stellar type mapping in future phases + */ +export function mapStellarParameterTypeToFieldType(_parameterType: string): FieldType { + // Placeholder implementation that defaults everything to text fields + return 'text'; +} + +/** + * Get field types compatible with a specific parameter type + * @param _parameterType The blockchain parameter type + * @returns Array of compatible field types + * + * TODO: Implement proper Stellar field type compatibility in future phases + */ +export function getStellarCompatibleFieldTypes(_parameterType: string): FieldType[] { + // Placeholder implementation that returns all field types + return [ + 'text', + 'number', + 'checkbox', + 'radio', + 'select', + 'textarea', + 'date', + 'email', + 'password', + 'blockchain-address', + 'amount', + 'hidden', + ]; +} diff --git a/packages/adapter-stellar/src/networks/index.ts b/packages/adapter-stellar/src/networks/index.ts new file mode 100644 index 00000000..dff72832 --- /dev/null +++ b/packages/adapter-stellar/src/networks/index.ts @@ -0,0 +1,19 @@ +import { StellarNetworkConfig } from '@openzeppelin/transaction-form-types'; + +import { stellarPublic } from './mainnet'; +import { stellarTestnet } from './testnet'; + +// All mainnet networks +export const stellarMainnetNetworks: StellarNetworkConfig[] = [stellarPublic]; + +// All testnet networks +export const stellarTestnetNetworks: StellarNetworkConfig[] = [stellarTestnet]; + +// All Stellar networks +export const stellarNetworks: StellarNetworkConfig[] = [ + ...stellarMainnetNetworks, + ...stellarTestnetNetworks, +]; + +// Export individual networks as well +export { stellarPublic, stellarTestnet }; diff --git a/packages/adapter-stellar/src/networks/mainnet.ts b/packages/adapter-stellar/src/networks/mainnet.ts new file mode 100644 index 00000000..71485d44 --- /dev/null +++ b/packages/adapter-stellar/src/networks/mainnet.ts @@ -0,0 +1,16 @@ +import { StellarNetworkConfig } from '@openzeppelin/transaction-form-types'; + +// Placeholder for Stellar Public Network (Mainnet) +export const stellarPublic: StellarNetworkConfig = { + id: 'stellar-public', + exportConstName: 'stellarPublic', + name: 'Stellar', + ecosystem: 'stellar', + network: 'stellar', + type: 'mainnet', + isTestnet: false, + horizonUrl: 'https://horizon.stellar.org', + networkPassphrase: 'Public Global Stellar Network ; September 2015', + explorerUrl: 'https://stellar.expert/explorer/public', + icon: 'stellar', +}; diff --git a/packages/adapter-stellar/src/networks/testnet.ts b/packages/adapter-stellar/src/networks/testnet.ts new file mode 100644 index 00000000..fab64b4c --- /dev/null +++ b/packages/adapter-stellar/src/networks/testnet.ts @@ -0,0 +1,16 @@ +import { StellarNetworkConfig } from '@openzeppelin/transaction-form-types'; + +// Placeholder for Stellar Testnet +export const stellarTestnet: StellarNetworkConfig = { + id: 'stellar-testnet', + exportConstName: 'stellarTestnet', + name: 'Stellar Testnet', + ecosystem: 'stellar', + network: 'stellar', + type: 'testnet', + isTestnet: true, + horizonUrl: 'https://horizon-testnet.stellar.org', + networkPassphrase: 'Test SDF Network ; September 2015', + explorerUrl: 'https://stellar.expert/explorer/testnet', + icon: 'stellar', +}; diff --git a/packages/adapter-stellar/src/query/handler.ts b/packages/adapter-stellar/src/query/handler.ts new file mode 100644 index 00000000..a2dd6bcb --- /dev/null +++ b/packages/adapter-stellar/src/query/handler.ts @@ -0,0 +1,30 @@ +import type { + ContractSchema, + NetworkConfig, + StellarNetworkConfig, +} from '@openzeppelin/transaction-form-types'; + +/** + * Queries a view function on a contract + */ +export async function queryStellarViewFunction( + _contractAddress: string, + _functionId: string, + networkConfig: NetworkConfig, + _params: unknown[] = [], + _contractSchema?: ContractSchema +): Promise { + if (networkConfig.ecosystem !== 'stellar') { + throw new Error('Invalid network configuration for Stellar query.'); + } + const stellarConfig = networkConfig as StellarNetworkConfig; + + // TODO: Implement Stellar contract query functionality using: + // - stellarConfig.horizonUrl + // - _contractAddress, _functionId, _params, _contractSchema + // - Potentially use stellar-sdk + console.warn( + `queryStellarViewFunction not implemented for network: ${stellarConfig.name} (Horizon: ${stellarConfig.horizonUrl})` + ); + throw new Error('Stellar view function queries not yet implemented'); +} diff --git a/packages/adapter-stellar/src/query/index.ts b/packages/adapter-stellar/src/query/index.ts new file mode 100644 index 00000000..2f70e21f --- /dev/null +++ b/packages/adapter-stellar/src/query/index.ts @@ -0,0 +1,4 @@ +// Barrel file + +export * from './handler'; +export * from './view-checker'; diff --git a/packages/adapter-stellar/src/query/view-checker.ts b/packages/adapter-stellar/src/query/view-checker.ts new file mode 100644 index 00000000..55b725e3 --- /dev/null +++ b/packages/adapter-stellar/src/query/view-checker.ts @@ -0,0 +1,9 @@ +import type { ContractFunction } from '@openzeppelin/transaction-form-types'; + +/** + * Determines if a function is a view/pure function (read-only) + */ +export function isStellarViewFunction(_functionDetails: ContractFunction): boolean { + // TODO: Implement properly for Stellar Soroban contracts + return false; // Temporary placeholder +} diff --git a/packages/adapter-stellar/src/transaction/formatter.ts b/packages/adapter-stellar/src/transaction/formatter.ts new file mode 100644 index 00000000..e51a9d30 --- /dev/null +++ b/packages/adapter-stellar/src/transaction/formatter.ts @@ -0,0 +1,15 @@ +import type { ContractSchema, FormFieldType } from '@openzeppelin/transaction-form-types'; + +/** + * @inheritdoc + */ +export function formatStellarTransactionData( + _contractSchema: ContractSchema, + _functionId: string, + _submittedInputs: Record, + _allFieldsConfig: FormFieldType[] +): unknown { + console.warn('StellarAdapter.formatTransactionData not implemented, returning placeholder data.'); + // Placeholder implementation + return { data: 'stellar_formatted_placeholder' }; +} diff --git a/packages/adapter-stellar/src/transaction/index.ts b/packages/adapter-stellar/src/transaction/index.ts new file mode 100644 index 00000000..c074c0b8 --- /dev/null +++ b/packages/adapter-stellar/src/transaction/index.ts @@ -0,0 +1,4 @@ +// Barrel file + +export * from './formatter'; +export * from './sender'; diff --git a/packages/adapter-stellar/src/transaction/sender.ts b/packages/adapter-stellar/src/transaction/sender.ts new file mode 100644 index 00000000..b889610d --- /dev/null +++ b/packages/adapter-stellar/src/transaction/sender.ts @@ -0,0 +1,28 @@ +/** + * Sign and broadcast a transaction + * + * TODO: Implement proper Stellar transaction signing in future phases + */ +export function signAndBroadcastStellarTransaction( + _transactionData: unknown +): Promise<{ txHash: string }> { + return Promise.resolve({ txHash: 'stellar_placeholder_tx' }); +} + +/** + * (Optional) Waits for a transaction to be confirmed on the blockchain. + * + * @param _txHash - The hash of the transaction to wait for. + * @returns A promise resolving to the final status and receipt/error. + */ +export const waitForStellarTransactionConfirmation: undefined = undefined; +// Optional methods can be implemented later if needed, for now export undefined +// export function waitForStellarTransactionConfirmation(txHash: string): Promise<{ +// status: 'success' | 'error'; +// receipt?: unknown; +// error?: Error; +// }> { +// // Placeholder logic +// console.warn('waitForStellarTransactionConfirmation placeholder called'); +// return Promise.resolve({ status: 'success' as const }); +// } diff --git a/packages/adapter-stellar/src/transform/index.ts b/packages/adapter-stellar/src/transform/index.ts new file mode 100644 index 00000000..639a0195 --- /dev/null +++ b/packages/adapter-stellar/src/transform/index.ts @@ -0,0 +1,4 @@ +// Barrel file + +export * from './input-parser'; +export * from './output-formatter'; diff --git a/packages/adapter-stellar/src/transform/input-parser.ts b/packages/adapter-stellar/src/transform/input-parser.ts new file mode 100644 index 00000000..c60dbf7d --- /dev/null +++ b/packages/adapter-stellar/src/transform/input-parser.ts @@ -0,0 +1,11 @@ +// TODO: Implement Stellar-specific input parsing logic if needed. + +// Placeholder function - adapt as needed +export function parseStellarInput( + _fieldType: string, + _value: unknown, + _parameterType: string +): unknown { + console.warn('StellarAdapter.parseStellarInput not implemented, returning raw value.'); + return _value; // Placeholder: return value as is +} diff --git a/packages/adapter-stellar/src/transform/output-formatter.ts b/packages/adapter-stellar/src/transform/output-formatter.ts new file mode 100644 index 00000000..3b1a5e6a --- /dev/null +++ b/packages/adapter-stellar/src/transform/output-formatter.ts @@ -0,0 +1,17 @@ +import type { ContractFunction } from '@openzeppelin/transaction-form-types'; + +/** + * Formats a function result for display + */ +export function formatStellarFunctionResult( + result: unknown, + _functionDetails: ContractFunction +): string { + // TODO: Implement Stellar-specific result formatting + if (result === null || result === undefined) { + return 'No data'; + } + + // Placeholder: Return simple string representation + return String(result); +} diff --git a/packages/adapter-stellar/src/utils/formatting.ts b/packages/adapter-stellar/src/utils/formatting.ts new file mode 100644 index 00000000..10051c76 --- /dev/null +++ b/packages/adapter-stellar/src/utils/formatting.ts @@ -0,0 +1 @@ +// Placeholder diff --git a/packages/adapter-stellar/src/utils/index.ts b/packages/adapter-stellar/src/utils/index.ts new file mode 100644 index 00000000..f7cf4988 --- /dev/null +++ b/packages/adapter-stellar/src/utils/index.ts @@ -0,0 +1,4 @@ +// Barrel file + +export * from './validator'; +// Add other utils exports here if needed diff --git a/packages/adapter-stellar/src/utils/validator.ts b/packages/adapter-stellar/src/utils/validator.ts new file mode 100644 index 00000000..92eae2d0 --- /dev/null +++ b/packages/adapter-stellar/src/utils/validator.ts @@ -0,0 +1,10 @@ +/** + * Validate a Stellar blockchain address + * @param address The address to validate + * @returns Whether the address is a valid Stellar address + */ +export function isValidAddress(address: string): boolean { + // Basic check for Stellar addresses (starts with G and is 56 chars long) + // TODO: Use a proper Stellar SDK for validation when focusing on that chain + return /^G[A-Z0-9]{55}$/.test(address); +} diff --git a/packages/adapter-stellar/src/wallet/connection.ts b/packages/adapter-stellar/src/wallet/connection.ts new file mode 100644 index 00000000..0eb4065e --- /dev/null +++ b/packages/adapter-stellar/src/wallet/connection.ts @@ -0,0 +1,38 @@ +import type { Connector } from '@openzeppelin/transaction-form-types'; + +/** + * Indicates if this adapter supports wallet connection + * @returns Whether wallet connection is supported by this adapter + */ +export function supportsStellarWalletConnection(): boolean { + return false; // Stellar wallet connection not yet implemented +} + +export async function getStellarAvailableConnectors(): Promise { + return []; +} + +export async function connectStellarWallet( + _connectorId: string +): Promise<{ connected: boolean; address?: string; error?: string }> { + return { connected: false, error: 'Stellar adapter does not support wallet connection.' }; +} + +export async function disconnectStellarWallet(): Promise<{ + disconnected: boolean; + error?: string; +}> { + return { disconnected: false, error: 'Stellar adapter does not support wallet connection.' }; +} + +/** + * @inheritdoc + */ +export function getStellarWalletConnectionStatus(): { + isConnected: boolean; + address?: string; + chainId?: string; +} { + // Stub implementation: Always return disconnected status + return { isConnected: false }; +} diff --git a/packages/adapter-stellar/src/wallet/index.ts b/packages/adapter-stellar/src/wallet/index.ts new file mode 100644 index 00000000..a02aa2d7 --- /dev/null +++ b/packages/adapter-stellar/src/wallet/index.ts @@ -0,0 +1,4 @@ +// Barrel file + +export * from './connection'; +// Add other wallet exports here if needed (e.g., implementations) diff --git a/packages/adapter-stellar/tsconfig.json b/packages/adapter-stellar/tsconfig.json new file mode 100644 index 00000000..ea88c883 --- /dev/null +++ b/packages/adapter-stellar/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "tsBuildInfoFile": "./tsconfig.tsbuildinfo" + // Add specific compiler options for Stellar if needed + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/packages/core/README.md b/packages/core/README.md index b52abfe4..893f38f7 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,10 +1,10 @@ # Transaction Form Builder Core -The main application for the Transaction Form Builder monorepo. This package contains the form builder UI, adapters for different blockchain ecosystems, and core functionality. +The main application for the Transaction Form Builder monorepo. This package contains the form builder UI and core functionality. ## Structure -``` +```text core/ ├── public/ # Static assets ├── src/ @@ -13,15 +13,11 @@ core/ │ │ ├── Common/ # Shared components across features │ │ └── FormBuilder/ # Form builder components │ ├── core/ # Chain-agnostic core functionality -│ │ ├── types/ # Type definitions +│ │ ├── types/ # Local type definitions (shared types in @openzeppelin/transaction-form-types) │ │ ├── utils/ # Utility functions │ │ ├── hooks/ # Shared hooks -│ │ └── factories/ # Schema factories -│ ├── adapters/ # Chain-specific implementations -│ │ ├── evm/ # Ethereum Virtual Machine adapter -│ │ ├── midnight/ # Midnight blockchain adapter -│ │ ├── solana/ # Solana blockchain adapter -│ │ ├── stellar/ # Stellar blockchain adapter +│ │ ├── factories/ # Schema factories +│ │ └── ecosystemManager.ts # Central management of ecosystems, adapters, and network configs │ ├── export/ # Export system │ │ ├── generators/ # Form code generators │ │ ├── codeTemplates/ # Code template files for generation @@ -36,7 +32,6 @@ core/ │ │ ├── form-builder/ # Stories for form builder components │ │ └── ui/ # Stories for UI components │ ├── test/ # Test setup and utilities -│ ├── mocks/ # Mock data for development and testing │ ├── App.tsx # Main application component │ ├── main.tsx # Application entry point │ └── index.css # Imports centralized styling from styles package @@ -46,6 +41,15 @@ core/ └── ... # Other configuration files ``` +## Dependencies + +This package relies on: + +- **@openzeppelin/transaction-form-types**: Shared type definitions for contracts, adapters, and forms +- **@openzeppelin/transaction-form-renderer**: Form rendering components +- **@openzeppelin/transaction-form-styles**: Centralized styling system +- **@openzeppelin/transaction-form-adapter-{chain}**: Specific blockchain adapter packages (e.g., `-evm`, `-solana`) + ## Styling This package uses the centralized styling system from the `packages/styles` package: @@ -57,6 +61,16 @@ This package uses the centralized styling system from the `packages/styles` pack For more details on the styling system, see the [Styles README](../styles/README.md). +## Type System + +The core package uses type definitions from the `@openzeppelin/transaction-form-types` package, which serves as the single source of truth for types used across the Transaction Form Builder ecosystem. These include: + +- **Contract Types**: Definitions for blockchain contracts and their schemas +- **Adapter Types**: Interfaces for chain-specific adapters +- **Form Types**: Definitions for form fields, layouts, and validation + +By using the shared types package, we ensure consistency across all packages and eliminate type duplication. + ## Development ### Running the Core Application @@ -93,11 +107,11 @@ pnpm test The core package uses an adapter pattern to support multiple blockchain ecosystems: -- **Core**: Chain-agnostic components, types, and utilities -- **Adapters**: Chain-specific implementations that conform to a common interface -- **UI Components**: React components that use adapters to interact with different blockchains -- **Styling System**: Centralized CSS variables and styling approach from the styles package +- **Core**: Chain-agnostic components, types, services, and utilities. Manages ecosystem details, network configurations, and adapter instantiation via `src/core/ecosystemManager.ts`. The `ecosystemManager.getAdapter()` function is asynchronous, and UI components typically obtain configured adapter instances either through the `useConfiguredAdapter` hook for direct use, or via props from higher-level state management hooks (like `useFormBuilderState`) which handle the asynchronous loading. +- **Adapters (`@openzeppelin/transaction-form-adapter-*`)**: Separate packages containing chain-specific implementations conforming to the `ContractAdapter` interface (defined in `@openzeppelin/transaction-form-types`). Adapters are now instantiated with a specific `NetworkConfig` making them network-aware. +- **UI Components**: React components within this package that utilize the centrally managed, network-configured adapters to interact with different blockchains. +- **Styling System**: Centralized CSS variables and styling approach from the `@openzeppelin/transaction-form-styles` package. -This architecture allows for easy extension to support additional blockchain ecosystems without modifying the core application logic. +This architecture allows for easy extension to support additional blockchain ecosystems by creating new adapter packages and registering them in `ecosystemManager.ts` without modifying core application logic significantly. -For more detailed documentation about the adapter pattern and implementation guidelines, see the [Adapter System documentation](./src/adapters/README.md). +For more detailed documentation about the adapter pattern, see the main project [README.md](../../README.md#adding-new-adapters). diff --git a/packages/core/package.json b/packages/core/package.json index 0d4c4148..64c8353f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -29,7 +29,13 @@ }, "dependencies": { "@hookform/resolvers": "^4.1.3", + "@openzeppelin/transaction-form-adapter-evm": "workspace:*", + "@openzeppelin/transaction-form-adapter-midnight": "workspace:*", + "@openzeppelin/transaction-form-adapter-solana": "workspace:*", + "@openzeppelin/transaction-form-adapter-stellar": "workspace:*", "@openzeppelin/transaction-form-renderer": "workspace:*", + "@openzeppelin/transaction-form-types": "workspace:*", + "@radix-ui/react-accordion": "^1.2.4", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", @@ -47,7 +53,7 @@ "ethers": "6", "jszip": "^3.10.1", "lodash": "^4.17.21", - "lucide-react": "^0.479.0", + "lucide-react": "^0.503.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", @@ -55,6 +61,8 @@ "tailwind-merge": "^3.0.2", "tailwindcss-animate": "^1.0.7", "uuid": "^11.1.0", + "viem": "^2.28.0", + "wagmi": "^2.15.0", "zod": "^3.24.2" }, "devDependencies": { diff --git a/packages/core/src/App.tsx b/packages/core/src/App.tsx index aa14c7f0..e5b3d3a4 100644 --- a/packages/core/src/App.tsx +++ b/packages/core/src/App.tsx @@ -1,4 +1,5 @@ import { TransactionFormBuilder } from './components/FormBuilder/TransactionFormBuilder'; +import { AdapterProvider } from './core/hooks'; function App() { return ( @@ -29,7 +30,9 @@ function App() {
- + + +