initial commit
This commit is contained in:
2
interfaces/websocket/client/.gitignore
vendored
Normal file
2
interfaces/websocket/client/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
/dist
|
94
interfaces/websocket/client/ARCHITECTURE.md
Normal file
94
interfaces/websocket/client/ARCHITECTURE.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# `client_ws` Architecture
|
||||
|
||||
This document details the internal architecture of the `client_ws` crate, focusing on its cross-platform design, internal modules, and the mechanics of its authentication process.
|
||||
|
||||
## 1. Core Design Principles
|
||||
|
||||
The `client_ws` is built on the following principles:
|
||||
|
||||
- **Platform Abstraction**: The core client logic is written in a platform-agnostic way. Platform-specific details (like the WebSocket implementation) are abstracted behind a common interface.
|
||||
- **Modularity**: The crate is divided into logical modules, with a clear separation of concerns between the main client logic, authentication procedures, and cryptographic utilities.
|
||||
- **Asynchronous Operations**: All network I/O is asynchronous, using `async/await` to ensure the client is non-blocking and efficient.
|
||||
- **Fluent Configuration**: A builder pattern (`CircleWsClientBuilder`) is used for clear and flexible client construction.
|
||||
- **Self-Managing Clients**: Each `CircleWsClient` handles its own lifecycle including connection, authentication, keep-alive, and reconnection logic internally.
|
||||
|
||||
## 2. Cross-Platform Implementation
|
||||
|
||||
To support both native and WebAssembly (WASM) environments, `client_ws` uses conditional compilation (`#[cfg]`) to provide different implementations for the underlying WebSocket transport.
|
||||
|
||||
- **Native (`target_arch != "wasm32"`)**: The `tokio-tungstenite` crate is used for robust, `tokio`-based WebSocket communication.
|
||||
- **WebAssembly (`target_arch = "wasm32"`)**: The `gloo-net` crate provides bindings to the browser's native `WebSocket` API.
|
||||
|
||||
This approach allows the `CircleWsClient` to expose a single, unified API while the underlying implementation details are handled transparently at compile time.
|
||||
|
||||
## 3. Module Structure
|
||||
|
||||
The `client_ws` crate is organized into the following key modules:
|
||||
|
||||
- **`lib.rs`**: The main module that defines the `CircleWsClientBuilder` and `CircleWsClient` structs and their public APIs. It orchestrates the entire communication flow.
|
||||
- **`auth/`**: This module contains all the logic related to the `secp256k1` authentication flow.
|
||||
- **`types.rs`**: Defines the core data structures used in authentication, such as `AuthError` and `AuthCredentials`.
|
||||
- **`crypto_utils.rs`**: A self-contained utility module for handling all `secp256k1` cryptographic operations, including key generation, public key derivation, and message signing.
|
||||
|
||||
## 4. Self-Managing Client Architecture
|
||||
|
||||
Each `CircleWsClient` is designed to be completely self-managing, handling its entire lifecycle internally. This includes:
|
||||
|
||||
- **Connection Management**: Establishing and maintaining WebSocket connections
|
||||
- **Authentication**: Automatic secp256k1 authentication flow when private keys are provided
|
||||
- **Keep-Alive**: Periodic health checks to ensure connection stability
|
||||
- **Reconnection**: Automatic reconnection with exponential backoff on connection failures
|
||||
- **Connection Status Tracking**: Internal state management for connection health
|
||||
|
||||
### Connection Flow
|
||||
|
||||
The `connect()` method orchestrates the complete connection and authentication process:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as User Code
|
||||
participant Builder as CircleWsClientBuilder
|
||||
participant Client as CircleWsClient
|
||||
participant CryptoUtils as auth::crypto_utils
|
||||
participant WsActor as Server WebSocket Actor
|
||||
|
||||
User->>+Builder: new(url)
|
||||
User->>+Builder: with_keypair(private_key)
|
||||
User->>+Builder: build()
|
||||
Builder-->>-User: client
|
||||
|
||||
User->>+Client: connect()
|
||||
|
||||
Note over Client: Self-managing connection process
|
||||
Client->>Client: Establish WebSocket connection
|
||||
Client->>Client: Start keep-alive loop
|
||||
Client->>Client: Start reconnection handler
|
||||
|
||||
alt Has Private Key
|
||||
Client->>Client: Check for private_key
|
||||
Client->>+CryptoUtils: derive_public_key(private_key)
|
||||
CryptoUtils-->>-Client: public_key
|
||||
|
||||
Note over Client: Request nonce via WebSocket
|
||||
Client->>+WsActor: JSON-RPC "fetch_nonce" (pubkey)
|
||||
WsActor-->>-Client: JSON-RPC Response (nonce)
|
||||
|
||||
Client->>+CryptoUtils: sign_message(private_key, nonce)
|
||||
CryptoUtils-->>-Client: signature
|
||||
|
||||
Note over Client: Send credentials via WebSocket
|
||||
Client->>+WsActor: JSON-RPC "authenticate" (pubkey, signature)
|
||||
WsActor-->>-Client: JSON-RPC Response (authenticated: true/false)
|
||||
end
|
||||
|
||||
Client-->>-User: Connection established and authenticated
|
||||
```
|
||||
|
||||
### Self-Management Features
|
||||
|
||||
- **Automatic Keep-Alive**: Each client runs its own keep-alive loop to detect connection issues
|
||||
- **Transparent Reconnection**: Failed connections are automatically retried with exponential backoff
|
||||
- **Status Monitoring**: Connection status is tracked internally and can be queried via `is_connected()`
|
||||
- **Resource Cleanup**: Proper cleanup of resources when clients are dropped
|
||||
|
||||
This architecture ensures that the cryptographic operations are isolated, the platform-specific code is cleanly separated, and each client is completely autonomous in managing its connection lifecycle.
|
2764
interfaces/websocket/client/Cargo.lock
generated
Normal file
2764
interfaces/websocket/client/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
56
interfaces/websocket/client/Cargo.toml
Normal file
56
interfaces/websocket/client/Cargo.toml
Normal file
@@ -0,0 +1,56 @@
|
||||
[package]
|
||||
name = "hero_websocket_client"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "hero_websocket_client"
|
||||
path = "cmd/main.rs"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
log = { workspace = true }
|
||||
futures-channel = { workspace = true, features = ["sink"] }
|
||||
futures-util = { workspace = true, features = ["sink"] }
|
||||
thiserror = { workspace = true }
|
||||
url = { workspace = true }
|
||||
http = "0.2"
|
||||
|
||||
# Authentication dependencies
|
||||
hex = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
|
||||
# Optional crypto dependencies (enabled by default)
|
||||
k256 = { version = "0.13", features = ["ecdsa", "sha256"], optional = true }
|
||||
sha3 = { workspace = true, optional = true }
|
||||
|
||||
# WASM-specific dependencies
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
gloo-net = { version = "0.4.0", features = ["websocket"] }
|
||||
gloo-timers = { version = "0.3.0", features = ["futures"] }
|
||||
wasm-bindgen-futures = "0.4"
|
||||
gloo-console = "0.3.0"
|
||||
wasm-bindgen = "0.2"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Request", "RequestInit", "RequestMode", "Response", "Window"] }
|
||||
|
||||
# Native-specific dependencies
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
tokio-tungstenite = { version = "0.23.1", features = ["native-tls"] }
|
||||
tokio = { workspace = true, features = ["rt", "macros", "time"] }
|
||||
native-tls = "0.2"
|
||||
clap = { workspace = true }
|
||||
env_logger = { workspace = true }
|
||||
dotenv = "0.15"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
|
||||
|
||||
# Features
|
||||
[features]
|
||||
default = ["crypto"]
|
||||
crypto = ["k256", "sha3"]
|
141
interfaces/websocket/client/README.md
Normal file
141
interfaces/websocket/client/README.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Circle WebSocket Client
|
||||
|
||||
A Rust library for connecting to Circle WebSocket servers with authentication support and self-managing connection lifecycle.
|
||||
|
||||
## Features
|
||||
|
||||
- **Cross-platform WebSocket client** (native and WASM)
|
||||
- **secp256k1 cryptographic authentication** with automatic challenge-response flow
|
||||
- **JSON-RPC 2.0 protocol support** for server communication
|
||||
- **Self-managing connections** with automatic keep-alive and reconnection
|
||||
- **Async/await interface** with modern Rust async patterns
|
||||
- **Built on tokio-tungstenite** for reliable WebSocket connections (native)
|
||||
- **Built on gloo-net** for WASM browser compatibility
|
||||
|
||||
## Architecture
|
||||
|
||||
Each `CircleWsClient` is completely self-managing:
|
||||
|
||||
- **Automatic Connection Management**: Handles WebSocket connection establishment
|
||||
- **Built-in Authentication**: Seamless secp256k1 authentication when private keys are provided
|
||||
- **Keep-Alive Monitoring**: Periodic health checks to detect connection issues
|
||||
- **Transparent Reconnection**: Automatic reconnection with exponential backoff on failures
|
||||
- **Connection Status Tracking**: Real-time connection state monitoring
|
||||
|
||||
## Usage
|
||||
|
||||
Add this to your `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
circle_client_ws = { path = "../client_ws" }
|
||||
```
|
||||
|
||||
### Basic Example (Self-Managing Connection)
|
||||
|
||||
```rust
|
||||
use circle_client_ws::CircleWsClientBuilder;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Create client with private key
|
||||
let private_key = "your_private_key_hex";
|
||||
let mut client = CircleWsClientBuilder::new("ws://localhost:8080".to_string())
|
||||
.with_keypair(private_key.to_string())
|
||||
.build();
|
||||
|
||||
// Connect - this handles authentication, keep-alive, and reconnection automatically
|
||||
client.connect().await?;
|
||||
|
||||
// Check connection status
|
||||
println!("Connected: {}", client.is_connected());
|
||||
|
||||
// Execute scripts on the server
|
||||
let result = client.play("\"Hello from client!\"".to_string()).await?;
|
||||
println!("Script result: {:?}", result);
|
||||
|
||||
// Client automatically maintains connection in the background
|
||||
// No manual keep-alive or reconnection logic needed
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Self-Managing Features
|
||||
|
||||
The client automatically handles:
|
||||
|
||||
1. **Connection Establishment**: WebSocket connection to the server
|
||||
2. **Authentication Flow**: secp256k1 challenge-response authentication
|
||||
3. **Keep-Alive Monitoring**: Periodic health checks to ensure connection stability
|
||||
4. **Automatic Reconnection**: Transparent reconnection on connection failures
|
||||
5. **Resource Management**: Proper cleanup when the client is dropped
|
||||
|
||||
### Connection Status Monitoring
|
||||
|
||||
```rust
|
||||
// Check if the client is currently connected
|
||||
if client.is_connected() {
|
||||
println!("Client is connected and healthy");
|
||||
} else {
|
||||
println!("Client is disconnected or reconnecting");
|
||||
}
|
||||
|
||||
// Get detailed connection status
|
||||
let status = client.get_connection_status();
|
||||
println!("Connection status: {}", status);
|
||||
```
|
||||
|
||||
### WASM Usage
|
||||
|
||||
For WASM applications, the client works seamlessly in browsers:
|
||||
|
||||
```rust
|
||||
use circle_client_ws::CircleWsClientBuilder;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
// In a WASM context
|
||||
spawn_local(async move {
|
||||
let mut client = CircleWsClientBuilder::new("ws://localhost:8080".to_string())
|
||||
.build();
|
||||
|
||||
// Self-managing connection works the same in WASM
|
||||
if let Ok(_) = client.connect().await {
|
||||
// Client automatically handles keep-alive and reconnection
|
||||
let result = client.play("\"WASM client connected!\"".to_string()).await;
|
||||
// Handle result...
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Binary Tool
|
||||
|
||||
A command-line binary is also available for interactive use and script execution. See [`cmd/README.md`](cmd/README.md) for details.
|
||||
|
||||
## Platform Support
|
||||
|
||||
- **Native**: Full support on all Rust-supported platforms with tokio-tungstenite
|
||||
- **WASM**: Browser support with gloo-net WebSocket bindings
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Core Dependencies
|
||||
- `serde`: JSON serialization and deserialization
|
||||
- `uuid`: Request ID generation for JSON-RPC
|
||||
- `futures-util`: Async utilities for WebSocket handling
|
||||
- `thiserror`: Error handling and propagation
|
||||
|
||||
### Platform-Specific Dependencies
|
||||
|
||||
#### Native (tokio-based)
|
||||
- `tokio-tungstenite`: Robust WebSocket implementation
|
||||
- `tokio`: Async runtime for connection management
|
||||
|
||||
#### WASM (browser-based)
|
||||
- `gloo-net`: WebSocket bindings for browsers
|
||||
- `gloo-timers`: Timer utilities for keep-alive functionality
|
||||
- `wasm-bindgen-futures`: Async support in WASM
|
||||
|
||||
### Cryptographic Dependencies (optional)
|
||||
- `secp256k1`: Elliptic curve cryptography for authentication
|
||||
- `sha3`: Hashing for cryptographic operations
|
136
interfaces/websocket/client/cmd/README.md
Normal file
136
interfaces/websocket/client/cmd/README.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Circles WebSocket Client
|
||||
|
||||
A WebSocket client for connecting to Circles servers with authentication support. Available in both CLI and WebAssembly (WASM) versions.
|
||||
|
||||
## CLI Usage
|
||||
|
||||
### Installation
|
||||
|
||||
Build the CLI binary:
|
||||
```bash
|
||||
cargo build --bin circles_client --release
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Create a `.env` file in the `cmd/` directory:
|
||||
```bash
|
||||
# cmd/.env
|
||||
PRIVATE_KEY=your_actual_private_key_hex_here
|
||||
```
|
||||
|
||||
Or set the environment variable directly:
|
||||
```bash
|
||||
export PRIVATE_KEY=your_actual_private_key_hex_here
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Basic usage - connects and enters interactive mode
|
||||
circles_client ws://localhost:8080
|
||||
|
||||
# Execute a single Rhai script
|
||||
circles_client -s "print('Hello from Rhai!')" ws://localhost:8080
|
||||
|
||||
# Execute a script from file
|
||||
circles_client -f script.rhai ws://localhost:8080
|
||||
|
||||
# Increase verbosity (can be used multiple times)
|
||||
circles_client -v ws://localhost:8080
|
||||
circles_client -vv ws://localhost:8080
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
- **Authentication**: Automatically loads private key and completes secp256k1 authentication flow
|
||||
- **Script Execution**: Supports both inline scripts (`-s`) and script files (`-f`)
|
||||
- **Interactive Mode**: When no script is provided, enters interactive REPL mode
|
||||
- **Verbosity Control**: Use `-v` flags to increase logging detail
|
||||
- **Cross-platform**: Works on all platforms supported by Rust and tokio-tungstenite
|
||||
|
||||
## WebAssembly (WASM) Usage
|
||||
|
||||
### Build and Serve
|
||||
|
||||
1. Install Trunk:
|
||||
```bash
|
||||
cargo install trunk
|
||||
```
|
||||
|
||||
2. Build the WASM version:
|
||||
```bash
|
||||
trunk build --release
|
||||
```
|
||||
|
||||
3. Serve the application:
|
||||
```bash
|
||||
trunk serve
|
||||
```
|
||||
|
||||
The application will be available at `http://localhost:8080`
|
||||
|
||||
### Usage in Browser
|
||||
|
||||
1. Open the served page in your browser
|
||||
2. Enter the WebSocket server URL
|
||||
3. Choose either:
|
||||
- Execute a Rhai script directly
|
||||
- Enter interactive mode (type 'exit' or 'quit' to leave)
|
||||
|
||||
### Features
|
||||
|
||||
- **Browser Integration**: Uses browser's WebSocket implementation
|
||||
- **Interactive Mode**: Browser-based input/output using prompts
|
||||
- **Error Handling**: Browser console logging
|
||||
- **Cross-browser**: Works in all modern browsers supporting WebAssembly
|
||||
|
||||
## Common Features
|
||||
|
||||
Both versions share the same core functionality:
|
||||
|
||||
- **WebSocket Connection**: Connects to Circles WebSocket server
|
||||
- **Authentication**: Handles secp256k1 authentication
|
||||
- **Script Execution**: Executes Rhai scripts
|
||||
- **Interactive Mode**: Provides REPL-like interface
|
||||
- **Error Handling**: Comprehensive error reporting
|
||||
- **Logging**: Detailed logging at different verbosity levels
|
||||
|
||||
### Interactive Mode
|
||||
|
||||
When run without `-s` or `-f` flags, the client enters interactive mode where you can:
|
||||
- Enter Rhai scripts line by line
|
||||
- Type `exit` or `quit` to close the connection
|
||||
- Use Ctrl+C to terminate
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Connect to local development server
|
||||
circles_client ws://localhost:8080
|
||||
|
||||
# Connect to secure WebSocket with verbose logging
|
||||
circles_client -v wss://circles.example.com/ws
|
||||
|
||||
# Execute a simple calculation
|
||||
circles_client -s "let result = 2 + 2; print(result);" ws://localhost:8080
|
||||
|
||||
# Load and execute a complex script
|
||||
circles_client -f examples/complex_script.rhai ws://localhost:8080
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
The client provides clear error messages for common issues:
|
||||
- Missing or invalid private key
|
||||
- Connection failures
|
||||
- Authentication errors
|
||||
- Script execution errors
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `tokio-tungstenite`: WebSocket client implementation
|
||||
- `secp256k1`: Cryptographic authentication
|
||||
- `clap`: Command-line argument parsing
|
||||
- `env_logger`: Logging infrastructure
|
||||
- `dotenv`: Environment variable loading
|
118
interfaces/websocket/client/cmd/index.html
Normal file
118
interfaces/websocket/client/cmd/index.html
Normal file
@@ -0,0 +1,118 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Circles WebSocket Client</title>
|
||||
<link data-trunk rel="rust" href="../Cargo.toml" data-wasm-opt="z" />
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
margin: 20px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.input-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
input[type="text"] {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
button {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
pre {
|
||||
background: #f5f5f5;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Circles WebSocket Client</h1>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="ws-url">WebSocket URL:</label>
|
||||
<input type="text" id="ws-url" placeholder="ws://localhost:8080">
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="script">Rhai Script:</label>
|
||||
<input type="text" id="script" placeholder="Enter Rhai script here">
|
||||
</div>
|
||||
|
||||
<button id="run-script">Run Script</button>
|
||||
<button id="run-interactive">Interactive Mode</button>
|
||||
|
||||
<div id="output" style="margin-top: 20px;">
|
||||
<h2>Output:</h2>
|
||||
<pre id="output-content"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
// Trunk will inject the necessary JS to load the WASM module.
|
||||
// The wasm_bindgen functions will be available on the `window` object.
|
||||
async function main() {
|
||||
// The `wasm_bindgen` object is exposed globally by the Trunk-injected script.
|
||||
const { start_client } = wasm_bindgen;
|
||||
|
||||
document.getElementById('run-script').addEventListener('click', async () => {
|
||||
const url = document.getElementById('ws-url').value;
|
||||
const script = document.getElementById('script').value;
|
||||
if (!url) {
|
||||
alert('Please enter a WebSocket URL');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// The init function is called automatically by Trunk's setup.
|
||||
const result = await start_client(url, script);
|
||||
document.getElementById('output-content').textContent = result;
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
document.getElementById('output-content').textContent = `Error: ${error}`;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('run-interactive').addEventListener('click', async () => {
|
||||
const url = document.getElementById('ws-url').value;
|
||||
if (!url) {
|
||||
alert('Please enter a WebSocket URL');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// The init function is called automatically by Trunk's setup.
|
||||
await start_client(url, null);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert(`Error: ${error}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The `wasm_bindgen` function is a promise that resolves when the WASM is loaded.
|
||||
wasm_bindgen('./pkg/circle_client_ws_bg.wasm').then(main).catch(console.error);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
342
interfaces/websocket/client/cmd/main.rs
Normal file
342
interfaces/websocket/client/cmd/main.rs
Normal file
@@ -0,0 +1,342 @@
|
||||
#![cfg_attr(target_arch = "wasm32", no_main)]
|
||||
|
||||
use hero_websocket_client::CircleWsClientBuilder;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::env;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::path::Path;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::io::{self, Write};
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use wasm_bindgen::prelude::*;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use web_sys::{console, window};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use clap::{Arg, ArgAction, Command};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use dotenv::dotenv;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use env_logger;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use tokio;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use log::{error, info};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Args {
|
||||
ws_url: String,
|
||||
script: Option<String>,
|
||||
script_path: Option<String>,
|
||||
verbose: u8,
|
||||
no_timestamp: bool,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn parse_args() -> Args {
|
||||
let matches = Command::new("circles_client")
|
||||
.version("0.1.0")
|
||||
.about("WebSocket client for Circles server")
|
||||
.arg(
|
||||
Arg::new("url")
|
||||
.help("WebSocket server URL")
|
||||
.required(true)
|
||||
.index(1),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("script")
|
||||
.short('s')
|
||||
.long("script")
|
||||
.value_name("SCRIPT")
|
||||
.help("Rhai script to execute")
|
||||
.conflicts_with("script_path"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("script_path")
|
||||
.short('f')
|
||||
.long("file")
|
||||
.value_name("FILE")
|
||||
.help("Path to Rhai script file")
|
||||
.conflicts_with("script"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("verbose")
|
||||
.short('v')
|
||||
.long("verbose")
|
||||
.help("Increase verbosity (can be used multiple times)")
|
||||
.action(ArgAction::Count),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("no_timestamp")
|
||||
.long("no-timestamp")
|
||||
.help("Remove timestamps from log output")
|
||||
.action(ArgAction::SetTrue),
|
||||
)
|
||||
.get_matches();
|
||||
|
||||
Args {
|
||||
ws_url: matches.get_one::<String>("url").unwrap().clone(),
|
||||
script: matches.get_one::<String>("script").cloned(),
|
||||
script_path: matches.get_one::<String>("script_path").cloned(),
|
||||
verbose: matches.get_count("verbose"),
|
||||
no_timestamp: matches.get_flag("no_timestamp"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn setup_logging(verbose: u8, no_timestamp: bool) {
|
||||
let log_level = match verbose {
|
||||
0 => "warn,hero_websocket_client=info",
|
||||
1 => "info,hero_websocket_client=debug",
|
||||
2 => "debug",
|
||||
_ => "trace",
|
||||
};
|
||||
|
||||
std::env::set_var("RUST_LOG", log_level);
|
||||
|
||||
// Configure env_logger with or without timestamps
|
||||
if no_timestamp {
|
||||
env_logger::Builder::from_default_env()
|
||||
.format_timestamp(None)
|
||||
.init();
|
||||
} else {
|
||||
env_logger::init();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_private_key() -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Try to load from .env file first
|
||||
if let Ok(_) = dotenv() {
|
||||
if let Ok(key) = env::var("PRIVATE_KEY") {
|
||||
return Ok(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to load from cmd/.env file
|
||||
let cmd_env_path = Path::new("cmd/.env");
|
||||
if cmd_env_path.exists() {
|
||||
dotenv::from_path(cmd_env_path)?;
|
||||
if let Ok(key) = env::var("PRIVATE_KEY") {
|
||||
return Ok(key);
|
||||
}
|
||||
}
|
||||
|
||||
Err("PRIVATE_KEY not found in environment or .env files".into())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
async fn run_interactive_mode(client: hero_websocket_client::CircleWsClient) -> Result<(), Box<dyn std::error::Error>> {
|
||||
console::log_1(&"Entering interactive mode. Type 'exit' or 'quit' to leave.".into());
|
||||
console::log_1(&"🔄 Interactive mode - Enter Rhai scripts (type 'exit' or 'quit' to leave):\n".into());
|
||||
|
||||
// In wasm32, we need to use browser's console for input/output
|
||||
let window = window().expect("Window not available");
|
||||
let input = window.prompt_with_message("Enter Rhai script (or 'exit' to quit):")
|
||||
.map_err(|e| format!("Failed to get input: {:#?}", e))? // Use debug formatting
|
||||
.unwrap_or_default();
|
||||
|
||||
// Handle empty or exit cases
|
||||
if input == "exit" || input == "quit" {
|
||||
console::log_1(&"👋 Goodbye!".into());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Execute the script
|
||||
match client.play(input).await {
|
||||
Ok(result) => {
|
||||
console::log_1(&format!("📤 Result: {}", result.output).into());
|
||||
}
|
||||
Err(e) => {
|
||||
console::log_1(&format!("❌ Script execution failed: {}", e).into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
async fn execute_script(client: hero_websocket_client::CircleWsClient, script: String) -> Result<(), Box<dyn std::error::Error>> {
|
||||
console::log_1(&format!("Executing script: {}", script).into());
|
||||
|
||||
match client.play(script).await {
|
||||
Ok(result) => {
|
||||
console::log_1(&result.output.into());
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
console::log_1(&format!("Script execution failed: {}", e).into());
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn start_client(url: &str, script: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Build client
|
||||
let mut client = CircleWsClientBuilder::new(url.to_string())
|
||||
.build();
|
||||
|
||||
// Connect to WebSocket server
|
||||
console::log_1(&"🔌 Connecting to WebSocket server...".into());
|
||||
if let Err(e) = client.connect().await {
|
||||
console::log_1(&format!("❌ Failed to connect: {}", e).into());
|
||||
return Err(e.into());
|
||||
}
|
||||
console::log_1(&"✅ Connected successfully".into());
|
||||
|
||||
// Authenticate with server
|
||||
if let Err(e) = client.authenticate().await {
|
||||
console::log_1(&format!("❌ Authentication failed: {}", e).into());
|
||||
return Err(e.into());
|
||||
}
|
||||
console::log_1(&"✅ Authentication successful".into());
|
||||
|
||||
// Handle script execution
|
||||
if let Some(script) = script {
|
||||
execute_script(client, script).await
|
||||
} else {
|
||||
run_interactive_mode(client).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
async fn execute_script(client: hero_websocket_client::CircleWsClient, script: String) -> Result<(), Box<dyn std::error::Error>> {
|
||||
info!("Executing script: {}", script);
|
||||
|
||||
match client.play(script).await {
|
||||
Ok(result) => {
|
||||
println!("{}", result.output);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Script execution failed: {}", e);
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
async fn load_script_from_file(path: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let script = tokio::fs::read_to_string(path).await?;
|
||||
Ok(script)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
async fn run_interactive_mode(client: hero_websocket_client::CircleWsClient) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("\n🔄 Interactive mode - Enter Rhai scripts (type 'exit' or 'quit' to leave):\n");
|
||||
|
||||
loop {
|
||||
print!("Enter Rhai script (or 'exit' to quit): ");
|
||||
io::stdout().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
let input = input.trim().to_string();
|
||||
|
||||
if input == "exit" || input == "quit" {
|
||||
println!("\n👋 Goodbye!");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match client.play(input).await {
|
||||
Ok(result) => {
|
||||
println!("\n📤 Result: {}", result.output);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("❌ Script execution failed: {}", e);
|
||||
println!("\n❌ Script execution failed: {}", e);
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args = parse_args();
|
||||
setup_logging(args.verbose, args.no_timestamp);
|
||||
|
||||
info!("🚀 Starting Circles WebSocket client");
|
||||
info!("📡 Connecting to: {}", args.ws_url);
|
||||
|
||||
// Load private key from environment
|
||||
let private_key = match load_private_key() {
|
||||
Ok(key) => {
|
||||
info!("🔑 Private key loaded from environment");
|
||||
key
|
||||
}
|
||||
Err(e) => {
|
||||
error!("❌ Failed to load private key: {}", e);
|
||||
eprintln!("Error: {}", e);
|
||||
eprintln!("Please set PRIVATE_KEY in your environment or create a cmd/.env file with:");
|
||||
eprintln!("PRIVATE_KEY=your_private_key_here");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Build client with private key
|
||||
let mut client = CircleWsClientBuilder::new(args.ws_url.clone())
|
||||
.with_keypair(private_key)
|
||||
.build();
|
||||
|
||||
// Connect to WebSocket server
|
||||
info!("🔌 Connecting to WebSocket server...");
|
||||
if let Err(e) = client.connect().await {
|
||||
error!("❌ Failed to connect: {}", e);
|
||||
eprintln!("Connection failed: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
info!("✅ Connected successfully");
|
||||
|
||||
// Authenticate with server
|
||||
info!("🔐 Authenticating with server...");
|
||||
match client.authenticate().await {
|
||||
Ok(true) => {
|
||||
info!("✅ Authentication successful");
|
||||
println!("🔐 Authentication successful");
|
||||
}
|
||||
Ok(false) => {
|
||||
error!("❌ Authentication failed");
|
||||
eprintln!("Authentication failed");
|
||||
std::process::exit(1);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("❌ Authentication error: {}", e);
|
||||
eprintln!("Authentication error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine execution mode
|
||||
let result = if let Some(script) = args.script {
|
||||
// Execute provided script and exit
|
||||
execute_script(client, script).await
|
||||
} else if let Some(script_path) = args.script_path {
|
||||
// Load script from file and execute
|
||||
match load_script_from_file(&script_path).await {
|
||||
Ok(script) => execute_script(client, script).await,
|
||||
Err(e) => {
|
||||
error!("❌ Failed to load script from file '{}': {}", script_path, e);
|
||||
eprintln!("Failed to load script file: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Enter interactive mode
|
||||
run_interactive_mode(client).await
|
||||
};
|
||||
|
||||
// Handle any errors from execution
|
||||
if let Err(e) = result {
|
||||
error!("❌ Execution failed: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
info!("🏁 Client finished successfully");
|
||||
Ok(())
|
||||
}
|
273
interfaces/websocket/client/src/auth/crypto_utils.rs
Normal file
273
interfaces/websocket/client/src/auth/crypto_utils.rs
Normal file
@@ -0,0 +1,273 @@
|
||||
//! Cryptographic utilities for secp256k1 operations
|
||||
//!
|
||||
//! This module provides functions for:
|
||||
//! - Private key validation and parsing
|
||||
//! - Public key derivation
|
||||
//! - Ethereum-style message signing
|
||||
//! - Signature verification
|
||||
|
||||
use crate::auth::types::{AuthError, AuthResult};
|
||||
|
||||
pub fn generate_keypair() -> AuthResult<(String, String)> {
|
||||
let private_key = generate_private_key()?;
|
||||
let public_key = derive_public_key(&private_key)?;
|
||||
Ok((public_key, private_key))
|
||||
}
|
||||
|
||||
/// Generate a new random private key
|
||||
pub fn generate_private_key() -> AuthResult<String> {
|
||||
#[cfg(feature = "crypto")]
|
||||
{
|
||||
use rand::rngs::OsRng;
|
||||
use k256::ecdsa::SigningKey;
|
||||
|
||||
let signing_key = SigningKey::random(&mut OsRng);
|
||||
Ok(hex::encode(signing_key.to_bytes()))
|
||||
}
|
||||
#[cfg(not(feature = "crypto"))]
|
||||
{
|
||||
// Fallback implementation for when crypto features are not available
|
||||
use rand::Rng;
|
||||
let mut rng = rand::thread_rng();
|
||||
let bytes: [u8; 32] = rng.gen();
|
||||
Ok(hex::encode(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a hex-encoded private key
|
||||
pub fn parse_private_key(private_key_hex: &str) -> AuthResult<Vec<u8>> {
|
||||
// Remove 0x prefix if present
|
||||
let clean_hex = private_key_hex
|
||||
.strip_prefix("0x")
|
||||
.unwrap_or(private_key_hex);
|
||||
|
||||
// Decode hex
|
||||
let bytes = hex::decode(clean_hex)
|
||||
.map_err(|e| AuthError::InvalidPrivateKey(format!("Invalid hex: {}", e)))?;
|
||||
|
||||
// Validate length
|
||||
if bytes.len() != 32 {
|
||||
return Err(AuthError::InvalidPrivateKey(format!(
|
||||
"Private key must be 32 bytes, got {}",
|
||||
bytes.len()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Derive public key from private key
|
||||
pub fn derive_public_key(private_key_hex: &str) -> AuthResult<String> {
|
||||
#[cfg(feature = "crypto")]
|
||||
{
|
||||
use k256::ecdsa::SigningKey;
|
||||
use k256::elliptic_curve::sec1::ToEncodedPoint;
|
||||
|
||||
let key_bytes = parse_private_key(private_key_hex)?;
|
||||
let signing_key = SigningKey::from_slice(&key_bytes)
|
||||
.map_err(|e| AuthError::InvalidPrivateKey(format!("Invalid key: {}", e)))?;
|
||||
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
let encoded_point = verifying_key.to_encoded_point(false); // false = uncompressed
|
||||
|
||||
// Return uncompressed public key (65 bytes with 0x04 prefix)
|
||||
Ok(hex::encode(encoded_point.as_bytes()))
|
||||
}
|
||||
#[cfg(not(feature = "crypto"))]
|
||||
{
|
||||
// Fallback implementation - generate a mock public key
|
||||
let key_bytes = parse_private_key(private_key_hex)?;
|
||||
let mut public_key_bytes = vec![0x04u8]; // Uncompressed prefix
|
||||
public_key_bytes.extend_from_slice(&key_bytes);
|
||||
public_key_bytes.extend_from_slice(&key_bytes); // Double for 65 bytes total
|
||||
public_key_bytes.truncate(65);
|
||||
Ok(hex::encode(public_key_bytes))
|
||||
}
|
||||
}
|
||||
|
||||
/// Create Ethereum-style message hash
|
||||
/// This follows the Ethereum standard: keccak256("\x19Ethereum Signed Message:\n" + len(message) + message)
|
||||
fn create_eth_message_hash(message: &str) -> Vec<u8> {
|
||||
let prefix = format!("\x19Ethereum Signed Message:\n{}", message.len());
|
||||
let full_message = format!("{}{}", prefix, message);
|
||||
|
||||
#[cfg(feature = "crypto")]
|
||||
{
|
||||
use sha3::{Digest, Keccak256};
|
||||
let mut hasher = Keccak256::new();
|
||||
hasher.update(full_message.as_bytes());
|
||||
hasher.finalize().to_vec()
|
||||
}
|
||||
#[cfg(not(feature = "crypto"))]
|
||||
{
|
||||
// Fallback: use a simple hash
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
full_message.hash(&mut hasher);
|
||||
let hash = hasher.finish();
|
||||
hash.to_be_bytes().to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign a message using Ethereum-style signing
|
||||
pub fn sign_message(private_key_hex: &str, message: &str) -> AuthResult<String> {
|
||||
#[cfg(feature = "crypto")]
|
||||
{
|
||||
use k256::ecdsa::{SigningKey, signature::Signer};
|
||||
|
||||
let key_bytes = parse_private_key(private_key_hex)?;
|
||||
let signing_key = SigningKey::from_slice(&key_bytes)
|
||||
.map_err(|e| AuthError::InvalidPrivateKey(format!("Invalid private key: {}", e)))?;
|
||||
|
||||
// Create message hash
|
||||
let message_hash = create_eth_message_hash(message);
|
||||
|
||||
// Sign the hash
|
||||
let signature: k256::ecdsa::Signature = signing_key.sign(&message_hash);
|
||||
|
||||
// Convert to recoverable signature format (65 bytes with recovery ID)
|
||||
let sig_bytes = signature.to_bytes();
|
||||
let mut full_sig = [0u8; 65];
|
||||
full_sig[..64].copy_from_slice(&sig_bytes);
|
||||
|
||||
// Calculate recovery ID (simplified - in production you'd want proper recovery)
|
||||
full_sig[64] = 0; // Recovery ID placeholder
|
||||
|
||||
Ok(hex::encode(full_sig))
|
||||
}
|
||||
#[cfg(not(feature = "crypto"))]
|
||||
{
|
||||
// Fallback implementation - generate a mock signature
|
||||
let key_bytes = parse_private_key(private_key_hex)?;
|
||||
let message_hash = create_eth_message_hash(message);
|
||||
|
||||
// Create a deterministic but fake signature
|
||||
let mut sig_bytes = Vec::with_capacity(65);
|
||||
sig_bytes.extend_from_slice(&key_bytes);
|
||||
sig_bytes.extend_from_slice(&message_hash[..32]);
|
||||
sig_bytes.push(27); // Recovery ID
|
||||
sig_bytes.truncate(65);
|
||||
|
||||
Ok(hex::encode(sig_bytes))
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify an Ethereum-style signature
|
||||
pub fn verify_signature(
|
||||
public_key_hex: &str,
|
||||
message: &str,
|
||||
signature_hex: &str,
|
||||
) -> AuthResult<bool> {
|
||||
#[cfg(feature = "crypto")]
|
||||
{
|
||||
use k256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
|
||||
use k256::EncodedPoint;
|
||||
|
||||
// Remove 0x prefix if present
|
||||
let clean_pubkey = public_key_hex.strip_prefix("0x").unwrap_or(public_key_hex);
|
||||
let clean_sig = signature_hex.strip_prefix("0x").unwrap_or(signature_hex);
|
||||
|
||||
// Decode public key
|
||||
let pubkey_bytes = hex::decode(clean_pubkey)
|
||||
.map_err(|e| AuthError::InvalidSignature(format!("Invalid public key hex: {}", e)))?;
|
||||
|
||||
let encoded_point = EncodedPoint::from_bytes(&pubkey_bytes)
|
||||
.map_err(|e| AuthError::InvalidSignature(format!("Invalid public key format: {}", e)))?;
|
||||
|
||||
let verifying_key = VerifyingKey::from_encoded_point(&encoded_point)
|
||||
.map_err(|e| AuthError::InvalidSignature(format!("Invalid public key: {}", e)))?;
|
||||
|
||||
// Decode signature
|
||||
let sig_bytes = hex::decode(clean_sig)
|
||||
.map_err(|e| AuthError::InvalidSignature(format!("Invalid signature hex: {}", e)))?;
|
||||
|
||||
if sig_bytes.len() != 65 {
|
||||
return Err(AuthError::InvalidSignature(format!(
|
||||
"Signature must be 65 bytes, got {}",
|
||||
sig_bytes.len()
|
||||
)));
|
||||
}
|
||||
|
||||
// Extract r, s components (ignore recovery byte for verification)
|
||||
let signature = Signature::from_slice(&sig_bytes[..64])
|
||||
.map_err(|e| AuthError::InvalidSignature(format!("Invalid signature format: {}", e)))?;
|
||||
|
||||
// Create message hash
|
||||
let message_hash = create_eth_message_hash(message);
|
||||
|
||||
// Verify signature
|
||||
match verifying_key.verify(&message_hash, &signature) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "crypto"))]
|
||||
{
|
||||
// Fallback implementation - basic validation
|
||||
let clean_pubkey = public_key_hex.strip_prefix("0x").unwrap_or(public_key_hex);
|
||||
let clean_sig = signature_hex.strip_prefix("0x").unwrap_or(signature_hex);
|
||||
|
||||
// Basic validation
|
||||
if clean_pubkey.len() != 130 {
|
||||
// 65 bytes as hex
|
||||
return Err(AuthError::InvalidSignature(
|
||||
"Invalid public key length".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if clean_sig.len() != 130 {
|
||||
// 65 bytes as hex
|
||||
return Err(AuthError::InvalidSignature(
|
||||
"Invalid signature length".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// For app purposes, accept any properly formatted signature
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that a private key is valid
|
||||
pub fn validate_private_key(private_key_hex: &str) -> AuthResult<()> {
|
||||
parse_private_key(private_key_hex)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_key_generation_and_derivation() {
|
||||
let private_key = generate_private_key().unwrap();
|
||||
let public_key = derive_public_key(&private_key).unwrap();
|
||||
|
||||
assert_eq!(private_key.len(), 64); // 32 bytes as hex
|
||||
assert_eq!(public_key.len(), 130); // 65 bytes as hex (uncompressed)
|
||||
assert!(public_key.starts_with("04")); // Uncompressed public key prefix
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_signing_and_verification() {
|
||||
let private_key = generate_private_key().unwrap();
|
||||
let public_key = derive_public_key(&private_key).unwrap();
|
||||
let message = "Hello, World!";
|
||||
|
||||
let signature = sign_message(&private_key, message).unwrap();
|
||||
let is_valid = verify_signature(&public_key, message, &signature).unwrap();
|
||||
|
||||
assert!(is_valid);
|
||||
assert_eq!(signature.len(), 130); // 65 bytes as hex
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_private_key() {
|
||||
let result = validate_private_key("invalid_hex");
|
||||
assert!(result.is_err());
|
||||
|
||||
let result = validate_private_key("0x1234"); // Too short
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
113
interfaces/websocket/client/src/auth/mod.rs
Normal file
113
interfaces/websocket/client/src/auth/mod.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
//! Authentication module for Circle WebSocket client
|
||||
//!
|
||||
//! This module provides core cryptographic authentication support for WebSocket connections
|
||||
//! using secp256k1 signatures. It includes:
|
||||
//!
|
||||
//! - **Cryptographic utilities**: Key generation, signing, and verification
|
||||
//! - **Nonce management**: Fetching nonces from authentication servers
|
||||
//! - **Basic types**: Core authentication data structures
|
||||
//!
|
||||
//! ## Features
|
||||
//!
|
||||
//! - **Cross-platform**: Works in both WASM and native environments
|
||||
//! - **Ethereum-compatible**: Uses Ethereum-style message signing
|
||||
//! - **Secure**: Implements proper nonce-based replay protection
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! ```rust
|
||||
//! use circle_client_ws::auth::{generate_private_key, derive_public_key, sign_message};
|
||||
//! use tokio::runtime::Runtime;
|
||||
//!
|
||||
//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! # let rt = Runtime::new()?;
|
||||
//! # rt.block_on(async {
|
||||
//! // Generate a private key
|
||||
//! let private_key = generate_private_key()?;
|
||||
//!
|
||||
//! // Derive public key from private key
|
||||
//! let public_key = derive_public_key(&private_key)?;
|
||||
//!
|
||||
//! // The nonce would typically be fetched from a server
|
||||
//! let nonce = "some_nonce_from_server";
|
||||
//!
|
||||
//! // Authentication Module
|
||||
//!
|
||||
//! This module handles the client-side authentication flow, including:
|
||||
//! - Fetching a nonce from the server
|
||||
//! - Signing the nonce with a private key
|
||||
//! - Sending the credentials to the server for verification
|
||||
//!
|
||||
//! // Sign the nonce
|
||||
//! let signature = sign_message(&private_key, nonce)?;
|
||||
//! # Ok(())
|
||||
//! # })
|
||||
//! # }
|
||||
//! ```
|
||||
|
||||
pub mod types;
|
||||
pub use types::{AuthCredentials, AuthError, AuthResult, NonceResponse};
|
||||
|
||||
pub mod crypto_utils;
|
||||
pub use crypto_utils::{
|
||||
derive_public_key, generate_keypair, generate_private_key, parse_private_key, sign_message,
|
||||
validate_private_key, verify_signature,
|
||||
};
|
||||
|
||||
/// Check if the authentication feature is enabled
|
||||
///
|
||||
/// This function can be used to conditionally enable authentication features
|
||||
/// based on compile-time feature flags.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `true` if crypto features are available, `false` otherwise
|
||||
pub fn is_auth_enabled() -> bool {
|
||||
cfg!(feature = "crypto")
|
||||
}
|
||||
|
||||
/// Get version information for the authentication module
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A string containing version and feature information
|
||||
pub fn auth_version_info() -> String {
|
||||
let crypto_status = if cfg!(feature = "crypto") {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled (fallback mode)"
|
||||
};
|
||||
|
||||
let platform = if cfg!(target_arch = "wasm32") {
|
||||
"WASM"
|
||||
} else {
|
||||
"native"
|
||||
};
|
||||
|
||||
format!(
|
||||
"circles-client-ws auth module - crypto: {}, platform: {}",
|
||||
crypto_status, platform
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_module_exports() {
|
||||
// Test utility functions
|
||||
assert!(auth_version_info().contains("circles-client-ws auth module"));
|
||||
|
||||
// Test feature detection
|
||||
let _is_enabled = is_auth_enabled();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_info() {
|
||||
let version = auth_version_info();
|
||||
assert!(version.contains("circles-client-ws auth module"));
|
||||
assert!(version.contains("crypto:"));
|
||||
assert!(version.contains("platform:"));
|
||||
}
|
||||
}
|
128
interfaces/websocket/client/src/auth/types.rs
Normal file
128
interfaces/websocket/client/src/auth/types.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
//! Authentication types for Circle WebSocket client
|
||||
//!
|
||||
//! This module defines the core types used in the authentication system,
|
||||
//! including error types, response structures, and authentication states.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Result type for authentication operations
|
||||
pub type AuthResult<T> = Result<T, AuthError>;
|
||||
|
||||
/// Authentication error types
|
||||
#[derive(Error, Debug, Clone)]
|
||||
pub enum AuthError {
|
||||
#[error("Invalid private key: {0}")]
|
||||
InvalidPrivateKey(String),
|
||||
#[error("Invalid URL: {0}")]
|
||||
InvalidUrl(String),
|
||||
#[error("Nonce request failed: {0}")]
|
||||
NonceRequestFailed(String),
|
||||
#[error("Signing failed: {0}")]
|
||||
SigningFailed(String),
|
||||
#[error("Network error: {0}")]
|
||||
NetworkError(String),
|
||||
#[error("Invalid signature: {0}")]
|
||||
InvalidSignature(String),
|
||||
#[error("Invalid credentials: {0}")]
|
||||
InvalidCredentials(String),
|
||||
}
|
||||
|
||||
/// Response from nonce endpoint
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct NonceResponse {
|
||||
/// The cryptographic nonce
|
||||
pub nonce: String,
|
||||
/// Expiration timestamp (seconds since epoch)
|
||||
pub expires_at: u64,
|
||||
}
|
||||
|
||||
/// Authentication credentials for WebSocket connection
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthCredentials {
|
||||
/// Public key in hex format
|
||||
pub public_key: String,
|
||||
/// Signature of the nonce
|
||||
pub signature: String,
|
||||
/// Nonce that was signed
|
||||
pub nonce: String,
|
||||
/// Expiration timestamp (seconds since epoch)
|
||||
pub expires_at: u64,
|
||||
}
|
||||
|
||||
impl AuthCredentials {
|
||||
/// Create new authentication credentials
|
||||
pub fn new(public_key: String, signature: String, nonce: String, expires_at: u64) -> Self {
|
||||
Self {
|
||||
public_key,
|
||||
signature,
|
||||
nonce,
|
||||
expires_at,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the public key
|
||||
pub fn public_key(&self) -> &str {
|
||||
&self.public_key
|
||||
}
|
||||
|
||||
/// Get the signature
|
||||
pub fn signature(&self) -> &str {
|
||||
&self.signature
|
||||
}
|
||||
|
||||
/// Get the nonce
|
||||
pub fn nonce(&self) -> &str {
|
||||
&self.nonce
|
||||
}
|
||||
|
||||
/// Check if credentials have expired
|
||||
pub fn is_expired(&self) -> bool {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
if let Ok(current_time) = SystemTime::now().duration_since(UNIX_EPOCH) {
|
||||
let current_timestamp = current_time.as_secs();
|
||||
current_timestamp >= self.expires_at
|
||||
} else {
|
||||
true // If we can't get current time, assume expired for safety
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if credentials expire within the given number of seconds
|
||||
pub fn expires_within(&self, seconds: u64) -> bool {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
if let Ok(current_time) = SystemTime::now().duration_since(UNIX_EPOCH) {
|
||||
let current_timestamp = current_time.as_secs();
|
||||
self.expires_at <= current_timestamp + seconds
|
||||
} else {
|
||||
true // If we can't get current time, assume expiring soon for safety
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Authentication state for tracking connection status
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AuthState {
|
||||
/// Not authenticated
|
||||
NotAuthenticated,
|
||||
/// Currently authenticating
|
||||
Authenticating,
|
||||
/// Successfully authenticated
|
||||
Authenticated { public_key: String },
|
||||
/// Authentication failed
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Authentication method used
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AuthMethod {
|
||||
/// Private key authentication
|
||||
PrivateKey,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AuthMethod {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
AuthMethod::PrivateKey => write!(f, "Private Key"),
|
||||
}
|
||||
}
|
||||
}
|
994
interfaces/websocket/client/src/lib.rs
Normal file
994
interfaces/websocket/client/src/lib.rs
Normal file
@@ -0,0 +1,994 @@
|
||||
use futures_channel::{mpsc, oneshot};
|
||||
use futures_util::{FutureExt, SinkExt, StreamExt};
|
||||
use log::{debug, error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Authentication module
|
||||
pub mod auth;
|
||||
|
||||
pub use auth::{AuthCredentials, AuthError, AuthResult};
|
||||
|
||||
// Platform-specific WebSocket imports and spawn function
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use {
|
||||
gloo_net::websocket::{futures::WebSocket, Message as GlooWsMessage},
|
||||
wasm_bindgen_futures::spawn_local,
|
||||
};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use {
|
||||
tokio::spawn as spawn_local,
|
||||
tokio_tungstenite::{
|
||||
connect_async, connect_async_tls_with_config,
|
||||
tungstenite::{
|
||||
protocol::Message as TungsteniteWsMessage,
|
||||
},
|
||||
Connector,
|
||||
},
|
||||
};
|
||||
|
||||
// JSON-RPC Structures (client-side perspective)
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub struct JsonRpcRequestClient {
|
||||
jsonrpc: String,
|
||||
method: String,
|
||||
params: Value,
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct JsonRpcResponseClient {
|
||||
#[allow(dead_code)]
|
||||
// Field is part of JSON-RPC spec, even if not directly used by client logic
|
||||
jsonrpc: String,
|
||||
pub result: Option<Value>,
|
||||
pub error: Option<JsonRpcErrorClient>,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct JsonRpcErrorClient {
|
||||
pub code: i32,
|
||||
pub message: String,
|
||||
pub data: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub struct PlayParamsClient {
|
||||
pub script: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct PlayResultClient {
|
||||
pub output: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub struct AuthCredentialsParams {
|
||||
pub pubkey: String,
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub struct FetchNonceParams {
|
||||
pub pubkey: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct FetchNonceResponse {
|
||||
pub nonce: String,
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum CircleWsClientError {
|
||||
#[error("WebSocket connection error: {0}")]
|
||||
ConnectionError(String),
|
||||
#[error("WebSocket send error: {0}")]
|
||||
SendError(String),
|
||||
#[error("WebSocket receive error: {0}")]
|
||||
ReceiveError(String),
|
||||
#[error("JSON serialization/deserialization error: {0}")]
|
||||
JsonError(#[from] serde_json::Error),
|
||||
#[error("Request timed out for request ID: {0}")]
|
||||
Timeout(String),
|
||||
#[error("JSON-RPC error response: {code} - {message}")]
|
||||
JsonRpcError {
|
||||
code: i32,
|
||||
message: String,
|
||||
data: Option<Value>,
|
||||
},
|
||||
#[error("No response received for request ID: {0}")]
|
||||
NoResponse(String),
|
||||
#[error("Client is not connected")]
|
||||
NotConnected,
|
||||
#[error("Internal channel error: {0}")]
|
||||
ChannelError(String),
|
||||
#[error("Authentication error: {0}")]
|
||||
Auth(#[from] auth::AuthError),
|
||||
#[error("Authentication requires a keypair, but none was provided.")]
|
||||
AuthNoKeyPair,
|
||||
}
|
||||
|
||||
// Wrapper for messages sent to the WebSocket task
|
||||
enum InternalWsMessage {
|
||||
SendJsonRpc(
|
||||
JsonRpcRequestClient,
|
||||
oneshot::Sender<Result<JsonRpcResponseClient, CircleWsClientError>>,
|
||||
),
|
||||
SendPlaintext(
|
||||
String,
|
||||
oneshot::Sender<Result<String, CircleWsClientError>>,
|
||||
),
|
||||
Close,
|
||||
}
|
||||
|
||||
pub struct CircleWsClientBuilder {
|
||||
ws_url: String,
|
||||
private_key: Option<String>,
|
||||
}
|
||||
|
||||
impl CircleWsClientBuilder {
|
||||
pub fn new(ws_url: String) -> Self {
|
||||
Self {
|
||||
ws_url,
|
||||
private_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_keypair(mut self, private_key: String) -> Self {
|
||||
self.private_key = Some(private_key);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> CircleWsClient {
|
||||
CircleWsClient {
|
||||
ws_url: self.ws_url,
|
||||
internal_tx: None,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
task_handle: None,
|
||||
private_key: self.private_key,
|
||||
is_connected: Arc::new(Mutex::new(false)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CircleWsClient {
|
||||
ws_url: String,
|
||||
internal_tx: Option<mpsc::Sender<InternalWsMessage>>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
task_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
private_key: Option<String>,
|
||||
is_connected: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl CircleWsClient {
|
||||
/// Get the connection status
|
||||
pub fn get_connection_status(&self) -> String {
|
||||
if *self.is_connected.lock().unwrap() {
|
||||
"Connected".to_string()
|
||||
} else {
|
||||
"Disconnected".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the client is connected
|
||||
pub fn is_connected(&self) -> bool {
|
||||
*self.is_connected.lock().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl CircleWsClient {
|
||||
pub async fn authenticate(&mut self) -> Result<bool, CircleWsClientError> {
|
||||
info!("🔐 [{}] Starting authentication process...", self.ws_url);
|
||||
|
||||
let private_key = self
|
||||
.private_key
|
||||
.as_ref()
|
||||
.ok_or(CircleWsClientError::AuthNoKeyPair)?;
|
||||
|
||||
info!("🔑 [{}] Deriving public key from private key...", self.ws_url);
|
||||
let public_key = auth::derive_public_key(private_key)?;
|
||||
info!("✅ [{}] Public key derived: {}...", self.ws_url, &public_key[..8]);
|
||||
|
||||
info!("🎫 [{}] Fetching authentication nonce...", self.ws_url);
|
||||
let nonce = self.fetch_nonce(&public_key).await?;
|
||||
info!("✅ [{}] Nonce received: {}...", self.ws_url, &nonce[..8]);
|
||||
|
||||
info!("✍️ [{}] Signing nonce with private key...", self.ws_url);
|
||||
let signature = auth::sign_message(private_key, &nonce)?;
|
||||
info!("✅ [{}] Signature created: {}...", self.ws_url, &signature[..8]);
|
||||
|
||||
info!("🔒 [{}] Submitting authentication credentials...", self.ws_url);
|
||||
let result = self.authenticate_with_signature(&public_key, &signature).await?;
|
||||
|
||||
if result {
|
||||
info!("🎉 [{}] Authentication successful!", self.ws_url);
|
||||
} else {
|
||||
error!("❌ [{}] Authentication failed - server rejected credentials", self.ws_url);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn fetch_nonce(&self, pubkey: &str) -> Result<String, CircleWsClientError> {
|
||||
info!("📡 [{}] Sending fetch_nonce request for pubkey: {}...", self.ws_url, &pubkey[..8]);
|
||||
|
||||
let params = FetchNonceParams {
|
||||
pubkey: pubkey.to_string(),
|
||||
};
|
||||
let req = self.create_request("fetch_nonce", params)?;
|
||||
let res = self.send_request(req).await?;
|
||||
|
||||
if let Some(err) = res.error {
|
||||
error!("❌ [{}] fetch_nonce failed: {} (code: {})", self.ws_url, err.message, err.code);
|
||||
return Err(CircleWsClientError::JsonRpcError {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
data: err.data,
|
||||
});
|
||||
}
|
||||
|
||||
let nonce_res: FetchNonceResponse = serde_json::from_value(res.result.unwrap_or_default())?;
|
||||
info!("✅ [{}] fetch_nonce successful, nonce length: {}", self.ws_url, nonce_res.nonce.len());
|
||||
Ok(nonce_res.nonce)
|
||||
}
|
||||
|
||||
async fn authenticate_with_signature(
|
||||
&self,
|
||||
pubkey: &str,
|
||||
signature: &str,
|
||||
) -> Result<bool, CircleWsClientError> {
|
||||
info!("📡 [{}] Sending authenticate request with signature...", self.ws_url);
|
||||
|
||||
let params = AuthCredentialsParams {
|
||||
pubkey: pubkey.to_string(),
|
||||
signature: signature.to_string(),
|
||||
};
|
||||
let req = self.create_request("authenticate", params)?;
|
||||
let res = self.send_request(req).await?;
|
||||
|
||||
if let Some(err) = res.error {
|
||||
error!("❌ [{}] authenticate failed: {} (code: {})", self.ws_url, err.message, err.code);
|
||||
return Err(CircleWsClientError::JsonRpcError {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
data: err.data,
|
||||
});
|
||||
}
|
||||
|
||||
let authenticated = res
|
||||
.result
|
||||
.and_then(|v| v.get("authenticated").and_then(|v| v.as_bool()))
|
||||
.unwrap_or(false);
|
||||
|
||||
if authenticated {
|
||||
info!("✅ [{}] authenticate request successful - server confirmed authentication", self.ws_url);
|
||||
} else {
|
||||
error!("❌ [{}] authenticate request failed - server returned false", self.ws_url);
|
||||
}
|
||||
|
||||
Ok(authenticated)
|
||||
}
|
||||
|
||||
/// Call the whoami method to get authentication status and user information
|
||||
pub async fn whoami(&self) -> Result<Value, CircleWsClientError> {
|
||||
let req = self.create_request("whoami", serde_json::json!({}))?;
|
||||
let response = self.send_request(req).await?;
|
||||
|
||||
if let Some(result) = response.result {
|
||||
Ok(result)
|
||||
} else if let Some(error) = response.error {
|
||||
Err(CircleWsClientError::JsonRpcError {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
data: error.data,
|
||||
})
|
||||
} else {
|
||||
Err(CircleWsClientError::NoResponse("whoami".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn create_request<T: Serialize>(
|
||||
&self,
|
||||
method: &str,
|
||||
params: T,
|
||||
) -> Result<JsonRpcRequestClient, CircleWsClientError> {
|
||||
Ok(JsonRpcRequestClient {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
method: method.to_string(),
|
||||
params: serde_json::to_value(params)?,
|
||||
id: Uuid::new_v4().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn send_request(
|
||||
&self,
|
||||
req: JsonRpcRequestClient,
|
||||
) -> Result<JsonRpcResponseClient, CircleWsClientError> {
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
if let Some(mut tx) = self.internal_tx.clone() {
|
||||
tx.send(InternalWsMessage::SendJsonRpc(req.clone(), response_tx))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
CircleWsClientError::ChannelError(format!(
|
||||
"Failed to send request to internal task: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
} else {
|
||||
return Err(CircleWsClientError::NotConnected);
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
match response_rx.await {
|
||||
Ok(Ok(rpc_response)) => Ok(rpc_response),
|
||||
Ok(Err(e)) => Err(e),
|
||||
Err(_) => Err(CircleWsClientError::Timeout(req.id)),
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use tokio::time::timeout as tokio_timeout;
|
||||
match tokio_timeout(std::time::Duration::from_secs(30), response_rx).await {
|
||||
Ok(Ok(Ok(rpc_response))) => Ok(rpc_response),
|
||||
Ok(Ok(Err(e))) => Err(e),
|
||||
Ok(Err(_)) => Err(CircleWsClientError::ChannelError(
|
||||
"Response channel cancelled".to_string(),
|
||||
)),
|
||||
Err(_) => Err(CircleWsClientError::Timeout(req.id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn connect(&mut self) -> Result<(), CircleWsClientError> {
|
||||
if self.internal_tx.is_some() {
|
||||
info!("🔄 [{}] Client already connected or connecting", self.ws_url);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("🚀 [{}] Starting self-managed WebSocket connection with keep-alive and reconnection...", self.ws_url);
|
||||
let (internal_tx, internal_rx) = mpsc::channel::<InternalWsMessage>(32);
|
||||
self.internal_tx = Some(internal_tx);
|
||||
|
||||
// Clone necessary data for the task
|
||||
let connection_url = self.ws_url.clone();
|
||||
let private_key = self.private_key.clone();
|
||||
let is_connected = self.is_connected.clone();
|
||||
info!("🔗 [{}] Will handle connection, authentication, keep-alive, and reconnection internally", connection_url);
|
||||
|
||||
// Pending requests: map request_id to a oneshot sender for the response
|
||||
let pending_requests: Arc<
|
||||
Mutex<
|
||||
HashMap<
|
||||
String,
|
||||
oneshot::Sender<Result<JsonRpcResponseClient, CircleWsClientError>>,
|
||||
>,
|
||||
>,
|
||||
> = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
let task_pending_requests = pending_requests.clone();
|
||||
let log_url = connection_url.clone();
|
||||
|
||||
let task = async move {
|
||||
// Main connection loop with reconnection logic
|
||||
loop {
|
||||
info!("🔄 [{}] Starting connection attempt...", log_url);
|
||||
|
||||
// Reset connection status
|
||||
*is_connected.lock().unwrap() = false;
|
||||
|
||||
// Clone connection_url for this iteration to avoid move issues
|
||||
let connection_url_clone = connection_url.clone();
|
||||
|
||||
// Establish WebSocket connection
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let ws_result = WebSocket::open(&connection_url_clone);
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let connect_attempt = async {
|
||||
// Check if this is a secure WebSocket connection
|
||||
if connection_url_clone.starts_with("wss://") {
|
||||
// For WSS connections, use a custom TLS connector that accepts self-signed certificates
|
||||
// This is for development/demo purposes only
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
|
||||
let request = connection_url_clone.as_str().into_client_request()
|
||||
.map_err(|e| CircleWsClientError::ConnectionError(format!("Invalid URL: {}", e)))?;
|
||||
|
||||
// Create a native-tls connector that accepts invalid certificates (for development)
|
||||
let tls_connector = native_tls::TlsConnector::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.danger_accept_invalid_hostnames(true)
|
||||
.build()
|
||||
.map_err(|e| CircleWsClientError::ConnectionError(format!("TLS connector creation failed: {}", e)))?;
|
||||
|
||||
let connector = Connector::NativeTls(tls_connector);
|
||||
|
||||
warn!("⚠️ DEVELOPMENT MODE: Accepting self-signed certificates (NOT for production!)");
|
||||
connect_async_tls_with_config(request, None, false, Some(connector))
|
||||
.await
|
||||
.map_err(|e| CircleWsClientError::ConnectionError(format!("WSS connection failed: {}", e)))
|
||||
} else {
|
||||
// For regular WS connections, use the standard method
|
||||
connect_async(&connection_url_clone)
|
||||
.await
|
||||
.map_err(|e| CircleWsClientError::ConnectionError(format!("WS connection failed: {}", e)))
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let ws_result = connect_attempt.await;
|
||||
|
||||
match ws_result {
|
||||
Ok(ws_conn_maybe_response) => {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let ws_conn = ws_conn_maybe_response;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let (ws_conn, _) = ws_conn_maybe_response;
|
||||
|
||||
// For WASM, WebSocket::open() always succeeds even if server is down
|
||||
// We'll start as "connecting" and detect failures through timeouts
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
info!("🔄 [{}] WebSocket object created, testing actual connectivity...", log_url);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
info!("✅ [{}] WebSocket connection established successfully", log_url);
|
||||
*is_connected.lock().unwrap() = true;
|
||||
}
|
||||
|
||||
// Handle authentication if private key is provided
|
||||
let auth_success = if let Some(ref _pk) = private_key {
|
||||
info!("🔐 [{}] Authentication will be handled by separate authenticate() call", log_url);
|
||||
true // For now, assume auth will be handled separately
|
||||
} else {
|
||||
info!("ℹ️ [{}] No private key provided, skipping authentication", log_url);
|
||||
true
|
||||
};
|
||||
|
||||
if auth_success {
|
||||
// Start the main message handling loop with keep-alive
|
||||
let disconnect_reason = Self::handle_connection_with_keepalive(
|
||||
ws_conn,
|
||||
internal_rx,
|
||||
&task_pending_requests,
|
||||
&log_url,
|
||||
&is_connected
|
||||
).await;
|
||||
|
||||
info!("🔌 [{}] Connection ended: {}", log_url, disconnect_reason);
|
||||
|
||||
// Check if this was a manual disconnect
|
||||
if disconnect_reason == "Manual close requested" {
|
||||
break; // Don't reconnect on manual close
|
||||
}
|
||||
|
||||
// If we reach here, we need to recreate internal_rx for the next iteration
|
||||
// But since internal_rx was moved, we need to break out of the loop
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("❌ [{}] WebSocket connection failed: {:?}", log_url, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset connection status
|
||||
*is_connected.lock().unwrap() = false;
|
||||
|
||||
// Wait before reconnecting
|
||||
info!("⏳ [{}] Waiting 5 seconds before reconnection attempt...", log_url);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
use gloo_timers::future::TimeoutFuture;
|
||||
TimeoutFuture::new(5_000).await;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup pending requests on exit
|
||||
task_pending_requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.drain()
|
||||
.for_each(|(_, sender)| {
|
||||
let _ = sender.send(Err(CircleWsClientError::ConnectionError(
|
||||
"WebSocket task terminated".to_string(),
|
||||
)));
|
||||
});
|
||||
|
||||
info!("🏁 [{}] WebSocket task finished", log_url);
|
||||
};
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
spawn_local(task);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
self.task_handle = Some(spawn_local(task));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Enhanced connection loop handler with keep-alive
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
async fn handle_connection_with_keepalive(
|
||||
ws_conn: WebSocket,
|
||||
mut internal_rx: mpsc::Receiver<InternalWsMessage>,
|
||||
pending_requests: &Arc<Mutex<HashMap<String, oneshot::Sender<Result<JsonRpcResponseClient, CircleWsClientError>>>>>,
|
||||
log_url: &str,
|
||||
is_connected: &Arc<Mutex<bool>>,
|
||||
) -> String {
|
||||
let (mut ws_tx, mut ws_rx) = ws_conn.split();
|
||||
let mut internal_rx_fused = internal_rx.fuse();
|
||||
|
||||
// Track plaintext requests (like ping)
|
||||
let pending_plaintext: Arc<Mutex<HashMap<String, oneshot::Sender<Result<String, CircleWsClientError>>>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
// Connection validation for WASM - test if connection actually works
|
||||
let mut connection_test_timer = TimeoutFuture::new(2_000).fuse(); // 2 second timeout
|
||||
let mut connection_validated = false;
|
||||
|
||||
// Keep-alive timer - send ping every 30 seconds
|
||||
use gloo_timers::future::TimeoutFuture;
|
||||
let mut keep_alive_timer = TimeoutFuture::new(30_000).fuse();
|
||||
|
||||
// Send initial connection test ping
|
||||
debug!("Sending initial connection test ping to {}", log_url);
|
||||
let test_ping_res = ws_tx.send(GlooWsMessage::Text("ping".to_string())).await;
|
||||
if let Err(e) = test_ping_res {
|
||||
error!("❌ [{}] Initial connection test failed: {:?}", log_url, e);
|
||||
*is_connected.lock().unwrap() = false;
|
||||
return format!("Initial connection test failed: {}", e);
|
||||
}
|
||||
|
||||
loop {
|
||||
futures_util::select! {
|
||||
// Connection test timeout - if no response in 2 seconds, connection failed
|
||||
_ = connection_test_timer => {
|
||||
if !connection_validated {
|
||||
error!("❌ [{}] Connection test failed - no response within 2 seconds", log_url);
|
||||
*is_connected.lock().unwrap() = false;
|
||||
return "Connection test timeout - server not responding".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle messages from the client's public methods (e.g., play)
|
||||
internal_msg = internal_rx_fused.next().fuse() => {
|
||||
match internal_msg {
|
||||
Some(InternalWsMessage::SendJsonRpc(req, response_sender)) => {
|
||||
let req_id = req.id.clone();
|
||||
match serde_json::to_string(&req) {
|
||||
Ok(req_str) => {
|
||||
debug!("Sending JSON-RPC request (ID: {}): {}", req_id, req_str);
|
||||
let send_res = ws_tx.send(GlooWsMessage::Text(req_str)).await;
|
||||
if let Err(e) = send_res {
|
||||
error!("WebSocket send error for request ID {}: {:?}", req_id, e);
|
||||
// Connection failed - update status
|
||||
*is_connected.lock().unwrap() = false;
|
||||
let _ = response_sender.send(Err(CircleWsClientError::SendError(e.to_string())));
|
||||
} else {
|
||||
// Store the sender to await the response
|
||||
pending_requests.lock().unwrap().insert(req_id, response_sender);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to serialize request ID {}: {}", req_id, e);
|
||||
let _ = response_sender.send(Err(CircleWsClientError::JsonError(e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(InternalWsMessage::SendPlaintext(text, response_sender)) => {
|
||||
debug!("Sending plaintext message: {}", text);
|
||||
let send_res = ws_tx.send(GlooWsMessage::Text(text.clone())).await;
|
||||
if let Err(e) = send_res {
|
||||
error!("WebSocket send error for plaintext message: {:?}", e);
|
||||
*is_connected.lock().unwrap() = false;
|
||||
let _ = response_sender.send(Err(CircleWsClientError::SendError(e.to_string())));
|
||||
} else {
|
||||
// For plaintext messages like ping, we expect an immediate response
|
||||
// Store the response sender to await the response (e.g., pong)
|
||||
let request_id = format!("plaintext_{}", uuid::Uuid::new_v4());
|
||||
pending_plaintext.lock().unwrap().insert(request_id, response_sender);
|
||||
}
|
||||
}
|
||||
Some(InternalWsMessage::Close) => {
|
||||
info!("Close message received internally, closing WebSocket.");
|
||||
let _ = ws_tx.close().await;
|
||||
return "Manual close requested".to_string();
|
||||
}
|
||||
None => {
|
||||
info!("Internal MPSC channel closed, WebSocket task shutting down.");
|
||||
let _ = ws_tx.close().await;
|
||||
return "Internal channel closed".to_string();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Handle messages received from the WebSocket server
|
||||
ws_msg_res = ws_rx.next().fuse() => {
|
||||
match ws_msg_res {
|
||||
Some(Ok(msg)) => {
|
||||
// Any successful message confirms the connection is working
|
||||
if !connection_validated {
|
||||
info!("✅ [{}] WebSocket connection validated - received message from server", log_url);
|
||||
*is_connected.lock().unwrap() = true;
|
||||
connection_validated = true;
|
||||
}
|
||||
|
||||
match msg {
|
||||
GlooWsMessage::Text(text) => {
|
||||
debug!("Received WebSocket message: {}", text);
|
||||
Self::handle_received_message(&text, pending_requests, &pending_plaintext);
|
||||
}
|
||||
GlooWsMessage::Bytes(_) => {
|
||||
debug!("Received binary WebSocket message (WASM).");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
error!("WebSocket receive error: {:?}", e);
|
||||
*is_connected.lock().unwrap() = false;
|
||||
return format!("Receive error: {}", e);
|
||||
}
|
||||
None => {
|
||||
info!("WebSocket connection closed by server (stream ended).");
|
||||
*is_connected.lock().unwrap() = false;
|
||||
return "Server closed connection (stream ended)".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep-alive timer - send ping every 30 seconds
|
||||
_ = keep_alive_timer => {
|
||||
// Only send ping if connection is validated
|
||||
if connection_validated {
|
||||
debug!("Sending keep-alive ping to {}", log_url);
|
||||
let ping_str = "ping"; // Send simple plaintext ping
|
||||
|
||||
let send_res = ws_tx.send(GlooWsMessage::Text(ping_str.to_string())).await;
|
||||
if let Err(e) = send_res {
|
||||
warn!("Keep-alive ping failed for {}: {:?}", log_url, e);
|
||||
*is_connected.lock().unwrap() = false;
|
||||
return format!("Keep-alive failed: {}", e);
|
||||
}
|
||||
} else {
|
||||
debug!("Skipping keep-alive ping - connection not yet validated for {}", log_url);
|
||||
}
|
||||
|
||||
// Reset timer
|
||||
keep_alive_timer = TimeoutFuture::new(30_000).fuse();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced connection loop handler with keep-alive for native targets
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
async fn handle_connection_with_keepalive(
|
||||
ws_conn: tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>,
|
||||
mut internal_rx: mpsc::Receiver<InternalWsMessage>,
|
||||
pending_requests: &Arc<Mutex<HashMap<String, oneshot::Sender<Result<JsonRpcResponseClient, CircleWsClientError>>>>>,
|
||||
log_url: &str,
|
||||
_is_connected: &Arc<Mutex<bool>>,
|
||||
) -> String {
|
||||
let (mut ws_tx, mut ws_rx) = ws_conn.split();
|
||||
let mut internal_rx_fused = internal_rx.fuse();
|
||||
|
||||
// Track plaintext requests (like ping)
|
||||
let pending_plaintext: Arc<Mutex<HashMap<String, oneshot::Sender<Result<String, CircleWsClientError>>>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
loop {
|
||||
futures_util::select! {
|
||||
// Handle messages from the client's public methods (e.g., play)
|
||||
internal_msg = internal_rx_fused.next().fuse() => {
|
||||
match internal_msg {
|
||||
Some(InternalWsMessage::SendJsonRpc(req, response_sender)) => {
|
||||
let req_id = req.id.clone();
|
||||
match serde_json::to_string(&req) {
|
||||
Ok(req_str) => {
|
||||
debug!("Sending JSON-RPC request (ID: {}): {}", req_id, req_str);
|
||||
let send_res = ws_tx.send(TungsteniteWsMessage::Text(req_str)).await;
|
||||
if let Err(e) = send_res {
|
||||
error!("WebSocket send error for request ID {}: {:?}", req_id, e);
|
||||
let _ = response_sender.send(Err(CircleWsClientError::SendError(e.to_string())));
|
||||
} else {
|
||||
// Store the sender to await the response
|
||||
pending_requests.lock().unwrap().insert(req_id, response_sender);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to serialize request ID {}: {}", req_id, e);
|
||||
let _ = response_sender.send(Err(CircleWsClientError::JsonError(e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(InternalWsMessage::SendPlaintext(text, response_sender)) => {
|
||||
debug!("Sending plaintext message: {}", text);
|
||||
let send_res = ws_tx.send(TungsteniteWsMessage::Text(text.clone())).await;
|
||||
if let Err(e) = send_res {
|
||||
error!("WebSocket send error for plaintext message: {:?}", e);
|
||||
let _ = response_sender.send(Err(CircleWsClientError::SendError(e.to_string())));
|
||||
} else {
|
||||
// For plaintext messages like ping, we expect an immediate response
|
||||
// Store the response sender to await the response (e.g., pong)
|
||||
let request_id = format!("plaintext_{}", uuid::Uuid::new_v4());
|
||||
pending_plaintext.lock().unwrap().insert(request_id, response_sender);
|
||||
}
|
||||
}
|
||||
Some(InternalWsMessage::Close) => {
|
||||
info!("Close message received internally, closing WebSocket.");
|
||||
let _ = ws_tx.close().await;
|
||||
return "Manual close requested".to_string();
|
||||
}
|
||||
None => {
|
||||
info!("Internal MPSC channel closed, WebSocket task shutting down.");
|
||||
let _ = ws_tx.close().await;
|
||||
return "Internal channel closed".to_string();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Handle messages received from the WebSocket server
|
||||
ws_msg_res = ws_rx.next().fuse() => {
|
||||
match ws_msg_res {
|
||||
Some(Ok(msg)) => {
|
||||
match msg {
|
||||
TungsteniteWsMessage::Text(text) => {
|
||||
debug!("Received WebSocket message: {}", text);
|
||||
Self::handle_received_message(&text, pending_requests, &pending_plaintext);
|
||||
}
|
||||
TungsteniteWsMessage::Binary(_) => {
|
||||
debug!("Received binary WebSocket message (Native).");
|
||||
}
|
||||
TungsteniteWsMessage::Ping(_) | TungsteniteWsMessage::Pong(_) => {
|
||||
debug!("Received Ping/Pong (Native).");
|
||||
}
|
||||
TungsteniteWsMessage::Close(_) => {
|
||||
info!("WebSocket connection closed by server (Native).");
|
||||
return "Server closed connection".to_string();
|
||||
}
|
||||
TungsteniteWsMessage::Frame(_) => {
|
||||
debug!("Received Frame (Native) - not typically handled directly.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
error!("WebSocket receive error: {:?}", e);
|
||||
return format!("Receive error: {}", e);
|
||||
}
|
||||
None => {
|
||||
info!("WebSocket connection closed by server (stream ended).");
|
||||
return "Server closed connection (stream ended)".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to handle received messages
|
||||
fn handle_received_message(
|
||||
text: &str,
|
||||
pending_requests: &Arc<Mutex<HashMap<String, oneshot::Sender<Result<JsonRpcResponseClient, CircleWsClientError>>>>>,
|
||||
pending_plaintext: &Arc<Mutex<HashMap<String, oneshot::Sender<Result<String, CircleWsClientError>>>>>,
|
||||
) {
|
||||
// Handle ping/pong messages - these are not JSON-RPC
|
||||
if text.trim() == "pong" {
|
||||
debug!("Received pong response");
|
||||
// Find and respond to any pending plaintext ping requests
|
||||
let mut plaintext_map = pending_plaintext.lock().unwrap();
|
||||
if let Some((_, sender)) = plaintext_map.drain().next() {
|
||||
let _ = sender.send(Ok("pong".to_string()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
match serde_json::from_str::<JsonRpcResponseClient>(text) {
|
||||
Ok(response) => {
|
||||
if let Some(sender) = pending_requests.lock().unwrap().remove(&response.id) {
|
||||
if let Err(failed_send_val) = sender.send(Ok(response)) {
|
||||
if let Ok(resp_for_log) = failed_send_val {
|
||||
warn!("Failed to send response to waiting task for ID: {}", resp_for_log.id);
|
||||
} else {
|
||||
warn!("Failed to send response to waiting task, and also failed to get original response for logging.");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("Received response for unknown request ID or unsolicited message: {:?}", response);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to parse JSON-RPC response: {}. Raw: {}", e, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn play(
|
||||
&self,
|
||||
script: String,
|
||||
) -> impl std::future::Future<Output = Result<PlayResultClient, CircleWsClientError>> + Send + 'static
|
||||
{
|
||||
let req_id_outer = Uuid::new_v4().to_string();
|
||||
|
||||
// Clone the sender option. The sender itself (mpsc::Sender) is also Clone.
|
||||
let internal_tx_clone_opt = self.internal_tx.clone();
|
||||
|
||||
async move {
|
||||
let req_id = req_id_outer; // Move req_id into the async block
|
||||
let params = PlayParamsClient { script }; // script is moved in
|
||||
|
||||
let request = match serde_json::to_value(params) {
|
||||
Ok(p_val) => JsonRpcRequestClient {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
method: "play".to_string(),
|
||||
params: p_val,
|
||||
id: req_id.clone(),
|
||||
},
|
||||
Err(e) => return Err(CircleWsClientError::JsonError(e)),
|
||||
};
|
||||
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
|
||||
if let Some(mut internal_tx) = internal_tx_clone_opt {
|
||||
internal_tx
|
||||
.send(InternalWsMessage::SendJsonRpc(request, response_tx))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
CircleWsClientError::ChannelError(format!(
|
||||
"Failed to send request to internal task: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
} else {
|
||||
return Err(CircleWsClientError::NotConnected);
|
||||
}
|
||||
|
||||
// Add a timeout for waiting for the response
|
||||
// For simplicity, using a fixed timeout here. Could be configurable.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
match response_rx.await {
|
||||
Ok(Ok(rpc_response)) => {
|
||||
if let Some(json_rpc_error) = rpc_response.error {
|
||||
Err(CircleWsClientError::JsonRpcError {
|
||||
code: json_rpc_error.code,
|
||||
message: json_rpc_error.message,
|
||||
data: json_rpc_error.data,
|
||||
})
|
||||
} else if let Some(result_value) = rpc_response.result {
|
||||
serde_json::from_value(result_value)
|
||||
.map_err(CircleWsClientError::JsonError)
|
||||
} else {
|
||||
Err(CircleWsClientError::NoResponse(req_id.clone()))
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => Err(e), // Error propagated from the ws task
|
||||
Err(_) => Err(CircleWsClientError::Timeout(req_id.clone())), // oneshot channel cancelled
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use tokio::time::timeout as tokio_timeout;
|
||||
match tokio_timeout(std::time::Duration::from_secs(10), response_rx).await {
|
||||
Ok(Ok(Ok(rpc_response))) => {
|
||||
// Timeout -> Result<ChannelRecvResult, Error>
|
||||
if let Some(json_rpc_error) = rpc_response.error {
|
||||
Err(CircleWsClientError::JsonRpcError {
|
||||
code: json_rpc_error.code,
|
||||
message: json_rpc_error.message,
|
||||
data: json_rpc_error.data,
|
||||
})
|
||||
} else if let Some(result_value) = rpc_response.result {
|
||||
serde_json::from_value(result_value)
|
||||
.map_err(CircleWsClientError::JsonError)
|
||||
} else {
|
||||
Err(CircleWsClientError::NoResponse(req_id.clone()))
|
||||
}
|
||||
}
|
||||
Ok(Ok(Err(e))) => Err(e), // Error propagated from the ws task
|
||||
Ok(Err(_)) => Err(CircleWsClientError::ChannelError(
|
||||
"Response channel cancelled".to_string(),
|
||||
)), // oneshot cancelled
|
||||
Err(_) => Err(CircleWsClientError::Timeout(req_id.clone())), // tokio_timeout expired
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a plaintext ping message and wait for pong response
|
||||
pub async fn ping(&mut self) -> Result<String, CircleWsClientError> {
|
||||
if let Some(mut tx) = self.internal_tx.clone() {
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
|
||||
// Send plaintext ping message
|
||||
tx.send(InternalWsMessage::SendPlaintext("ping".to_string(), response_tx))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
CircleWsClientError::ChannelError(format!(
|
||||
"Failed to send ping request to internal task: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
// Wait for pong response with timeout
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
match response_rx.await {
|
||||
Ok(Ok(response)) => Ok(response),
|
||||
Ok(Err(e)) => Err(e),
|
||||
Err(_) => Err(CircleWsClientError::ChannelError(
|
||||
"Ping response channel cancelled".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use tokio::time::timeout as tokio_timeout;
|
||||
match tokio_timeout(std::time::Duration::from_secs(10), response_rx).await {
|
||||
Ok(Ok(Ok(response))) => Ok(response),
|
||||
Ok(Ok(Err(e))) => Err(e),
|
||||
Ok(Err(_)) => Err(CircleWsClientError::ChannelError(
|
||||
"Ping response channel cancelled".to_string(),
|
||||
)),
|
||||
Err(_) => Err(CircleWsClientError::Timeout("ping".to_string())),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err(CircleWsClientError::NotConnected)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn disconnect(&mut self) {
|
||||
if let Some(mut tx) = self.internal_tx.take() {
|
||||
info!("Sending close signal to internal WebSocket task.");
|
||||
let _ = tx.send(InternalWsMessage::Close).await;
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(handle) = self.task_handle.take() {
|
||||
let _ = handle.await; // Wait for the task to finish
|
||||
}
|
||||
info!("Client disconnected.");
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure client cleans up on drop for native targets
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl Drop for CircleWsClient {
|
||||
fn drop(&mut self) {
|
||||
if self.internal_tx.is_some() || self.task_handle.is_some() {
|
||||
warn!("CircleWsClient dropped without explicit disconnect. Spawning task to send close signal.");
|
||||
// We can't call async disconnect directly in drop.
|
||||
// Spawn a new task to send the close message if on native.
|
||||
if let Some(mut tx) = self.internal_tx.take() {
|
||||
spawn_local(async move {
|
||||
info!("Drop: Sending close signal to internal WebSocket task.");
|
||||
let _ = tx.send(InternalWsMessage::Close).await;
|
||||
});
|
||||
}
|
||||
if let Some(handle) = self.task_handle.take() {
|
||||
spawn_local(async move {
|
||||
info!("Drop: Waiting for WebSocket task to finish.");
|
||||
let _ = handle.await;
|
||||
info!("Drop: WebSocket task finished.");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
// use super::*;
|
||||
#[test]
|
||||
fn it_compiles() {
|
||||
assert_eq!(2 + 2, 4);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user