Add Synor Storage and Wallet SDKs for Swift
- Implement SynorStorage class for decentralized storage operations including upload, download, pinning, and CAR file management. - Create supporting types and models for storage operations such as UploadOptions, Pin, and StorageConfig. - Implement SynorWallet class for wallet operations including wallet creation, address generation, transaction signing, and balance queries. - Create supporting types and models for wallet operations such as Wallet, Address, and Transaction. - Introduce error handling for both storage and wallet operations.
This commit is contained in:
parent
59a7123535
commit
74b82d2bb2
113 changed files with 26722 additions and 531 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
cmake_minimum_required(VERSION 3.16)
|
cmake_minimum_required(VERSION 3.16)
|
||||||
project(synor_compute VERSION 0.1.0 LANGUAGES C)
|
project(synor_sdk VERSION 0.1.0 LANGUAGES C)
|
||||||
|
|
||||||
set(CMAKE_C_STANDARD 11)
|
set(CMAKE_C_STANDARD 11)
|
||||||
set(CMAKE_C_STANDARD_REQUIRED ON)
|
set(CMAKE_C_STANDARD_REQUIRED ON)
|
||||||
|
|
@ -11,51 +11,90 @@ option(BUILD_EXAMPLES "Build examples" ON)
|
||||||
|
|
||||||
# Find dependencies
|
# Find dependencies
|
||||||
find_package(CURL REQUIRED)
|
find_package(CURL REQUIRED)
|
||||||
|
find_package(PkgConfig QUIET)
|
||||||
|
if(PkgConfig_FOUND)
|
||||||
|
pkg_check_modules(JANSSON jansson)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# If jansson not found via pkg-config, try find_library
|
||||||
|
if(NOT JANSSON_FOUND)
|
||||||
|
find_library(JANSSON_LIBRARIES jansson)
|
||||||
|
find_path(JANSSON_INCLUDE_DIRS jansson.h)
|
||||||
|
if(JANSSON_LIBRARIES AND JANSSON_INCLUDE_DIRS)
|
||||||
|
set(JANSSON_FOUND TRUE)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(NOT JANSSON_FOUND)
|
||||||
|
message(WARNING "jansson library not found. Using bundled JSON parser.")
|
||||||
|
set(USE_BUNDLED_JSON TRUE)
|
||||||
|
endif()
|
||||||
|
|
||||||
# Library sources
|
# Library sources
|
||||||
set(SYNOR_SOURCES
|
set(SYNOR_SOURCES
|
||||||
src/synor_compute.c
|
src/synor_compute.c
|
||||||
|
src/common.c
|
||||||
|
src/wallet/wallet.c
|
||||||
|
src/rpc/rpc.c
|
||||||
|
src/storage/storage.c
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create library
|
# Create library
|
||||||
add_library(synor_compute ${SYNOR_SOURCES})
|
add_library(synor_sdk ${SYNOR_SOURCES})
|
||||||
|
|
||||||
target_include_directories(synor_compute
|
target_include_directories(synor_sdk
|
||||||
PUBLIC
|
PUBLIC
|
||||||
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
|
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
|
||||||
$<INSTALL_INTERFACE:include>
|
$<INSTALL_INTERFACE:include>
|
||||||
|
PRIVATE
|
||||||
|
${JANSSON_INCLUDE_DIRS}
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(synor_compute
|
target_link_libraries(synor_sdk
|
||||||
PRIVATE
|
PRIVATE
|
||||||
CURL::libcurl
|
CURL::libcurl
|
||||||
m
|
m
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if(JANSSON_FOUND)
|
||||||
|
target_link_libraries(synor_sdk PRIVATE ${JANSSON_LIBRARIES})
|
||||||
|
target_compile_definitions(synor_sdk PRIVATE HAVE_JANSSON)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Platform-specific settings
|
||||||
|
if(WIN32)
|
||||||
|
target_link_libraries(synor_sdk PRIVATE ws2_32)
|
||||||
|
endif()
|
||||||
|
|
||||||
# Set library properties
|
# Set library properties
|
||||||
set_target_properties(synor_compute PROPERTIES
|
set_target_properties(synor_sdk PROPERTIES
|
||||||
VERSION ${PROJECT_VERSION}
|
VERSION ${PROJECT_VERSION}
|
||||||
SOVERSION ${PROJECT_VERSION_MAJOR}
|
SOVERSION ${PROJECT_VERSION_MAJOR}
|
||||||
PUBLIC_HEADER include/synor_compute.h
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
include(GNUInstallDirs)
|
include(GNUInstallDirs)
|
||||||
|
|
||||||
install(TARGETS synor_compute
|
install(TARGETS synor_sdk
|
||||||
EXPORT synor_compute-targets
|
EXPORT synor_sdk-targets
|
||||||
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
||||||
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
||||||
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
||||||
PUBLIC_HEADER DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
install(EXPORT synor_compute-targets
|
install(DIRECTORY include/
|
||||||
FILE synor_compute-targets.cmake
|
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
|
||||||
NAMESPACE synor::
|
|
||||||
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/synor_compute
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
install(EXPORT synor_sdk-targets
|
||||||
|
FILE synor_sdk-targets.cmake
|
||||||
|
NAMESPACE synor::
|
||||||
|
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/synor_sdk
|
||||||
|
)
|
||||||
|
|
||||||
|
# Alias for backwards compatibility
|
||||||
|
add_library(synor_compute ALIAS synor_sdk)
|
||||||
|
|
||||||
# Tests
|
# Tests
|
||||||
if(BUILD_TESTS)
|
if(BUILD_TESTS)
|
||||||
enable_testing()
|
enable_testing()
|
||||||
|
|
|
||||||
269
sdk/c/include/synor/rpc.h
Normal file
269
sdk/c/include/synor/rpc.h
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
/**
|
||||||
|
* @file rpc.h
|
||||||
|
* @brief Synor RPC SDK for C
|
||||||
|
*
|
||||||
|
* Provides blockchain queries, transaction submission, and real-time
|
||||||
|
* subscriptions for the Synor blockchain.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef SYNOR_RPC_H
|
||||||
|
#define SYNOR_RPC_H
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include "wallet.h" /* For synor_error_t, synor_network_t, synor_priority_t */
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Transaction status */
|
||||||
|
typedef enum {
|
||||||
|
SYNOR_TX_STATUS_PENDING = 0,
|
||||||
|
SYNOR_TX_STATUS_CONFIRMED,
|
||||||
|
SYNOR_TX_STATUS_FAILED
|
||||||
|
} synor_tx_status_t;
|
||||||
|
|
||||||
|
/* RPC configuration */
|
||||||
|
typedef struct {
|
||||||
|
const char* api_key;
|
||||||
|
const char* endpoint; /* Default: "https://rpc.synor.io/v1" */
|
||||||
|
const char* ws_endpoint; /* Default: "wss://rpc.synor.io/v1/ws" */
|
||||||
|
synor_network_t network; /* Default: SYNOR_NETWORK_MAINNET */
|
||||||
|
uint32_t timeout_ms; /* Default: 30000 */
|
||||||
|
uint32_t retries; /* Default: 3 */
|
||||||
|
bool debug; /* Default: false */
|
||||||
|
} synor_rpc_config_t;
|
||||||
|
|
||||||
|
/* Opaque RPC client handle */
|
||||||
|
typedef struct synor_rpc synor_rpc_t;
|
||||||
|
|
||||||
|
/* Block header */
|
||||||
|
typedef struct {
|
||||||
|
char* hash;
|
||||||
|
int64_t height;
|
||||||
|
char* previous_hash;
|
||||||
|
int64_t timestamp;
|
||||||
|
int32_t version;
|
||||||
|
char* merkle_root;
|
||||||
|
int64_t nonce;
|
||||||
|
double difficulty;
|
||||||
|
} synor_block_header_t;
|
||||||
|
|
||||||
|
/* Script pubkey */
|
||||||
|
typedef struct {
|
||||||
|
char* asm_str;
|
||||||
|
char* hex;
|
||||||
|
char* type;
|
||||||
|
char* address; /* May be NULL */
|
||||||
|
} synor_script_pubkey_t;
|
||||||
|
|
||||||
|
/* Transaction input */
|
||||||
|
typedef struct {
|
||||||
|
char* txid;
|
||||||
|
int32_t vout;
|
||||||
|
char* script_sig; /* May be NULL */
|
||||||
|
int64_t sequence;
|
||||||
|
char** witness;
|
||||||
|
size_t witness_count;
|
||||||
|
} synor_rpc_tx_input_t;
|
||||||
|
|
||||||
|
/* Transaction output */
|
||||||
|
typedef struct {
|
||||||
|
int64_t value;
|
||||||
|
int32_t n;
|
||||||
|
synor_script_pubkey_t script_pubkey;
|
||||||
|
} synor_rpc_tx_output_t;
|
||||||
|
|
||||||
|
/* Transaction */
|
||||||
|
typedef struct {
|
||||||
|
char* txid;
|
||||||
|
char* hash;
|
||||||
|
int32_t version;
|
||||||
|
int32_t size;
|
||||||
|
int32_t weight;
|
||||||
|
int64_t lock_time;
|
||||||
|
synor_rpc_tx_input_t* inputs;
|
||||||
|
size_t input_count;
|
||||||
|
synor_rpc_tx_output_t* outputs;
|
||||||
|
size_t output_count;
|
||||||
|
int64_t fee;
|
||||||
|
int32_t confirmations;
|
||||||
|
char* block_hash; /* May be NULL */
|
||||||
|
int64_t block_height; /* -1 if not in block */
|
||||||
|
int64_t timestamp; /* -1 if unknown */
|
||||||
|
synor_tx_status_t status;
|
||||||
|
} synor_rpc_transaction_t;
|
||||||
|
|
||||||
|
/* Block */
|
||||||
|
typedef struct {
|
||||||
|
char* hash;
|
||||||
|
int64_t height;
|
||||||
|
char* previous_hash;
|
||||||
|
int64_t timestamp;
|
||||||
|
int32_t version;
|
||||||
|
char* merkle_root;
|
||||||
|
int64_t nonce;
|
||||||
|
double difficulty;
|
||||||
|
synor_rpc_transaction_t* transactions;
|
||||||
|
size_t transaction_count;
|
||||||
|
int32_t size;
|
||||||
|
int32_t weight;
|
||||||
|
} synor_block_t;
|
||||||
|
|
||||||
|
/* Chain info */
|
||||||
|
typedef struct {
|
||||||
|
char* chain;
|
||||||
|
int64_t blocks;
|
||||||
|
int64_t headers;
|
||||||
|
char* best_block_hash;
|
||||||
|
double difficulty;
|
||||||
|
int64_t median_time;
|
||||||
|
double verification_progress;
|
||||||
|
char* chain_work;
|
||||||
|
bool pruned;
|
||||||
|
} synor_chain_info_t;
|
||||||
|
|
||||||
|
/* Mempool info */
|
||||||
|
typedef struct {
|
||||||
|
int32_t size;
|
||||||
|
int64_t bytes;
|
||||||
|
int64_t usage;
|
||||||
|
int64_t max_mempool;
|
||||||
|
double mempool_min_fee;
|
||||||
|
double min_relay_tx_fee;
|
||||||
|
} synor_mempool_info_t;
|
||||||
|
|
||||||
|
/* Fee estimate */
|
||||||
|
typedef struct {
|
||||||
|
synor_priority_t priority;
|
||||||
|
int64_t fee_rate;
|
||||||
|
int32_t estimated_blocks;
|
||||||
|
} synor_fee_estimate_t;
|
||||||
|
|
||||||
|
/* Submit result */
|
||||||
|
typedef struct {
|
||||||
|
char* txid;
|
||||||
|
bool accepted;
|
||||||
|
char* reason; /* May be NULL */
|
||||||
|
} synor_submit_result_t;
|
||||||
|
|
||||||
|
/* Subscription handle */
|
||||||
|
typedef struct synor_subscription synor_subscription_t;
|
||||||
|
|
||||||
|
/* Subscription callbacks */
|
||||||
|
typedef void (*synor_block_callback_t)(const synor_block_t* block, void* user_data);
|
||||||
|
typedef void (*synor_tx_callback_t)(const synor_rpc_transaction_t* tx, void* user_data);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new RPC client.
|
||||||
|
*/
|
||||||
|
synor_rpc_t* synor_rpc_create(const synor_rpc_config_t* config);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy RPC client and free resources.
|
||||||
|
*/
|
||||||
|
void synor_rpc_destroy(synor_rpc_t* rpc);
|
||||||
|
|
||||||
|
/* Block operations */
|
||||||
|
|
||||||
|
synor_error_t synor_rpc_get_latest_block(
|
||||||
|
synor_rpc_t* rpc,
|
||||||
|
synor_block_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
synor_error_t synor_rpc_get_block(
|
||||||
|
synor_rpc_t* rpc,
|
||||||
|
const char* hash_or_height,
|
||||||
|
synor_block_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
synor_error_t synor_rpc_get_block_header(
|
||||||
|
synor_rpc_t* rpc,
|
||||||
|
const char* hash_or_height,
|
||||||
|
synor_block_header_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Transaction operations */
|
||||||
|
|
||||||
|
synor_error_t synor_rpc_get_transaction(
|
||||||
|
synor_rpc_t* rpc,
|
||||||
|
const char* txid,
|
||||||
|
synor_rpc_transaction_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
synor_error_t synor_rpc_get_raw_transaction(
|
||||||
|
synor_rpc_t* rpc,
|
||||||
|
const char* txid,
|
||||||
|
char** hex
|
||||||
|
);
|
||||||
|
|
||||||
|
synor_error_t synor_rpc_send_raw_transaction(
|
||||||
|
synor_rpc_t* rpc,
|
||||||
|
const char* hex,
|
||||||
|
synor_submit_result_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
synor_error_t synor_rpc_decode_raw_transaction(
|
||||||
|
synor_rpc_t* rpc,
|
||||||
|
const char* hex,
|
||||||
|
synor_rpc_transaction_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Fee estimation */
|
||||||
|
|
||||||
|
synor_error_t synor_rpc_estimate_fee(
|
||||||
|
synor_rpc_t* rpc,
|
||||||
|
synor_priority_t priority,
|
||||||
|
synor_fee_estimate_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Chain information */
|
||||||
|
|
||||||
|
synor_error_t synor_rpc_get_chain_info(
|
||||||
|
synor_rpc_t* rpc,
|
||||||
|
synor_chain_info_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
synor_error_t synor_rpc_get_mempool_info(
|
||||||
|
synor_rpc_t* rpc,
|
||||||
|
synor_mempool_info_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Subscriptions */
|
||||||
|
|
||||||
|
synor_subscription_t* synor_rpc_subscribe_blocks(
|
||||||
|
synor_rpc_t* rpc,
|
||||||
|
synor_block_callback_t callback,
|
||||||
|
void* user_data
|
||||||
|
);
|
||||||
|
|
||||||
|
synor_subscription_t* synor_rpc_subscribe_address(
|
||||||
|
synor_rpc_t* rpc,
|
||||||
|
const char* address,
|
||||||
|
synor_tx_callback_t callback,
|
||||||
|
void* user_data
|
||||||
|
);
|
||||||
|
|
||||||
|
synor_subscription_t* synor_rpc_subscribe_mempool(
|
||||||
|
synor_rpc_t* rpc,
|
||||||
|
synor_tx_callback_t callback,
|
||||||
|
void* user_data
|
||||||
|
);
|
||||||
|
|
||||||
|
void synor_subscription_cancel(synor_subscription_t* sub);
|
||||||
|
|
||||||
|
/* Free functions */
|
||||||
|
void synor_block_header_free(synor_block_header_t* header);
|
||||||
|
void synor_block_free(synor_block_t* block);
|
||||||
|
void synor_rpc_transaction_free(synor_rpc_transaction_t* tx);
|
||||||
|
void synor_chain_info_free(synor_chain_info_t* info);
|
||||||
|
void synor_mempool_info_free(synor_mempool_info_t* info);
|
||||||
|
void synor_submit_result_free(synor_submit_result_t* result);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* SYNOR_RPC_H */
|
||||||
282
sdk/c/include/synor/storage.h
Normal file
282
sdk/c/include/synor/storage.h
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
/**
|
||||||
|
* @file storage.h
|
||||||
|
* @brief Synor Storage SDK for C
|
||||||
|
*
|
||||||
|
* Provides IPFS-compatible decentralized storage with upload, download,
|
||||||
|
* pinning, and CAR file operations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef SYNOR_STORAGE_H
|
||||||
|
#define SYNOR_STORAGE_H
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include "wallet.h" /* For synor_error_t */
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Pin status */
|
||||||
|
typedef enum {
|
||||||
|
SYNOR_PIN_STATUS_QUEUED = 0,
|
||||||
|
SYNOR_PIN_STATUS_PINNING,
|
||||||
|
SYNOR_PIN_STATUS_PINNED,
|
||||||
|
SYNOR_PIN_STATUS_FAILED,
|
||||||
|
SYNOR_PIN_STATUS_UNPINNED
|
||||||
|
} synor_pin_status_t;
|
||||||
|
|
||||||
|
/* Hash algorithm */
|
||||||
|
typedef enum {
|
||||||
|
SYNOR_HASH_SHA2_256 = 0,
|
||||||
|
SYNOR_HASH_BLAKE3
|
||||||
|
} synor_hash_algorithm_t;
|
||||||
|
|
||||||
|
/* Entry type */
|
||||||
|
typedef enum {
|
||||||
|
SYNOR_ENTRY_FILE = 0,
|
||||||
|
SYNOR_ENTRY_DIRECTORY
|
||||||
|
} synor_entry_type_t;
|
||||||
|
|
||||||
|
/* Storage configuration */
|
||||||
|
typedef struct {
|
||||||
|
const char* api_key;
|
||||||
|
const char* endpoint; /* Default: "https://storage.synor.io/v1" */
|
||||||
|
const char* gateway; /* Default: "https://gateway.synor.io" */
|
||||||
|
uint32_t timeout_ms; /* Default: 60000 */
|
||||||
|
uint32_t retries; /* Default: 3 */
|
||||||
|
bool debug; /* Default: false */
|
||||||
|
} synor_storage_config_t;
|
||||||
|
|
||||||
|
/* Opaque storage client handle */
|
||||||
|
typedef struct synor_storage synor_storage_t;
|
||||||
|
|
||||||
|
/* Upload options */
|
||||||
|
typedef struct {
|
||||||
|
const char* name; /* May be NULL */
|
||||||
|
bool wrap_with_directory; /* Default: false */
|
||||||
|
synor_hash_algorithm_t hash_algorithm; /* Default: SHA2_256 */
|
||||||
|
int32_t chunk_size; /* 0 for default */
|
||||||
|
int32_t pin_duration_days; /* 0 for default */
|
||||||
|
} synor_upload_options_t;
|
||||||
|
|
||||||
|
/* Upload response */
|
||||||
|
typedef struct {
|
||||||
|
char* cid;
|
||||||
|
int64_t size;
|
||||||
|
char* name; /* May be NULL */
|
||||||
|
char* created_at;
|
||||||
|
} synor_upload_response_t;
|
||||||
|
|
||||||
|
/* Pin info */
|
||||||
|
typedef struct {
|
||||||
|
char* cid;
|
||||||
|
char* name; /* May be NULL */
|
||||||
|
synor_pin_status_t status;
|
||||||
|
int64_t size;
|
||||||
|
char* created_at;
|
||||||
|
char* expires_at; /* May be NULL */
|
||||||
|
char** delegates;
|
||||||
|
size_t delegate_count;
|
||||||
|
} synor_pin_t;
|
||||||
|
|
||||||
|
/* Pin request */
|
||||||
|
typedef struct {
|
||||||
|
const char* cid;
|
||||||
|
const char* name; /* May be NULL */
|
||||||
|
int32_t duration_days; /* 0 for default */
|
||||||
|
const char** origins;
|
||||||
|
size_t origin_count;
|
||||||
|
} synor_pin_request_t;
|
||||||
|
|
||||||
|
/* Pin list */
|
||||||
|
typedef struct {
|
||||||
|
synor_pin_t* pins;
|
||||||
|
size_t count;
|
||||||
|
} synor_pin_list_t;
|
||||||
|
|
||||||
|
/* CAR file info */
|
||||||
|
typedef struct {
|
||||||
|
char* cid;
|
||||||
|
int64_t size;
|
||||||
|
char** roots;
|
||||||
|
size_t root_count;
|
||||||
|
char* created_at;
|
||||||
|
} synor_car_file_t;
|
||||||
|
|
||||||
|
/* File entry for upload */
|
||||||
|
typedef struct {
|
||||||
|
const char* path;
|
||||||
|
const uint8_t* content;
|
||||||
|
size_t content_size;
|
||||||
|
} synor_file_entry_t;
|
||||||
|
|
||||||
|
/* Directory entry */
|
||||||
|
typedef struct {
|
||||||
|
char* name;
|
||||||
|
char* cid;
|
||||||
|
int64_t size;
|
||||||
|
synor_entry_type_t type;
|
||||||
|
} synor_directory_entry_t;
|
||||||
|
|
||||||
|
/* Directory listing */
|
||||||
|
typedef struct {
|
||||||
|
synor_directory_entry_t* entries;
|
||||||
|
size_t count;
|
||||||
|
} synor_directory_listing_t;
|
||||||
|
|
||||||
|
/* Storage statistics */
|
||||||
|
typedef struct {
|
||||||
|
int64_t total_size;
|
||||||
|
int32_t pin_count;
|
||||||
|
int64_t bandwidth_used;
|
||||||
|
double quota_used;
|
||||||
|
} synor_storage_stats_t;
|
||||||
|
|
||||||
|
/* CID list (for CAR import) */
|
||||||
|
typedef struct {
|
||||||
|
char** cids;
|
||||||
|
size_t count;
|
||||||
|
} synor_cid_list_t;
|
||||||
|
|
||||||
|
/* Download callback for streaming */
|
||||||
|
typedef void (*synor_download_callback_t)(
|
||||||
|
const uint8_t* data,
|
||||||
|
size_t size,
|
||||||
|
void* user_data
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new storage client.
|
||||||
|
*/
|
||||||
|
synor_storage_t* synor_storage_create(const synor_storage_config_t* config);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy storage client and free resources.
|
||||||
|
*/
|
||||||
|
void synor_storage_destroy(synor_storage_t* storage);
|
||||||
|
|
||||||
|
/* Upload operations */
|
||||||
|
|
||||||
|
synor_error_t synor_storage_upload(
|
||||||
|
synor_storage_t* storage,
|
||||||
|
const uint8_t* data,
|
||||||
|
size_t size,
|
||||||
|
const synor_upload_options_t* options,
|
||||||
|
synor_upload_response_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
synor_error_t synor_storage_upload_directory(
|
||||||
|
synor_storage_t* storage,
|
||||||
|
const synor_file_entry_t* files,
|
||||||
|
size_t file_count,
|
||||||
|
const char* dir_name,
|
||||||
|
synor_upload_response_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Download operations */
|
||||||
|
|
||||||
|
synor_error_t synor_storage_download(
|
||||||
|
synor_storage_t* storage,
|
||||||
|
const char* cid,
|
||||||
|
uint8_t** data,
|
||||||
|
size_t* size
|
||||||
|
);
|
||||||
|
|
||||||
|
synor_error_t synor_storage_download_stream(
|
||||||
|
synor_storage_t* storage,
|
||||||
|
const char* cid,
|
||||||
|
synor_download_callback_t callback,
|
||||||
|
void* user_data
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get gateway URL for content.
|
||||||
|
* Caller must free the returned string.
|
||||||
|
*/
|
||||||
|
char* synor_storage_get_gateway_url(
|
||||||
|
synor_storage_t* storage,
|
||||||
|
const char* cid,
|
||||||
|
const char* path /* May be NULL */
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Pinning operations */
|
||||||
|
|
||||||
|
synor_error_t synor_storage_pin(
|
||||||
|
synor_storage_t* storage,
|
||||||
|
const synor_pin_request_t* request,
|
||||||
|
synor_pin_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
synor_error_t synor_storage_unpin(
|
||||||
|
synor_storage_t* storage,
|
||||||
|
const char* cid
|
||||||
|
);
|
||||||
|
|
||||||
|
synor_error_t synor_storage_get_pin_status(
|
||||||
|
synor_storage_t* storage,
|
||||||
|
const char* cid,
|
||||||
|
synor_pin_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
synor_error_t synor_storage_list_pins(
|
||||||
|
synor_storage_t* storage,
|
||||||
|
synor_pin_status_t* status, /* NULL for all */
|
||||||
|
int32_t limit,
|
||||||
|
int32_t offset,
|
||||||
|
synor_pin_list_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
/* CAR file operations */
|
||||||
|
|
||||||
|
synor_error_t synor_storage_create_car(
|
||||||
|
synor_storage_t* storage,
|
||||||
|
const synor_file_entry_t* entries,
|
||||||
|
size_t entry_count,
|
||||||
|
synor_car_file_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
synor_error_t synor_storage_import_car(
|
||||||
|
synor_storage_t* storage,
|
||||||
|
const uint8_t* car_data,
|
||||||
|
size_t car_size,
|
||||||
|
synor_cid_list_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
synor_error_t synor_storage_export_car(
|
||||||
|
synor_storage_t* storage,
|
||||||
|
const char* cid,
|
||||||
|
uint8_t** data,
|
||||||
|
size_t* size
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Directory operations */
|
||||||
|
|
||||||
|
synor_error_t synor_storage_list_directory(
|
||||||
|
synor_storage_t* storage,
|
||||||
|
const char* cid,
|
||||||
|
synor_directory_listing_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Statistics */
|
||||||
|
|
||||||
|
synor_error_t synor_storage_get_stats(
|
||||||
|
synor_storage_t* storage,
|
||||||
|
synor_storage_stats_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Free functions */
|
||||||
|
void synor_upload_response_free(synor_upload_response_t* response);
|
||||||
|
void synor_pin_free(synor_pin_t* pin);
|
||||||
|
void synor_pin_list_free(synor_pin_list_t* list);
|
||||||
|
void synor_car_file_free(synor_car_file_t* car);
|
||||||
|
void synor_cid_list_free(synor_cid_list_t* list);
|
||||||
|
void synor_directory_entry_free(synor_directory_entry_t* entry);
|
||||||
|
void synor_directory_listing_free(synor_directory_listing_t* listing);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* SYNOR_STORAGE_H */
|
||||||
399
sdk/c/include/synor/wallet.h
Normal file
399
sdk/c/include/synor/wallet.h
Normal file
|
|
@ -0,0 +1,399 @@
|
||||||
|
/**
|
||||||
|
* @file wallet.h
|
||||||
|
* @brief Synor Wallet SDK for C
|
||||||
|
*
|
||||||
|
* Provides key management, transaction signing, and balance queries
|
||||||
|
* for the Synor blockchain.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```c
|
||||||
|
* #include <synor/wallet.h>
|
||||||
|
*
|
||||||
|
* int main() {
|
||||||
|
* synor_wallet_config_t config = {
|
||||||
|
* .api_key = "your-api-key",
|
||||||
|
* .endpoint = "https://wallet.synor.io/v1",
|
||||||
|
* .network = SYNOR_NETWORK_MAINNET,
|
||||||
|
* .timeout_ms = 30000,
|
||||||
|
* .retries = 3
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* synor_wallet_t* wallet = synor_wallet_create(&config);
|
||||||
|
* if (!wallet) {
|
||||||
|
* fprintf(stderr, "Failed to create wallet client\n");
|
||||||
|
* return 1;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* synor_create_wallet_result_t result;
|
||||||
|
* synor_error_t err = synor_wallet_create_wallet(wallet, SYNOR_WALLET_STANDARD, &result);
|
||||||
|
* if (err == SYNOR_OK) {
|
||||||
|
* printf("Wallet ID: %s\n", result.wallet.id);
|
||||||
|
* printf("Mnemonic: %s\n", result.mnemonic);
|
||||||
|
* synor_create_wallet_result_free(&result);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* synor_wallet_destroy(wallet);
|
||||||
|
* return 0;
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef SYNOR_WALLET_H
|
||||||
|
#define SYNOR_WALLET_H
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Error codes */
|
||||||
|
typedef enum {
|
||||||
|
SYNOR_OK = 0,
|
||||||
|
SYNOR_ERROR_INVALID_ARGUMENT,
|
||||||
|
SYNOR_ERROR_OUT_OF_MEMORY,
|
||||||
|
SYNOR_ERROR_NETWORK,
|
||||||
|
SYNOR_ERROR_HTTP,
|
||||||
|
SYNOR_ERROR_JSON_PARSE,
|
||||||
|
SYNOR_ERROR_TIMEOUT,
|
||||||
|
SYNOR_ERROR_AUTH,
|
||||||
|
SYNOR_ERROR_NOT_FOUND,
|
||||||
|
SYNOR_ERROR_UNKNOWN
|
||||||
|
} synor_error_t;
|
||||||
|
|
||||||
|
/* Network types */
|
||||||
|
typedef enum {
|
||||||
|
SYNOR_NETWORK_MAINNET = 0,
|
||||||
|
SYNOR_NETWORK_TESTNET,
|
||||||
|
SYNOR_NETWORK_DEVNET
|
||||||
|
} synor_network_t;
|
||||||
|
|
||||||
|
/* Wallet types */
|
||||||
|
typedef enum {
|
||||||
|
SYNOR_WALLET_STANDARD = 0,
|
||||||
|
SYNOR_WALLET_MULTISIG,
|
||||||
|
SYNOR_WALLET_HARDWARE,
|
||||||
|
SYNOR_WALLET_STEALTH
|
||||||
|
} synor_wallet_type_t;
|
||||||
|
|
||||||
|
/* Priority types */
|
||||||
|
typedef enum {
|
||||||
|
SYNOR_PRIORITY_LOW = 0,
|
||||||
|
SYNOR_PRIORITY_MEDIUM,
|
||||||
|
SYNOR_PRIORITY_HIGH
|
||||||
|
} synor_priority_t;
|
||||||
|
|
||||||
|
/* Configuration */
|
||||||
|
typedef struct {
|
||||||
|
const char* api_key;
|
||||||
|
const char* endpoint; /* Default: "https://wallet.synor.io/v1" */
|
||||||
|
synor_network_t network; /* Default: SYNOR_NETWORK_MAINNET */
|
||||||
|
uint32_t timeout_ms; /* Default: 30000 */
|
||||||
|
uint32_t retries; /* Default: 3 */
|
||||||
|
bool debug; /* Default: false */
|
||||||
|
} synor_wallet_config_t;
|
||||||
|
|
||||||
|
/* Opaque wallet client handle */
|
||||||
|
typedef struct synor_wallet synor_wallet_t;
|
||||||
|
|
||||||
|
/* Address structure */
|
||||||
|
typedef struct {
|
||||||
|
char* address;
|
||||||
|
int32_t index;
|
||||||
|
bool is_change;
|
||||||
|
} synor_address_t;
|
||||||
|
|
||||||
|
/* Wallet structure */
|
||||||
|
typedef struct {
|
||||||
|
char* id;
|
||||||
|
synor_wallet_type_t type;
|
||||||
|
synor_network_t network;
|
||||||
|
synor_address_t* addresses;
|
||||||
|
size_t address_count;
|
||||||
|
char* created_at;
|
||||||
|
} synor_wallet_info_t;
|
||||||
|
|
||||||
|
/* Stealth address */
|
||||||
|
typedef struct {
|
||||||
|
char* spend_public_key;
|
||||||
|
char* view_public_key;
|
||||||
|
char* one_time_address;
|
||||||
|
} synor_stealth_address_t;
|
||||||
|
|
||||||
|
/* Create wallet result */
|
||||||
|
typedef struct {
|
||||||
|
synor_wallet_info_t wallet;
|
||||||
|
char* mnemonic; /* May be NULL for hardware wallets */
|
||||||
|
} synor_create_wallet_result_t;
|
||||||
|
|
||||||
|
/* Transaction input */
|
||||||
|
typedef struct {
|
||||||
|
char* txid;
|
||||||
|
int32_t vout;
|
||||||
|
int64_t amount;
|
||||||
|
} synor_tx_input_t;
|
||||||
|
|
||||||
|
/* Transaction output */
|
||||||
|
typedef struct {
|
||||||
|
char* address;
|
||||||
|
int64_t amount;
|
||||||
|
} synor_tx_output_t;
|
||||||
|
|
||||||
|
/* Transaction for signing */
|
||||||
|
typedef struct {
|
||||||
|
synor_tx_input_t* inputs;
|
||||||
|
size_t input_count;
|
||||||
|
synor_tx_output_t* outputs;
|
||||||
|
size_t output_count;
|
||||||
|
int64_t fee; /* 0 for auto-calculate */
|
||||||
|
synor_priority_t priority;
|
||||||
|
} synor_transaction_t;
|
||||||
|
|
||||||
|
/* Signed transaction */
|
||||||
|
typedef struct {
|
||||||
|
char* txid;
|
||||||
|
char* hex;
|
||||||
|
int32_t size;
|
||||||
|
int64_t fee;
|
||||||
|
} synor_signed_tx_t;
|
||||||
|
|
||||||
|
/* Signature */
|
||||||
|
typedef struct {
|
||||||
|
char* signature;
|
||||||
|
char* address;
|
||||||
|
int32_t recovery_id; /* -1 if not applicable */
|
||||||
|
} synor_signature_t;
|
||||||
|
|
||||||
|
/* UTXO */
|
||||||
|
typedef struct {
|
||||||
|
char* txid;
|
||||||
|
int32_t vout;
|
||||||
|
int64_t amount;
|
||||||
|
char* address;
|
||||||
|
int32_t confirmations;
|
||||||
|
char* script_pubkey; /* May be NULL */
|
||||||
|
} synor_utxo_t;
|
||||||
|
|
||||||
|
/* Balance */
|
||||||
|
typedef struct {
|
||||||
|
int64_t confirmed;
|
||||||
|
int64_t unconfirmed;
|
||||||
|
int64_t total;
|
||||||
|
} synor_balance_t;
|
||||||
|
|
||||||
|
/* UTXO list */
|
||||||
|
typedef struct {
|
||||||
|
synor_utxo_t* utxos;
|
||||||
|
size_t count;
|
||||||
|
} synor_utxo_list_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new wallet client.
|
||||||
|
*
|
||||||
|
* @param config Configuration options
|
||||||
|
* @return Wallet client handle, or NULL on failure
|
||||||
|
*/
|
||||||
|
synor_wallet_t* synor_wallet_create(const synor_wallet_config_t* config);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy wallet client and free resources.
|
||||||
|
*
|
||||||
|
* @param wallet Wallet client handle
|
||||||
|
*/
|
||||||
|
void synor_wallet_destroy(synor_wallet_t* wallet);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new wallet.
|
||||||
|
*
|
||||||
|
* @param wallet Wallet client handle
|
||||||
|
* @param type Type of wallet to create
|
||||||
|
* @param result Output parameter for result
|
||||||
|
* @return Error code
|
||||||
|
*/
|
||||||
|
synor_error_t synor_wallet_create_wallet(
|
||||||
|
synor_wallet_t* wallet,
|
||||||
|
synor_wallet_type_t type,
|
||||||
|
synor_create_wallet_result_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a wallet from mnemonic.
|
||||||
|
*
|
||||||
|
* @param wallet Wallet client handle
|
||||||
|
* @param mnemonic 12 or 24 word mnemonic phrase
|
||||||
|
* @param passphrase Optional BIP39 passphrase (can be NULL)
|
||||||
|
* @param result Output parameter for wallet info
|
||||||
|
* @return Error code
|
||||||
|
*/
|
||||||
|
synor_error_t synor_wallet_import(
|
||||||
|
synor_wallet_t* wallet,
|
||||||
|
const char* mnemonic,
|
||||||
|
const char* passphrase,
|
||||||
|
synor_wallet_info_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a wallet by ID.
|
||||||
|
*
|
||||||
|
* @param wallet Wallet client handle
|
||||||
|
* @param wallet_id Wallet ID
|
||||||
|
* @param result Output parameter for wallet info
|
||||||
|
* @return Error code
|
||||||
|
*/
|
||||||
|
synor_error_t synor_wallet_get(
|
||||||
|
synor_wallet_t* wallet,
|
||||||
|
const char* wallet_id,
|
||||||
|
synor_wallet_info_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new address for a wallet.
|
||||||
|
*
|
||||||
|
* @param wallet Wallet client handle
|
||||||
|
* @param wallet_id Wallet ID
|
||||||
|
* @param is_change Whether this is a change address
|
||||||
|
* @param result Output parameter for address
|
||||||
|
* @return Error code
|
||||||
|
*/
|
||||||
|
synor_error_t synor_wallet_generate_address(
|
||||||
|
synor_wallet_t* wallet,
|
||||||
|
const char* wallet_id,
|
||||||
|
bool is_change,
|
||||||
|
synor_address_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a stealth address for privacy transactions.
|
||||||
|
*
|
||||||
|
* @param wallet Wallet client handle
|
||||||
|
* @param wallet_id Wallet ID
|
||||||
|
* @param result Output parameter for stealth address
|
||||||
|
* @return Error code
|
||||||
|
*/
|
||||||
|
synor_error_t synor_wallet_get_stealth_address(
|
||||||
|
synor_wallet_t* wallet,
|
||||||
|
const char* wallet_id,
|
||||||
|
synor_stealth_address_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a transaction.
|
||||||
|
*
|
||||||
|
* @param wallet Wallet client handle
|
||||||
|
* @param wallet_id Wallet ID to sign with
|
||||||
|
* @param transaction Transaction to sign
|
||||||
|
* @param result Output parameter for signed transaction
|
||||||
|
* @return Error code
|
||||||
|
*/
|
||||||
|
synor_error_t synor_wallet_sign_transaction(
|
||||||
|
synor_wallet_t* wallet,
|
||||||
|
const char* wallet_id,
|
||||||
|
const synor_transaction_t* transaction,
|
||||||
|
synor_signed_tx_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a message.
|
||||||
|
*
|
||||||
|
* @param wallet Wallet client handle
|
||||||
|
* @param wallet_id Wallet ID
|
||||||
|
* @param message Message to sign
|
||||||
|
* @param address_index Address index to use for signing
|
||||||
|
* @param result Output parameter for signature
|
||||||
|
* @return Error code
|
||||||
|
*/
|
||||||
|
synor_error_t synor_wallet_sign_message(
|
||||||
|
synor_wallet_t* wallet,
|
||||||
|
const char* wallet_id,
|
||||||
|
const char* message,
|
||||||
|
int32_t address_index,
|
||||||
|
synor_signature_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a message signature.
|
||||||
|
*
|
||||||
|
* @param wallet Wallet client handle
|
||||||
|
* @param message Original message
|
||||||
|
* @param signature Signature to verify
|
||||||
|
* @param address Address that signed the message
|
||||||
|
* @param is_valid Output parameter for validity
|
||||||
|
* @return Error code
|
||||||
|
*/
|
||||||
|
synor_error_t synor_wallet_verify_message(
|
||||||
|
synor_wallet_t* wallet,
|
||||||
|
const char* message,
|
||||||
|
const char* signature,
|
||||||
|
const char* address,
|
||||||
|
bool* is_valid
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get balance for an address.
|
||||||
|
*
|
||||||
|
* @param wallet Wallet client handle
|
||||||
|
* @param address Address to query
|
||||||
|
* @param result Output parameter for balance
|
||||||
|
* @return Error code
|
||||||
|
*/
|
||||||
|
synor_error_t synor_wallet_get_balance(
|
||||||
|
synor_wallet_t* wallet,
|
||||||
|
const char* address,
|
||||||
|
synor_balance_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get UTXOs for an address.
|
||||||
|
*
|
||||||
|
* @param wallet Wallet client handle
|
||||||
|
* @param address Address to query
|
||||||
|
* @param min_confirmations Minimum confirmations required
|
||||||
|
* @param result Output parameter for UTXO list
|
||||||
|
* @return Error code
|
||||||
|
*/
|
||||||
|
synor_error_t synor_wallet_get_utxos(
|
||||||
|
synor_wallet_t* wallet,
|
||||||
|
const char* address,
|
||||||
|
int32_t min_confirmations,
|
||||||
|
synor_utxo_list_t* result
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate transaction fee.
|
||||||
|
*
|
||||||
|
* @param wallet Wallet client handle
|
||||||
|
* @param priority Transaction priority
|
||||||
|
* @param fee_per_byte Output parameter for fee per byte
|
||||||
|
* @return Error code
|
||||||
|
*/
|
||||||
|
synor_error_t synor_wallet_estimate_fee(
|
||||||
|
synor_wallet_t* wallet,
|
||||||
|
synor_priority_t priority,
|
||||||
|
int64_t* fee_per_byte
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Free functions */
|
||||||
|
void synor_address_free(synor_address_t* addr);
|
||||||
|
void synor_wallet_info_free(synor_wallet_info_t* info);
|
||||||
|
void synor_stealth_address_free(synor_stealth_address_t* addr);
|
||||||
|
void synor_create_wallet_result_free(synor_create_wallet_result_t* result);
|
||||||
|
void synor_signed_tx_free(synor_signed_tx_t* tx);
|
||||||
|
void synor_signature_free(synor_signature_t* sig);
|
||||||
|
void synor_utxo_free(synor_utxo_t* utxo);
|
||||||
|
void synor_utxo_list_free(synor_utxo_list_t* list);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get error message for error code.
|
||||||
|
*
|
||||||
|
* @param error Error code
|
||||||
|
* @return Static string describing the error
|
||||||
|
*/
|
||||||
|
const char* synor_error_message(synor_error_t error);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* SYNOR_WALLET_H */
|
||||||
249
sdk/c/src/common.c
Normal file
249
sdk/c/src/common.c
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
/**
|
||||||
|
* @file common.c
|
||||||
|
* @brief Common utilities for Synor C SDK
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <curl/curl.h>
|
||||||
|
#include "synor/wallet.h"
|
||||||
|
|
||||||
|
/* Error messages */
|
||||||
|
static const char* error_messages[] = {
|
||||||
|
"OK",
|
||||||
|
"Invalid argument",
|
||||||
|
"Out of memory",
|
||||||
|
"Network error",
|
||||||
|
"HTTP error",
|
||||||
|
"JSON parse error",
|
||||||
|
"Timeout",
|
||||||
|
"Authentication error",
|
||||||
|
"Not found",
|
||||||
|
"Unknown error"
|
||||||
|
};
|
||||||
|
|
||||||
|
const char* synor_error_message(synor_error_t error) {
|
||||||
|
if (error >= 0 && error <= SYNOR_ERROR_UNKNOWN) {
|
||||||
|
return error_messages[error];
|
||||||
|
}
|
||||||
|
return "Unknown error code";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HTTP response buffer */
|
||||||
|
typedef struct {
|
||||||
|
char* data;
|
||||||
|
size_t size;
|
||||||
|
} http_response_t;
|
||||||
|
|
||||||
|
/* CURL write callback */
|
||||||
|
static size_t write_callback(void* contents, size_t size, size_t nmemb, void* userp) {
|
||||||
|
size_t real_size = size * nmemb;
|
||||||
|
http_response_t* resp = (http_response_t*)userp;
|
||||||
|
|
||||||
|
char* ptr = realloc(resp->data, resp->size + real_size + 1);
|
||||||
|
if (!ptr) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
resp->data = ptr;
|
||||||
|
memcpy(&(resp->data[resp->size]), contents, real_size);
|
||||||
|
resp->size += real_size;
|
||||||
|
resp->data[resp->size] = 0;
|
||||||
|
|
||||||
|
return real_size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Internal HTTP client structure */
|
||||||
|
typedef struct {
|
||||||
|
CURL* curl;
|
||||||
|
char* api_key;
|
||||||
|
char* endpoint;
|
||||||
|
uint32_t timeout_ms;
|
||||||
|
uint32_t retries;
|
||||||
|
bool debug;
|
||||||
|
} synor_http_client_t;
|
||||||
|
|
||||||
|
synor_http_client_t* synor_http_client_create(
|
||||||
|
const char* api_key,
|
||||||
|
const char* endpoint,
|
||||||
|
uint32_t timeout_ms,
|
||||||
|
uint32_t retries,
|
||||||
|
bool debug
|
||||||
|
) {
|
||||||
|
synor_http_client_t* client = calloc(1, sizeof(synor_http_client_t));
|
||||||
|
if (!client) return NULL;
|
||||||
|
|
||||||
|
client->curl = curl_easy_init();
|
||||||
|
if (!client->curl) {
|
||||||
|
free(client);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
client->api_key = strdup(api_key);
|
||||||
|
client->endpoint = strdup(endpoint);
|
||||||
|
client->timeout_ms = timeout_ms;
|
||||||
|
client->retries = retries;
|
||||||
|
client->debug = debug;
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
void synor_http_client_destroy(synor_http_client_t* client) {
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
if (client->curl) {
|
||||||
|
curl_easy_cleanup(client->curl);
|
||||||
|
}
|
||||||
|
free(client->api_key);
|
||||||
|
free(client->endpoint);
|
||||||
|
free(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_http_get(
|
||||||
|
synor_http_client_t* client,
|
||||||
|
const char* path,
|
||||||
|
char** response,
|
||||||
|
size_t* response_size
|
||||||
|
) {
|
||||||
|
if (!client || !path || !response) {
|
||||||
|
return SYNOR_ERROR_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
char url[2048];
|
||||||
|
snprintf(url, sizeof(url), "%s%s", client->endpoint, path);
|
||||||
|
|
||||||
|
http_response_t resp = {0};
|
||||||
|
struct curl_slist* headers = NULL;
|
||||||
|
char auth_header[512];
|
||||||
|
snprintf(auth_header, sizeof(auth_header), "Authorization: Bearer %s", client->api_key);
|
||||||
|
|
||||||
|
headers = curl_slist_append(headers, auth_header);
|
||||||
|
headers = curl_slist_append(headers, "Content-Type: application/json");
|
||||||
|
|
||||||
|
curl_easy_setopt(client->curl, CURLOPT_URL, url);
|
||||||
|
curl_easy_setopt(client->curl, CURLOPT_HTTPHEADER, headers);
|
||||||
|
curl_easy_setopt(client->curl, CURLOPT_WRITEFUNCTION, write_callback);
|
||||||
|
curl_easy_setopt(client->curl, CURLOPT_WRITEDATA, &resp);
|
||||||
|
curl_easy_setopt(client->curl, CURLOPT_TIMEOUT_MS, client->timeout_ms);
|
||||||
|
|
||||||
|
synor_error_t result = SYNOR_OK;
|
||||||
|
|
||||||
|
for (uint32_t attempt = 0; attempt < client->retries; attempt++) {
|
||||||
|
CURLcode res = curl_easy_perform(client->curl);
|
||||||
|
|
||||||
|
if (res == CURLE_OK) {
|
||||||
|
long http_code = 0;
|
||||||
|
curl_easy_getinfo(client->curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||||
|
|
||||||
|
if (http_code >= 200 && http_code < 300) {
|
||||||
|
*response = resp.data;
|
||||||
|
if (response_size) {
|
||||||
|
*response_size = resp.size;
|
||||||
|
}
|
||||||
|
curl_slist_free_all(headers);
|
||||||
|
return SYNOR_OK;
|
||||||
|
} else if (http_code == 401) {
|
||||||
|
result = SYNOR_ERROR_AUTH;
|
||||||
|
} else if (http_code == 404) {
|
||||||
|
result = SYNOR_ERROR_NOT_FOUND;
|
||||||
|
} else {
|
||||||
|
result = SYNOR_ERROR_HTTP;
|
||||||
|
}
|
||||||
|
} else if (res == CURLE_OPERATION_TIMEDOUT) {
|
||||||
|
result = SYNOR_ERROR_TIMEOUT;
|
||||||
|
} else {
|
||||||
|
result = SYNOR_ERROR_NETWORK;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client->debug) {
|
||||||
|
fprintf(stderr, "HTTP attempt %u failed: %s\n",
|
||||||
|
attempt + 1, curl_easy_strerror(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
free(resp.data);
|
||||||
|
resp.data = NULL;
|
||||||
|
resp.size = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
curl_slist_free_all(headers);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_http_post(
|
||||||
|
synor_http_client_t* client,
|
||||||
|
const char* path,
|
||||||
|
const char* body,
|
||||||
|
char** response,
|
||||||
|
size_t* response_size
|
||||||
|
) {
|
||||||
|
if (!client || !path || !body || !response) {
|
||||||
|
return SYNOR_ERROR_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
char url[2048];
|
||||||
|
snprintf(url, sizeof(url), "%s%s", client->endpoint, path);
|
||||||
|
|
||||||
|
http_response_t resp = {0};
|
||||||
|
struct curl_slist* headers = NULL;
|
||||||
|
char auth_header[512];
|
||||||
|
snprintf(auth_header, sizeof(auth_header), "Authorization: Bearer %s", client->api_key);
|
||||||
|
|
||||||
|
headers = curl_slist_append(headers, auth_header);
|
||||||
|
headers = curl_slist_append(headers, "Content-Type: application/json");
|
||||||
|
|
||||||
|
curl_easy_setopt(client->curl, CURLOPT_URL, url);
|
||||||
|
curl_easy_setopt(client->curl, CURLOPT_HTTPHEADER, headers);
|
||||||
|
curl_easy_setopt(client->curl, CURLOPT_POSTFIELDS, body);
|
||||||
|
curl_easy_setopt(client->curl, CURLOPT_WRITEFUNCTION, write_callback);
|
||||||
|
curl_easy_setopt(client->curl, CURLOPT_WRITEDATA, &resp);
|
||||||
|
curl_easy_setopt(client->curl, CURLOPT_TIMEOUT_MS, client->timeout_ms);
|
||||||
|
|
||||||
|
synor_error_t result = SYNOR_OK;
|
||||||
|
|
||||||
|
for (uint32_t attempt = 0; attempt < client->retries; attempt++) {
|
||||||
|
CURLcode res = curl_easy_perform(client->curl);
|
||||||
|
|
||||||
|
if (res == CURLE_OK) {
|
||||||
|
long http_code = 0;
|
||||||
|
curl_easy_getinfo(client->curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||||
|
|
||||||
|
if (http_code >= 200 && http_code < 300) {
|
||||||
|
*response = resp.data;
|
||||||
|
if (response_size) {
|
||||||
|
*response_size = resp.size;
|
||||||
|
}
|
||||||
|
curl_slist_free_all(headers);
|
||||||
|
return SYNOR_OK;
|
||||||
|
} else if (http_code == 401) {
|
||||||
|
result = SYNOR_ERROR_AUTH;
|
||||||
|
} else if (http_code == 404) {
|
||||||
|
result = SYNOR_ERROR_NOT_FOUND;
|
||||||
|
} else {
|
||||||
|
result = SYNOR_ERROR_HTTP;
|
||||||
|
}
|
||||||
|
} else if (res == CURLE_OPERATION_TIMEDOUT) {
|
||||||
|
result = SYNOR_ERROR_TIMEOUT;
|
||||||
|
} else {
|
||||||
|
result = SYNOR_ERROR_NETWORK;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client->debug) {
|
||||||
|
fprintf(stderr, "HTTP attempt %u failed: %s\n",
|
||||||
|
attempt + 1, curl_easy_strerror(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
free(resp.data);
|
||||||
|
resp.data = NULL;
|
||||||
|
resp.size = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
curl_slist_free_all(headers);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* String duplication helper */
|
||||||
|
char* synor_strdup(const char* s) {
|
||||||
|
if (!s) return NULL;
|
||||||
|
return strdup(s);
|
||||||
|
}
|
||||||
221
sdk/c/src/rpc/rpc.c
Normal file
221
sdk/c/src/rpc/rpc.c
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
/**
|
||||||
|
* @file rpc.c
|
||||||
|
* @brief Synor RPC SDK implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include "synor/rpc.h"
|
||||||
|
|
||||||
|
/* Forward declarations from common.c */
|
||||||
|
typedef struct synor_http_client synor_http_client_t;
|
||||||
|
synor_http_client_t* synor_http_client_create(
|
||||||
|
const char* api_key, const char* endpoint,
|
||||||
|
uint32_t timeout_ms, uint32_t retries, bool debug);
|
||||||
|
void synor_http_client_destroy(synor_http_client_t* client);
|
||||||
|
synor_error_t synor_http_get(synor_http_client_t* client, const char* path,
|
||||||
|
char** response, size_t* response_size);
|
||||||
|
synor_error_t synor_http_post(synor_http_client_t* client, const char* path,
|
||||||
|
const char* body, char** response, size_t* response_size);
|
||||||
|
char* synor_strdup(const char* s);
|
||||||
|
|
||||||
|
/* Internal RPC structure */
|
||||||
|
struct synor_rpc {
|
||||||
|
synor_http_client_t* http;
|
||||||
|
synor_network_t network;
|
||||||
|
char* ws_endpoint;
|
||||||
|
/* WebSocket connection would go here */
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Internal subscription structure */
|
||||||
|
struct synor_subscription {
|
||||||
|
char* id;
|
||||||
|
char* channel;
|
||||||
|
void* callback;
|
||||||
|
void* user_data;
|
||||||
|
struct synor_rpc* rpc;
|
||||||
|
};
|
||||||
|
|
||||||
|
synor_rpc_t* synor_rpc_create(const synor_rpc_config_t* config) {
|
||||||
|
if (!config || !config->api_key) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_rpc_t* rpc = calloc(1, sizeof(synor_rpc_t));
|
||||||
|
if (!rpc) return NULL;
|
||||||
|
|
||||||
|
const char* endpoint = config->endpoint ?
|
||||||
|
config->endpoint : "https://rpc.synor.io/v1";
|
||||||
|
const char* ws_endpoint = config->ws_endpoint ?
|
||||||
|
config->ws_endpoint : "wss://rpc.synor.io/v1/ws";
|
||||||
|
uint32_t timeout = config->timeout_ms > 0 ? config->timeout_ms : 30000;
|
||||||
|
uint32_t retries = config->retries > 0 ? config->retries : 3;
|
||||||
|
|
||||||
|
rpc->http = synor_http_client_create(
|
||||||
|
config->api_key, endpoint, timeout, retries, config->debug);
|
||||||
|
|
||||||
|
if (!rpc->http) {
|
||||||
|
free(rpc);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc->network = config->network;
|
||||||
|
rpc->ws_endpoint = synor_strdup(ws_endpoint);
|
||||||
|
|
||||||
|
return rpc;
|
||||||
|
}
|
||||||
|
|
||||||
|
void synor_rpc_destroy(synor_rpc_t* rpc) {
|
||||||
|
if (!rpc) return;
|
||||||
|
synor_http_client_destroy(rpc->http);
|
||||||
|
free(rpc->ws_endpoint);
|
||||||
|
free(rpc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stub implementations - TODO: Implement with JSON parsing */
|
||||||
|
|
||||||
|
synor_error_t synor_rpc_get_latest_block(synor_rpc_t* rpc, synor_block_t* result) {
|
||||||
|
(void)rpc; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_rpc_get_block(synor_rpc_t* rpc, const char* hash_or_height,
|
||||||
|
synor_block_t* result) {
|
||||||
|
(void)rpc; (void)hash_or_height; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_rpc_get_block_header(synor_rpc_t* rpc, const char* hash_or_height,
|
||||||
|
synor_block_header_t* result) {
|
||||||
|
(void)rpc; (void)hash_or_height; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_rpc_get_transaction(synor_rpc_t* rpc, const char* txid,
|
||||||
|
synor_rpc_transaction_t* result) {
|
||||||
|
(void)rpc; (void)txid; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_rpc_get_raw_transaction(synor_rpc_t* rpc, const char* txid,
|
||||||
|
char** hex) {
|
||||||
|
(void)rpc; (void)txid; (void)hex;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_rpc_send_raw_transaction(synor_rpc_t* rpc, const char* hex,
|
||||||
|
synor_submit_result_t* result) {
|
||||||
|
(void)rpc; (void)hex; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_rpc_decode_raw_transaction(synor_rpc_t* rpc, const char* hex,
|
||||||
|
synor_rpc_transaction_t* result) {
|
||||||
|
(void)rpc; (void)hex; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_rpc_estimate_fee(synor_rpc_t* rpc, synor_priority_t priority,
|
||||||
|
synor_fee_estimate_t* result) {
|
||||||
|
(void)rpc; (void)priority; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_rpc_get_chain_info(synor_rpc_t* rpc, synor_chain_info_t* result) {
|
||||||
|
(void)rpc; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_rpc_get_mempool_info(synor_rpc_t* rpc, synor_mempool_info_t* result) {
|
||||||
|
(void)rpc; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_subscription_t* synor_rpc_subscribe_blocks(synor_rpc_t* rpc,
|
||||||
|
synor_block_callback_t callback, void* user_data) {
|
||||||
|
(void)rpc; (void)callback; (void)user_data;
|
||||||
|
return NULL; /* TODO: Implement WebSocket */
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_subscription_t* synor_rpc_subscribe_address(synor_rpc_t* rpc,
|
||||||
|
const char* address, synor_tx_callback_t callback, void* user_data) {
|
||||||
|
(void)rpc; (void)address; (void)callback; (void)user_data;
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_subscription_t* synor_rpc_subscribe_mempool(synor_rpc_t* rpc,
|
||||||
|
synor_tx_callback_t callback, void* user_data) {
|
||||||
|
(void)rpc; (void)callback; (void)user_data;
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
void synor_subscription_cancel(synor_subscription_t* sub) {
|
||||||
|
if (!sub) return;
|
||||||
|
free(sub->id);
|
||||||
|
free(sub->channel);
|
||||||
|
free(sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Free functions */
|
||||||
|
void synor_block_header_free(synor_block_header_t* header) {
|
||||||
|
if (!header) return;
|
||||||
|
free(header->hash);
|
||||||
|
free(header->previous_hash);
|
||||||
|
free(header->merkle_root);
|
||||||
|
memset(header, 0, sizeof(*header));
|
||||||
|
}
|
||||||
|
|
||||||
|
void synor_block_free(synor_block_t* block) {
|
||||||
|
if (!block) return;
|
||||||
|
free(block->hash);
|
||||||
|
free(block->previous_hash);
|
||||||
|
free(block->merkle_root);
|
||||||
|
for (size_t i = 0; i < block->transaction_count; i++) {
|
||||||
|
synor_rpc_transaction_free(&block->transactions[i]);
|
||||||
|
}
|
||||||
|
free(block->transactions);
|
||||||
|
memset(block, 0, sizeof(*block));
|
||||||
|
}
|
||||||
|
|
||||||
|
void synor_rpc_transaction_free(synor_rpc_transaction_t* tx) {
|
||||||
|
if (!tx) return;
|
||||||
|
free(tx->txid);
|
||||||
|
free(tx->hash);
|
||||||
|
free(tx->block_hash);
|
||||||
|
for (size_t i = 0; i < tx->input_count; i++) {
|
||||||
|
free(tx->inputs[i].txid);
|
||||||
|
free(tx->inputs[i].script_sig);
|
||||||
|
for (size_t j = 0; j < tx->inputs[i].witness_count; j++) {
|
||||||
|
free(tx->inputs[i].witness[j]);
|
||||||
|
}
|
||||||
|
free(tx->inputs[i].witness);
|
||||||
|
}
|
||||||
|
free(tx->inputs);
|
||||||
|
for (size_t i = 0; i < tx->output_count; i++) {
|
||||||
|
free(tx->outputs[i].script_pubkey.asm_str);
|
||||||
|
free(tx->outputs[i].script_pubkey.hex);
|
||||||
|
free(tx->outputs[i].script_pubkey.type);
|
||||||
|
free(tx->outputs[i].script_pubkey.address);
|
||||||
|
}
|
||||||
|
free(tx->outputs);
|
||||||
|
memset(tx, 0, sizeof(*tx));
|
||||||
|
}
|
||||||
|
|
||||||
|
void synor_chain_info_free(synor_chain_info_t* info) {
|
||||||
|
if (!info) return;
|
||||||
|
free(info->chain);
|
||||||
|
free(info->best_block_hash);
|
||||||
|
free(info->chain_work);
|
||||||
|
memset(info, 0, sizeof(*info));
|
||||||
|
}
|
||||||
|
|
||||||
|
void synor_mempool_info_free(synor_mempool_info_t* info) {
|
||||||
|
(void)info; /* No dynamic members */
|
||||||
|
}
|
||||||
|
|
||||||
|
void synor_submit_result_free(synor_submit_result_t* result) {
|
||||||
|
if (!result) return;
|
||||||
|
free(result->txid);
|
||||||
|
free(result->reason);
|
||||||
|
memset(result, 0, sizeof(*result));
|
||||||
|
}
|
||||||
234
sdk/c/src/storage/storage.c
Normal file
234
sdk/c/src/storage/storage.c
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
/**
|
||||||
|
* @file storage.c
|
||||||
|
* @brief Synor Storage SDK implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include "synor/storage.h"
|
||||||
|
|
||||||
|
/* Forward declarations from common.c */
|
||||||
|
typedef struct synor_http_client synor_http_client_t;
|
||||||
|
synor_http_client_t* synor_http_client_create(
|
||||||
|
const char* api_key, const char* endpoint,
|
||||||
|
uint32_t timeout_ms, uint32_t retries, bool debug);
|
||||||
|
void synor_http_client_destroy(synor_http_client_t* client);
|
||||||
|
synor_error_t synor_http_get(synor_http_client_t* client, const char* path,
|
||||||
|
char** response, size_t* response_size);
|
||||||
|
synor_error_t synor_http_post(synor_http_client_t* client, const char* path,
|
||||||
|
const char* body, char** response, size_t* response_size);
|
||||||
|
char* synor_strdup(const char* s);
|
||||||
|
|
||||||
|
/* Internal storage structure */
|
||||||
|
struct synor_storage {
|
||||||
|
synor_http_client_t* http;
|
||||||
|
char* gateway;
|
||||||
|
};
|
||||||
|
|
||||||
|
synor_storage_t* synor_storage_create(const synor_storage_config_t* config) {
|
||||||
|
if (!config || !config->api_key) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_storage_t* storage = calloc(1, sizeof(synor_storage_t));
|
||||||
|
if (!storage) return NULL;
|
||||||
|
|
||||||
|
const char* endpoint = config->endpoint ?
|
||||||
|
config->endpoint : "https://storage.synor.io/v1";
|
||||||
|
const char* gateway = config->gateway ?
|
||||||
|
config->gateway : "https://gateway.synor.io";
|
||||||
|
uint32_t timeout = config->timeout_ms > 0 ? config->timeout_ms : 60000;
|
||||||
|
uint32_t retries = config->retries > 0 ? config->retries : 3;
|
||||||
|
|
||||||
|
storage->http = synor_http_client_create(
|
||||||
|
config->api_key, endpoint, timeout, retries, config->debug);
|
||||||
|
|
||||||
|
if (!storage->http) {
|
||||||
|
free(storage);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
storage->gateway = synor_strdup(gateway);
|
||||||
|
|
||||||
|
return storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
void synor_storage_destroy(synor_storage_t* storage) {
|
||||||
|
if (!storage) return;
|
||||||
|
synor_http_client_destroy(storage->http);
|
||||||
|
free(storage->gateway);
|
||||||
|
free(storage);
|
||||||
|
}
|
||||||
|
|
||||||
|
char* synor_storage_get_gateway_url(synor_storage_t* storage,
|
||||||
|
const char* cid, const char* path) {
|
||||||
|
if (!storage || !cid) return NULL;
|
||||||
|
|
||||||
|
size_t len = strlen(storage->gateway) + strlen("/ipfs/") + strlen(cid);
|
||||||
|
if (path) {
|
||||||
|
len += 1 + strlen(path);
|
||||||
|
}
|
||||||
|
len += 1; /* null terminator */
|
||||||
|
|
||||||
|
char* url = malloc(len);
|
||||||
|
if (!url) return NULL;
|
||||||
|
|
||||||
|
if (path) {
|
||||||
|
snprintf(url, len, "%s/ipfs/%s/%s", storage->gateway, cid, path);
|
||||||
|
} else {
|
||||||
|
snprintf(url, len, "%s/ipfs/%s", storage->gateway, cid);
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stub implementations - TODO: Implement with multipart upload */
|
||||||
|
|
||||||
|
synor_error_t synor_storage_upload(synor_storage_t* storage,
|
||||||
|
const uint8_t* data, size_t size,
|
||||||
|
const synor_upload_options_t* options, synor_upload_response_t* result) {
|
||||||
|
(void)storage; (void)data; (void)size; (void)options; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_storage_upload_directory(synor_storage_t* storage,
|
||||||
|
const synor_file_entry_t* files, size_t file_count,
|
||||||
|
const char* dir_name, synor_upload_response_t* result) {
|
||||||
|
(void)storage; (void)files; (void)file_count; (void)dir_name; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_storage_download(synor_storage_t* storage,
|
||||||
|
const char* cid, uint8_t** data, size_t* size) {
|
||||||
|
(void)storage; (void)cid; (void)data; (void)size;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_storage_download_stream(synor_storage_t* storage,
|
||||||
|
const char* cid, synor_download_callback_t callback, void* user_data) {
|
||||||
|
(void)storage; (void)cid; (void)callback; (void)user_data;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_storage_pin(synor_storage_t* storage,
|
||||||
|
const synor_pin_request_t* request, synor_pin_t* result) {
|
||||||
|
(void)storage; (void)request; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_storage_unpin(synor_storage_t* storage, const char* cid) {
|
||||||
|
(void)storage; (void)cid;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_storage_get_pin_status(synor_storage_t* storage,
|
||||||
|
const char* cid, synor_pin_t* result) {
|
||||||
|
(void)storage; (void)cid; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_storage_list_pins(synor_storage_t* storage,
|
||||||
|
synor_pin_status_t* status, int32_t limit, int32_t offset,
|
||||||
|
synor_pin_list_t* result) {
|
||||||
|
(void)storage; (void)status; (void)limit; (void)offset; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_storage_create_car(synor_storage_t* storage,
|
||||||
|
const synor_file_entry_t* entries, size_t entry_count,
|
||||||
|
synor_car_file_t* result) {
|
||||||
|
(void)storage; (void)entries; (void)entry_count; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_storage_import_car(synor_storage_t* storage,
|
||||||
|
const uint8_t* car_data, size_t car_size, synor_cid_list_t* result) {
|
||||||
|
(void)storage; (void)car_data; (void)car_size; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_storage_export_car(synor_storage_t* storage,
|
||||||
|
const char* cid, uint8_t** data, size_t* size) {
|
||||||
|
(void)storage; (void)cid; (void)data; (void)size;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_storage_list_directory(synor_storage_t* storage,
|
||||||
|
const char* cid, synor_directory_listing_t* result) {
|
||||||
|
(void)storage; (void)cid; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_storage_get_stats(synor_storage_t* storage,
|
||||||
|
synor_storage_stats_t* result) {
|
||||||
|
(void)storage; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Free functions */
|
||||||
|
void synor_upload_response_free(synor_upload_response_t* response) {
|
||||||
|
if (!response) return;
|
||||||
|
free(response->cid);
|
||||||
|
free(response->name);
|
||||||
|
free(response->created_at);
|
||||||
|
memset(response, 0, sizeof(*response));
|
||||||
|
}
|
||||||
|
|
||||||
|
void synor_pin_free(synor_pin_t* pin) {
|
||||||
|
if (!pin) return;
|
||||||
|
free(pin->cid);
|
||||||
|
free(pin->name);
|
||||||
|
free(pin->created_at);
|
||||||
|
free(pin->expires_at);
|
||||||
|
for (size_t i = 0; i < pin->delegate_count; i++) {
|
||||||
|
free(pin->delegates[i]);
|
||||||
|
}
|
||||||
|
free(pin->delegates);
|
||||||
|
memset(pin, 0, sizeof(*pin));
|
||||||
|
}
|
||||||
|
|
||||||
|
void synor_pin_list_free(synor_pin_list_t* list) {
|
||||||
|
if (!list) return;
|
||||||
|
for (size_t i = 0; i < list->count; i++) {
|
||||||
|
synor_pin_free(&list->pins[i]);
|
||||||
|
}
|
||||||
|
free(list->pins);
|
||||||
|
memset(list, 0, sizeof(*list));
|
||||||
|
}
|
||||||
|
|
||||||
|
void synor_car_file_free(synor_car_file_t* car) {
|
||||||
|
if (!car) return;
|
||||||
|
free(car->cid);
|
||||||
|
for (size_t i = 0; i < car->root_count; i++) {
|
||||||
|
free(car->roots[i]);
|
||||||
|
}
|
||||||
|
free(car->roots);
|
||||||
|
free(car->created_at);
|
||||||
|
memset(car, 0, sizeof(*car));
|
||||||
|
}
|
||||||
|
|
||||||
|
void synor_cid_list_free(synor_cid_list_t* list) {
|
||||||
|
if (!list) return;
|
||||||
|
for (size_t i = 0; i < list->count; i++) {
|
||||||
|
free(list->cids[i]);
|
||||||
|
}
|
||||||
|
free(list->cids);
|
||||||
|
memset(list, 0, sizeof(*list));
|
||||||
|
}
|
||||||
|
|
||||||
|
void synor_directory_entry_free(synor_directory_entry_t* entry) {
|
||||||
|
if (!entry) return;
|
||||||
|
free(entry->name);
|
||||||
|
free(entry->cid);
|
||||||
|
memset(entry, 0, sizeof(*entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
void synor_directory_listing_free(synor_directory_listing_t* listing) {
|
||||||
|
if (!listing) return;
|
||||||
|
for (size_t i = 0; i < listing->count; i++) {
|
||||||
|
synor_directory_entry_free(&listing->entries[i]);
|
||||||
|
}
|
||||||
|
free(listing->entries);
|
||||||
|
memset(listing, 0, sizeof(*listing));
|
||||||
|
}
|
||||||
355
sdk/c/src/wallet/wallet.c
Normal file
355
sdk/c/src/wallet/wallet.c
Normal file
|
|
@ -0,0 +1,355 @@
|
||||||
|
/**
|
||||||
|
* @file wallet.c
|
||||||
|
* @brief Synor Wallet SDK implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include "synor/wallet.h"
|
||||||
|
|
||||||
|
#ifdef HAVE_JANSSON
|
||||||
|
#include <jansson.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* Forward declarations from common.c */
|
||||||
|
typedef struct synor_http_client synor_http_client_t;
|
||||||
|
synor_http_client_t* synor_http_client_create(
|
||||||
|
const char* api_key, const char* endpoint,
|
||||||
|
uint32_t timeout_ms, uint32_t retries, bool debug);
|
||||||
|
void synor_http_client_destroy(synor_http_client_t* client);
|
||||||
|
synor_error_t synor_http_get(synor_http_client_t* client, const char* path,
|
||||||
|
char** response, size_t* response_size);
|
||||||
|
synor_error_t synor_http_post(synor_http_client_t* client, const char* path,
|
||||||
|
const char* body, char** response, size_t* response_size);
|
||||||
|
char* synor_strdup(const char* s);
|
||||||
|
|
||||||
|
/* Internal wallet structure */
|
||||||
|
struct synor_wallet {
|
||||||
|
synor_http_client_t* http;
|
||||||
|
synor_network_t network;
|
||||||
|
};
|
||||||
|
|
||||||
|
synor_wallet_t* synor_wallet_create(const synor_wallet_config_t* config) {
|
||||||
|
if (!config || !config->api_key) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_wallet_t* wallet = calloc(1, sizeof(synor_wallet_t));
|
||||||
|
if (!wallet) return NULL;
|
||||||
|
|
||||||
|
const char* endpoint = config->endpoint ?
|
||||||
|
config->endpoint : "https://wallet.synor.io/v1";
|
||||||
|
uint32_t timeout = config->timeout_ms > 0 ? config->timeout_ms : 30000;
|
||||||
|
uint32_t retries = config->retries > 0 ? config->retries : 3;
|
||||||
|
|
||||||
|
wallet->http = synor_http_client_create(
|
||||||
|
config->api_key, endpoint, timeout, retries, config->debug);
|
||||||
|
|
||||||
|
if (!wallet->http) {
|
||||||
|
free(wallet);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
wallet->network = config->network;
|
||||||
|
return wallet;
|
||||||
|
}
|
||||||
|
|
||||||
|
void synor_wallet_destroy(synor_wallet_t* wallet) {
|
||||||
|
if (!wallet) return;
|
||||||
|
synor_http_client_destroy(wallet->http);
|
||||||
|
free(wallet);
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef HAVE_JANSSON
|
||||||
|
|
||||||
|
static synor_error_t parse_wallet_info(json_t* json, synor_wallet_info_t* info) {
|
||||||
|
if (!json || !info) return SYNOR_ERROR_INVALID_ARGUMENT;
|
||||||
|
|
||||||
|
memset(info, 0, sizeof(*info));
|
||||||
|
|
||||||
|
json_t* id = json_object_get(json, "id");
|
||||||
|
if (id) info->id = synor_strdup(json_string_value(id));
|
||||||
|
|
||||||
|
json_t* type = json_object_get(json, "type");
|
||||||
|
if (type) {
|
||||||
|
const char* type_str = json_string_value(type);
|
||||||
|
if (strcmp(type_str, "standard") == 0) info->type = SYNOR_WALLET_STANDARD;
|
||||||
|
else if (strcmp(type_str, "multisig") == 0) info->type = SYNOR_WALLET_MULTISIG;
|
||||||
|
else if (strcmp(type_str, "hardware") == 0) info->type = SYNOR_WALLET_HARDWARE;
|
||||||
|
else if (strcmp(type_str, "stealth") == 0) info->type = SYNOR_WALLET_STEALTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
json_t* network = json_object_get(json, "network");
|
||||||
|
if (network) {
|
||||||
|
const char* net_str = json_string_value(network);
|
||||||
|
if (strcmp(net_str, "mainnet") == 0) info->network = SYNOR_NETWORK_MAINNET;
|
||||||
|
else if (strcmp(net_str, "testnet") == 0) info->network = SYNOR_NETWORK_TESTNET;
|
||||||
|
else if (strcmp(net_str, "devnet") == 0) info->network = SYNOR_NETWORK_DEVNET;
|
||||||
|
}
|
||||||
|
|
||||||
|
json_t* created_at = json_object_get(json, "created_at");
|
||||||
|
if (created_at) info->created_at = synor_strdup(json_string_value(created_at));
|
||||||
|
|
||||||
|
json_t* addresses = json_object_get(json, "addresses");
|
||||||
|
if (addresses && json_is_array(addresses)) {
|
||||||
|
size_t count = json_array_size(addresses);
|
||||||
|
info->addresses = calloc(count, sizeof(synor_address_t));
|
||||||
|
info->address_count = count;
|
||||||
|
|
||||||
|
for (size_t i = 0; i < count; i++) {
|
||||||
|
json_t* addr = json_array_get(addresses, i);
|
||||||
|
info->addresses[i].address = synor_strdup(
|
||||||
|
json_string_value(json_object_get(addr, "address")));
|
||||||
|
info->addresses[i].index = (int32_t)json_integer_value(
|
||||||
|
json_object_get(addr, "index"));
|
||||||
|
info->addresses[i].is_change = json_boolean_value(
|
||||||
|
json_object_get(addr, "is_change"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SYNOR_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_wallet_create_wallet(
|
||||||
|
synor_wallet_t* wallet,
|
||||||
|
synor_wallet_type_t type,
|
||||||
|
synor_create_wallet_result_t* result
|
||||||
|
) {
|
||||||
|
if (!wallet || !result) return SYNOR_ERROR_INVALID_ARGUMENT;
|
||||||
|
|
||||||
|
const char* type_str;
|
||||||
|
switch (type) {
|
||||||
|
case SYNOR_WALLET_STANDARD: type_str = "standard"; break;
|
||||||
|
case SYNOR_WALLET_MULTISIG: type_str = "multisig"; break;
|
||||||
|
case SYNOR_WALLET_HARDWARE: type_str = "hardware"; break;
|
||||||
|
case SYNOR_WALLET_STEALTH: type_str = "stealth"; break;
|
||||||
|
default: return SYNOR_ERROR_INVALID_ARGUMENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* net_str;
|
||||||
|
switch (wallet->network) {
|
||||||
|
case SYNOR_NETWORK_MAINNET: net_str = "mainnet"; break;
|
||||||
|
case SYNOR_NETWORK_TESTNET: net_str = "testnet"; break;
|
||||||
|
case SYNOR_NETWORK_DEVNET: net_str = "devnet"; break;
|
||||||
|
default: net_str = "mainnet"; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
char body[256];
|
||||||
|
snprintf(body, sizeof(body),
|
||||||
|
"{\"type\":\"%s\",\"network\":\"%s\"}", type_str, net_str);
|
||||||
|
|
||||||
|
char* response = NULL;
|
||||||
|
synor_error_t err = synor_http_post(wallet->http, "/wallets", body,
|
||||||
|
&response, NULL);
|
||||||
|
|
||||||
|
if (err != SYNOR_OK) return err;
|
||||||
|
|
||||||
|
json_error_t json_err;
|
||||||
|
json_t* json = json_loads(response, 0, &json_err);
|
||||||
|
free(response);
|
||||||
|
|
||||||
|
if (!json) return SYNOR_ERROR_JSON_PARSE;
|
||||||
|
|
||||||
|
memset(result, 0, sizeof(*result));
|
||||||
|
|
||||||
|
json_t* wallet_json = json_object_get(json, "wallet");
|
||||||
|
if (wallet_json) {
|
||||||
|
parse_wallet_info(wallet_json, &result->wallet);
|
||||||
|
}
|
||||||
|
|
||||||
|
json_t* mnemonic = json_object_get(json, "mnemonic");
|
||||||
|
if (mnemonic) {
|
||||||
|
result->mnemonic = synor_strdup(json_string_value(mnemonic));
|
||||||
|
}
|
||||||
|
|
||||||
|
json_decref(json);
|
||||||
|
return SYNOR_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_wallet_get_balance(
|
||||||
|
synor_wallet_t* wallet,
|
||||||
|
const char* address,
|
||||||
|
synor_balance_t* result
|
||||||
|
) {
|
||||||
|
if (!wallet || !address || !result) return SYNOR_ERROR_INVALID_ARGUMENT;
|
||||||
|
|
||||||
|
char path[512];
|
||||||
|
snprintf(path, sizeof(path), "/addresses/%s/balance", address);
|
||||||
|
|
||||||
|
char* response = NULL;
|
||||||
|
synor_error_t err = synor_http_get(wallet->http, path, &response, NULL);
|
||||||
|
|
||||||
|
if (err != SYNOR_OK) return err;
|
||||||
|
|
||||||
|
json_error_t json_err;
|
||||||
|
json_t* json = json_loads(response, 0, &json_err);
|
||||||
|
free(response);
|
||||||
|
|
||||||
|
if (!json) return SYNOR_ERROR_JSON_PARSE;
|
||||||
|
|
||||||
|
result->confirmed = json_integer_value(json_object_get(json, "confirmed"));
|
||||||
|
result->unconfirmed = json_integer_value(json_object_get(json, "unconfirmed"));
|
||||||
|
result->total = json_integer_value(json_object_get(json, "total"));
|
||||||
|
|
||||||
|
json_decref(json);
|
||||||
|
return SYNOR_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
#else /* No JANSSON */
|
||||||
|
|
||||||
|
synor_error_t synor_wallet_create_wallet(
|
||||||
|
synor_wallet_t* wallet,
|
||||||
|
synor_wallet_type_t type,
|
||||||
|
synor_create_wallet_result_t* result
|
||||||
|
) {
|
||||||
|
(void)wallet; (void)type; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN; /* JSON parsing not available */
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_wallet_get_balance(
|
||||||
|
synor_wallet_t* wallet,
|
||||||
|
const char* address,
|
||||||
|
synor_balance_t* result
|
||||||
|
) {
|
||||||
|
(void)wallet; (void)address; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif /* HAVE_JANSSON */
|
||||||
|
|
||||||
|
/* Stub implementations for remaining functions */
|
||||||
|
synor_error_t synor_wallet_import(
|
||||||
|
synor_wallet_t* wallet, const char* mnemonic,
|
||||||
|
const char* passphrase, synor_wallet_info_t* result
|
||||||
|
) {
|
||||||
|
(void)wallet; (void)mnemonic; (void)passphrase; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN; /* TODO: Implement */
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_wallet_get(
|
||||||
|
synor_wallet_t* wallet, const char* wallet_id,
|
||||||
|
synor_wallet_info_t* result
|
||||||
|
) {
|
||||||
|
(void)wallet; (void)wallet_id; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_wallet_generate_address(
|
||||||
|
synor_wallet_t* wallet, const char* wallet_id,
|
||||||
|
bool is_change, synor_address_t* result
|
||||||
|
) {
|
||||||
|
(void)wallet; (void)wallet_id; (void)is_change; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_wallet_get_stealth_address(
|
||||||
|
synor_wallet_t* wallet, const char* wallet_id,
|
||||||
|
synor_stealth_address_t* result
|
||||||
|
) {
|
||||||
|
(void)wallet; (void)wallet_id; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_wallet_sign_transaction(
|
||||||
|
synor_wallet_t* wallet, const char* wallet_id,
|
||||||
|
const synor_transaction_t* transaction, synor_signed_tx_t* result
|
||||||
|
) {
|
||||||
|
(void)wallet; (void)wallet_id; (void)transaction; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_wallet_sign_message(
|
||||||
|
synor_wallet_t* wallet, const char* wallet_id,
|
||||||
|
const char* message, int32_t address_index, synor_signature_t* result
|
||||||
|
) {
|
||||||
|
(void)wallet; (void)wallet_id; (void)message; (void)address_index; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_wallet_verify_message(
|
||||||
|
synor_wallet_t* wallet, const char* message,
|
||||||
|
const char* signature, const char* address, bool* is_valid
|
||||||
|
) {
|
||||||
|
(void)wallet; (void)message; (void)signature; (void)address; (void)is_valid;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_wallet_get_utxos(
|
||||||
|
synor_wallet_t* wallet, const char* address,
|
||||||
|
int32_t min_confirmations, synor_utxo_list_t* result
|
||||||
|
) {
|
||||||
|
(void)wallet; (void)address; (void)min_confirmations; (void)result;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
synor_error_t synor_wallet_estimate_fee(
|
||||||
|
synor_wallet_t* wallet, synor_priority_t priority, int64_t* fee_per_byte
|
||||||
|
) {
|
||||||
|
(void)wallet; (void)priority; (void)fee_per_byte;
|
||||||
|
return SYNOR_ERROR_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Free functions */
|
||||||
|
void synor_address_free(synor_address_t* addr) {
|
||||||
|
if (!addr) return;
|
||||||
|
free(addr->address);
|
||||||
|
memset(addr, 0, sizeof(*addr));
|
||||||
|
}
|
||||||
|
|
||||||
|
void synor_wallet_info_free(synor_wallet_info_t* info) {
|
||||||
|
if (!info) return;
|
||||||
|
free(info->id);
|
||||||
|
free(info->created_at);
|
||||||
|
for (size_t i = 0; i < info->address_count; i++) {
|
||||||
|
synor_address_free(&info->addresses[i]);
|
||||||
|
}
|
||||||
|
free(info->addresses);
|
||||||
|
memset(info, 0, sizeof(*info));
|
||||||
|
}
|
||||||
|
|
||||||
|
void synor_stealth_address_free(synor_stealth_address_t* addr) {
|
||||||
|
if (!addr) return;
|
||||||
|
free(addr->spend_public_key);
|
||||||
|
free(addr->view_public_key);
|
||||||
|
free(addr->one_time_address);
|
||||||
|
memset(addr, 0, sizeof(*addr));
|
||||||
|
}
|
||||||
|
|
||||||
|
void synor_create_wallet_result_free(synor_create_wallet_result_t* result) {
|
||||||
|
if (!result) return;
|
||||||
|
synor_wallet_info_free(&result->wallet);
|
||||||
|
free(result->mnemonic);
|
||||||
|
memset(result, 0, sizeof(*result));
|
||||||
|
}
|
||||||
|
|
||||||
|
void synor_signed_tx_free(synor_signed_tx_t* tx) {
|
||||||
|
if (!tx) return;
|
||||||
|
free(tx->txid);
|
||||||
|
free(tx->hex);
|
||||||
|
memset(tx, 0, sizeof(*tx));
|
||||||
|
}
|
||||||
|
|
||||||
|
void synor_signature_free(synor_signature_t* sig) {
|
||||||
|
if (!sig) return;
|
||||||
|
free(sig->signature);
|
||||||
|
free(sig->address);
|
||||||
|
memset(sig, 0, sizeof(*sig));
|
||||||
|
}
|
||||||
|
|
||||||
|
void synor_utxo_free(synor_utxo_t* utxo) {
|
||||||
|
if (!utxo) return;
|
||||||
|
free(utxo->txid);
|
||||||
|
free(utxo->address);
|
||||||
|
free(utxo->script_pubkey);
|
||||||
|
memset(utxo, 0, sizeof(*utxo));
|
||||||
|
}
|
||||||
|
|
||||||
|
void synor_utxo_list_free(synor_utxo_list_t* list) {
|
||||||
|
if (!list) return;
|
||||||
|
for (size_t i = 0; i < list->count; i++) {
|
||||||
|
synor_utxo_free(&list->utxos[i]);
|
||||||
|
}
|
||||||
|
free(list->utxos);
|
||||||
|
memset(list, 0, sizeof(*list));
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
cmake_minimum_required(VERSION 3.16)
|
cmake_minimum_required(VERSION 3.16)
|
||||||
project(synor_compute VERSION 0.1.0 LANGUAGES CXX)
|
project(synor_sdk VERSION 0.1.0 LANGUAGES CXX)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 20)
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
|
@ -28,25 +28,31 @@ set(SYNOR_SOURCES
|
||||||
src/synor_compute.cpp
|
src/synor_compute.cpp
|
||||||
src/tensor.cpp
|
src/tensor.cpp
|
||||||
src/client.cpp
|
src/client.cpp
|
||||||
|
src/wallet/wallet.cpp
|
||||||
|
src/rpc/rpc.cpp
|
||||||
|
src/storage/storage.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create library
|
# Create library
|
||||||
add_library(synor_compute ${SYNOR_SOURCES})
|
add_library(synor_sdk ${SYNOR_SOURCES})
|
||||||
|
|
||||||
target_include_directories(synor_compute
|
# Alias for backwards compatibility
|
||||||
|
add_library(synor_compute ALIAS synor_sdk)
|
||||||
|
|
||||||
|
target_include_directories(synor_sdk
|
||||||
PUBLIC
|
PUBLIC
|
||||||
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
|
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
|
||||||
$<INSTALL_INTERFACE:include>
|
$<INSTALL_INTERFACE:include>
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(synor_compute
|
target_link_libraries(synor_sdk
|
||||||
PRIVATE
|
PRIVATE
|
||||||
CURL::libcurl
|
CURL::libcurl
|
||||||
nlohmann_json::nlohmann_json
|
nlohmann_json::nlohmann_json
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set library properties
|
# Set library properties
|
||||||
set_target_properties(synor_compute PROPERTIES
|
set_target_properties(synor_sdk PROPERTIES
|
||||||
VERSION ${PROJECT_VERSION}
|
VERSION ${PROJECT_VERSION}
|
||||||
SOVERSION ${PROJECT_VERSION_MAJOR}
|
SOVERSION ${PROJECT_VERSION_MAJOR}
|
||||||
)
|
)
|
||||||
|
|
@ -54,8 +60,8 @@ set_target_properties(synor_compute PROPERTIES
|
||||||
# Installation
|
# Installation
|
||||||
include(GNUInstallDirs)
|
include(GNUInstallDirs)
|
||||||
|
|
||||||
install(TARGETS synor_compute
|
install(TARGETS synor_sdk
|
||||||
EXPORT synor_compute-targets
|
EXPORT synor_sdk-targets
|
||||||
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
||||||
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
||||||
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
||||||
|
|
@ -65,8 +71,8 @@ install(DIRECTORY include/
|
||||||
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
|
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
|
||||||
)
|
)
|
||||||
|
|
||||||
install(EXPORT synor_compute-targets
|
install(EXPORT synor_sdk-targets
|
||||||
FILE synor_compute-targets.cmake
|
FILE synor_sdk-targets.cmake
|
||||||
NAMESPACE synor::
|
NAMESPACE synor::
|
||||||
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/synor_compute
|
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/synor_sdk
|
||||||
)
|
)
|
||||||
|
|
|
||||||
219
sdk/cpp/include/synor/rpc.hpp
Normal file
219
sdk/cpp/include/synor/rpc.hpp
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
/**
|
||||||
|
* @file rpc.hpp
|
||||||
|
* @brief Synor RPC SDK for C++
|
||||||
|
*
|
||||||
|
* Modern C++17 SDK for blockchain queries and real-time subscriptions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <optional>
|
||||||
|
#include <future>
|
||||||
|
#include <memory>
|
||||||
|
#include <functional>
|
||||||
|
#include <cstdint>
|
||||||
|
#include "wallet.hpp" // For Network, Priority, SynorException
|
||||||
|
|
||||||
|
namespace synor {
|
||||||
|
namespace rpc {
|
||||||
|
|
||||||
|
/// Transaction status
|
||||||
|
enum class TransactionStatus {
|
||||||
|
Pending,
|
||||||
|
Confirmed,
|
||||||
|
Failed
|
||||||
|
};
|
||||||
|
|
||||||
|
/// RPC configuration
|
||||||
|
struct Config {
|
||||||
|
std::string api_key;
|
||||||
|
std::string endpoint = "https://rpc.synor.io/v1";
|
||||||
|
std::string ws_endpoint = "wss://rpc.synor.io/v1/ws";
|
||||||
|
Network network = Network::Mainnet;
|
||||||
|
uint32_t timeout_ms = 30000;
|
||||||
|
uint32_t retries = 3;
|
||||||
|
bool debug = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Block header
|
||||||
|
struct BlockHeader {
|
||||||
|
std::string hash;
|
||||||
|
int64_t height;
|
||||||
|
std::string previous_hash;
|
||||||
|
int64_t timestamp;
|
||||||
|
int32_t version;
|
||||||
|
std::string merkle_root;
|
||||||
|
int64_t nonce;
|
||||||
|
double difficulty;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Script public key
|
||||||
|
struct ScriptPubKey {
|
||||||
|
std::string asm_code;
|
||||||
|
std::string hex;
|
||||||
|
std::string type;
|
||||||
|
std::optional<std::string> address;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Transaction input
|
||||||
|
struct TransactionInput {
|
||||||
|
std::string txid;
|
||||||
|
int32_t vout;
|
||||||
|
std::optional<std::string> script_sig;
|
||||||
|
int64_t sequence;
|
||||||
|
std::vector<std::string> witness;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Transaction output
|
||||||
|
struct TransactionOutput {
|
||||||
|
int64_t value;
|
||||||
|
int32_t n;
|
||||||
|
ScriptPubKey script_pubkey;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Transaction
|
||||||
|
struct Transaction {
|
||||||
|
std::string txid;
|
||||||
|
std::string hash;
|
||||||
|
int32_t version;
|
||||||
|
int32_t size;
|
||||||
|
int32_t weight;
|
||||||
|
int64_t lock_time;
|
||||||
|
std::vector<TransactionInput> inputs;
|
||||||
|
std::vector<TransactionOutput> outputs;
|
||||||
|
int64_t fee;
|
||||||
|
int32_t confirmations;
|
||||||
|
std::optional<std::string> block_hash;
|
||||||
|
std::optional<int64_t> block_height;
|
||||||
|
std::optional<int64_t> timestamp;
|
||||||
|
TransactionStatus status = TransactionStatus::Pending;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Block
|
||||||
|
struct Block {
|
||||||
|
std::string hash;
|
||||||
|
int64_t height;
|
||||||
|
std::string previous_hash;
|
||||||
|
int64_t timestamp;
|
||||||
|
int32_t version;
|
||||||
|
std::string merkle_root;
|
||||||
|
int64_t nonce;
|
||||||
|
double difficulty;
|
||||||
|
std::vector<Transaction> transactions;
|
||||||
|
int32_t size;
|
||||||
|
int32_t weight;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Chain information
|
||||||
|
struct ChainInfo {
|
||||||
|
std::string chain;
|
||||||
|
int64_t blocks;
|
||||||
|
int64_t headers;
|
||||||
|
std::string best_block_hash;
|
||||||
|
double difficulty;
|
||||||
|
int64_t median_time;
|
||||||
|
double verification_progress;
|
||||||
|
std::string chain_work;
|
||||||
|
bool pruned;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Mempool information
|
||||||
|
struct MempoolInfo {
|
||||||
|
int32_t size;
|
||||||
|
int64_t bytes;
|
||||||
|
int64_t usage;
|
||||||
|
int64_t max_mempool;
|
||||||
|
double mempool_min_fee;
|
||||||
|
double min_relay_tx_fee;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Fee estimate
|
||||||
|
struct FeeEstimate {
|
||||||
|
Priority priority;
|
||||||
|
int64_t fee_rate;
|
||||||
|
int32_t estimated_blocks;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Transaction submission result
|
||||||
|
struct SubmitResult {
|
||||||
|
std::string txid;
|
||||||
|
bool accepted;
|
||||||
|
std::optional<std::string> reason;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Subscription handle
|
||||||
|
class Subscription {
|
||||||
|
public:
|
||||||
|
Subscription(const std::string& id, const std::string& channel,
|
||||||
|
std::function<void()> on_cancel);
|
||||||
|
~Subscription();
|
||||||
|
|
||||||
|
Subscription(const Subscription&) = delete;
|
||||||
|
Subscription& operator=(const Subscription&) = delete;
|
||||||
|
Subscription(Subscription&&) noexcept;
|
||||||
|
Subscription& operator=(Subscription&&) noexcept;
|
||||||
|
|
||||||
|
const std::string& id() const { return id_; }
|
||||||
|
const std::string& channel() const { return channel_; }
|
||||||
|
|
||||||
|
void cancel();
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::string id_;
|
||||||
|
std::string channel_;
|
||||||
|
std::function<void()> on_cancel_;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// RPC client
|
||||||
|
class Client {
|
||||||
|
public:
|
||||||
|
explicit Client(const Config& config);
|
||||||
|
~Client();
|
||||||
|
|
||||||
|
Client(const Client&) = delete;
|
||||||
|
Client& operator=(const Client&) = delete;
|
||||||
|
Client(Client&&) noexcept;
|
||||||
|
Client& operator=(Client&&) noexcept;
|
||||||
|
|
||||||
|
// Block operations
|
||||||
|
Block get_latest_block();
|
||||||
|
std::future<Block> get_latest_block_async();
|
||||||
|
Block get_block(const std::string& hash_or_height);
|
||||||
|
BlockHeader get_block_header(const std::string& hash_or_height);
|
||||||
|
std::vector<Block> get_blocks(int64_t start_height, int64_t end_height);
|
||||||
|
|
||||||
|
// Transaction operations
|
||||||
|
Transaction get_transaction(const std::string& txid);
|
||||||
|
std::string get_raw_transaction(const std::string& txid);
|
||||||
|
SubmitResult send_raw_transaction(const std::string& hex);
|
||||||
|
Transaction decode_raw_transaction(const std::string& hex);
|
||||||
|
std::vector<Transaction> get_address_transactions(
|
||||||
|
const std::string& address, int32_t limit = 50, int32_t offset = 0);
|
||||||
|
|
||||||
|
// Fee estimation
|
||||||
|
FeeEstimate estimate_fee(Priority priority = Priority::Medium);
|
||||||
|
std::map<Priority, FeeEstimate> get_all_fee_estimates();
|
||||||
|
|
||||||
|
// Chain information
|
||||||
|
ChainInfo get_chain_info();
|
||||||
|
MempoolInfo get_mempool_info();
|
||||||
|
std::vector<std::string> get_mempool_transactions(int32_t limit = 100);
|
||||||
|
|
||||||
|
// Subscriptions
|
||||||
|
std::unique_ptr<Subscription> subscribe_blocks(
|
||||||
|
std::function<void(const Block&)> callback);
|
||||||
|
std::unique_ptr<Subscription> subscribe_address(
|
||||||
|
const std::string& address,
|
||||||
|
std::function<void(const Transaction&)> callback);
|
||||||
|
std::unique_ptr<Subscription> subscribe_mempool(
|
||||||
|
std::function<void(const Transaction&)> callback);
|
||||||
|
|
||||||
|
private:
|
||||||
|
class Impl;
|
||||||
|
std::unique_ptr<Impl> impl_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace rpc
|
||||||
|
} // namespace synor
|
||||||
174
sdk/cpp/include/synor/storage.hpp
Normal file
174
sdk/cpp/include/synor/storage.hpp
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
/**
|
||||||
|
* @file storage.hpp
|
||||||
|
* @brief Synor Storage SDK for C++
|
||||||
|
*
|
||||||
|
* Modern C++17 SDK for IPFS-compatible decentralized storage.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <optional>
|
||||||
|
#include <future>
|
||||||
|
#include <memory>
|
||||||
|
#include <functional>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <span>
|
||||||
|
#include "wallet.hpp" // For SynorException
|
||||||
|
|
||||||
|
namespace synor {
|
||||||
|
namespace storage {
|
||||||
|
|
||||||
|
/// Pin status
|
||||||
|
enum class PinStatus {
|
||||||
|
Queued,
|
||||||
|
Pinning,
|
||||||
|
Pinned,
|
||||||
|
Failed,
|
||||||
|
Unpinned
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Hash algorithm
|
||||||
|
enum class HashAlgorithm {
|
||||||
|
Sha2_256,
|
||||||
|
Blake3
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Entry type
|
||||||
|
enum class EntryType {
|
||||||
|
File,
|
||||||
|
Directory
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Storage configuration
|
||||||
|
struct Config {
|
||||||
|
std::string api_key;
|
||||||
|
std::string endpoint = "https://storage.synor.io/v1";
|
||||||
|
std::string gateway = "https://gateway.synor.io";
|
||||||
|
uint32_t timeout_ms = 60000;
|
||||||
|
uint32_t retries = 3;
|
||||||
|
bool debug = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Upload options
|
||||||
|
struct UploadOptions {
|
||||||
|
std::optional<std::string> name;
|
||||||
|
bool wrap_with_directory = false;
|
||||||
|
HashAlgorithm hash_algorithm = HashAlgorithm::Sha2_256;
|
||||||
|
std::optional<int32_t> chunk_size;
|
||||||
|
std::optional<int32_t> pin_duration_days;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Upload response
|
||||||
|
struct UploadResponse {
|
||||||
|
std::string cid;
|
||||||
|
int64_t size;
|
||||||
|
std::optional<std::string> name;
|
||||||
|
std::string created_at;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Pin information
|
||||||
|
struct Pin {
|
||||||
|
std::string cid;
|
||||||
|
std::optional<std::string> name;
|
||||||
|
PinStatus status;
|
||||||
|
int64_t size;
|
||||||
|
std::string created_at;
|
||||||
|
std::optional<std::string> expires_at;
|
||||||
|
std::vector<std::string> delegates;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Pin request
|
||||||
|
struct PinRequest {
|
||||||
|
std::string cid;
|
||||||
|
std::optional<std::string> name;
|
||||||
|
std::optional<int32_t> duration_days;
|
||||||
|
std::vector<std::string> origins;
|
||||||
|
std::map<std::string, std::string> meta;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// CAR file information
|
||||||
|
struct CarFile {
|
||||||
|
std::string cid;
|
||||||
|
int64_t size;
|
||||||
|
std::vector<std::string> roots;
|
||||||
|
std::string created_at;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// File entry for upload
|
||||||
|
struct FileEntry {
|
||||||
|
std::string path;
|
||||||
|
std::vector<uint8_t> content;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Directory entry
|
||||||
|
struct DirectoryEntry {
|
||||||
|
std::string name;
|
||||||
|
std::string cid;
|
||||||
|
int64_t size;
|
||||||
|
EntryType type;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Storage statistics
|
||||||
|
struct StorageStats {
|
||||||
|
int64_t total_size;
|
||||||
|
int32_t pin_count;
|
||||||
|
int64_t bandwidth_used;
|
||||||
|
double quota_used;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Storage client
|
||||||
|
class Client {
|
||||||
|
public:
|
||||||
|
explicit Client(const Config& config);
|
||||||
|
~Client();
|
||||||
|
|
||||||
|
Client(const Client&) = delete;
|
||||||
|
Client& operator=(const Client&) = delete;
|
||||||
|
Client(Client&&) noexcept;
|
||||||
|
Client& operator=(Client&&) noexcept;
|
||||||
|
|
||||||
|
// Upload operations
|
||||||
|
UploadResponse upload(std::span<const uint8_t> data,
|
||||||
|
const UploadOptions& options = {});
|
||||||
|
std::future<UploadResponse> upload_async(std::vector<uint8_t> data,
|
||||||
|
UploadOptions options = {});
|
||||||
|
UploadResponse upload_directory(const std::vector<FileEntry>& files,
|
||||||
|
const std::optional<std::string>& dir_name = std::nullopt);
|
||||||
|
|
||||||
|
// Download operations
|
||||||
|
std::vector<uint8_t> download(const std::string& cid);
|
||||||
|
std::future<std::vector<uint8_t>> download_async(const std::string& cid);
|
||||||
|
void download_stream(const std::string& cid,
|
||||||
|
std::function<void(std::span<const uint8_t>)> callback);
|
||||||
|
|
||||||
|
/// Get gateway URL for content
|
||||||
|
std::string get_gateway_url(const std::string& cid,
|
||||||
|
const std::optional<std::string>& path = std::nullopt);
|
||||||
|
|
||||||
|
// Pinning operations
|
||||||
|
Pin pin(const PinRequest& request);
|
||||||
|
void unpin(const std::string& cid);
|
||||||
|
Pin get_pin_status(const std::string& cid);
|
||||||
|
std::vector<Pin> list_pins(std::optional<PinStatus> status = std::nullopt,
|
||||||
|
int32_t limit = 50, int32_t offset = 0);
|
||||||
|
|
||||||
|
// CAR file operations
|
||||||
|
CarFile create_car(const std::vector<FileEntry>& entries);
|
||||||
|
std::vector<std::string> import_car(std::span<const uint8_t> car_data);
|
||||||
|
std::vector<uint8_t> export_car(const std::string& cid);
|
||||||
|
|
||||||
|
// Directory operations
|
||||||
|
std::vector<DirectoryEntry> list_directory(const std::string& cid);
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
StorageStats get_stats();
|
||||||
|
|
||||||
|
private:
|
||||||
|
class Impl;
|
||||||
|
std::unique_ptr<Impl> impl_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace storage
|
||||||
|
} // namespace synor
|
||||||
68
sdk/cpp/include/synor/synor.hpp
Normal file
68
sdk/cpp/include/synor/synor.hpp
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
/**
|
||||||
|
* @file synor.hpp
|
||||||
|
* @brief Synor SDK for C++ - Main Include Header
|
||||||
|
*
|
||||||
|
* This header includes all Synor SDK components:
|
||||||
|
* - Wallet: Key management and transaction signing
|
||||||
|
* - RPC: Blockchain queries and subscriptions
|
||||||
|
* - Storage: IPFS-compatible decentralized storage
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```cpp
|
||||||
|
* #include <synor/synor.hpp>
|
||||||
|
*
|
||||||
|
* int main() {
|
||||||
|
* // Create clients
|
||||||
|
* synor::wallet::Client wallet({.api_key = "your-api-key"});
|
||||||
|
* synor::rpc::Client rpc({.api_key = "your-api-key"});
|
||||||
|
* synor::storage::Client storage({.api_key = "your-api-key"});
|
||||||
|
*
|
||||||
|
* // Use the SDKs...
|
||||||
|
* return 0;
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "wallet.hpp"
|
||||||
|
#include "rpc.hpp"
|
||||||
|
#include "storage.hpp"
|
||||||
|
|
||||||
|
namespace synor {
|
||||||
|
|
||||||
|
/// SDK version
|
||||||
|
inline constexpr const char* VERSION = "0.1.0";
|
||||||
|
|
||||||
|
/// Convert Network to string
|
||||||
|
inline const char* to_string(Network network) {
|
||||||
|
switch (network) {
|
||||||
|
case Network::Mainnet: return "mainnet";
|
||||||
|
case Network::Testnet: return "testnet";
|
||||||
|
case Network::Devnet: return "devnet";
|
||||||
|
default: return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert WalletType to string
|
||||||
|
inline const char* to_string(WalletType type) {
|
||||||
|
switch (type) {
|
||||||
|
case WalletType::Standard: return "standard";
|
||||||
|
case WalletType::Multisig: return "multisig";
|
||||||
|
case WalletType::Hardware: return "hardware";
|
||||||
|
case WalletType::Stealth: return "stealth";
|
||||||
|
default: return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert Priority to string
|
||||||
|
inline const char* to_string(Priority priority) {
|
||||||
|
switch (priority) {
|
||||||
|
case Priority::Low: return "low";
|
||||||
|
case Priority::Medium: return "medium";
|
||||||
|
case Priority::High: return "high";
|
||||||
|
default: return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace synor
|
||||||
242
sdk/cpp/include/synor/wallet.hpp
Normal file
242
sdk/cpp/include/synor/wallet.hpp
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
/**
|
||||||
|
* @file wallet.hpp
|
||||||
|
* @brief Synor Wallet SDK for C++
|
||||||
|
*
|
||||||
|
* Modern C++17 SDK for key management, transaction signing, and balance queries.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```cpp
|
||||||
|
* #include <synor/wallet.hpp>
|
||||||
|
*
|
||||||
|
* int main() {
|
||||||
|
* synor::wallet::Config config{
|
||||||
|
* .api_key = "your-api-key",
|
||||||
|
* .endpoint = "https://wallet.synor.io/v1",
|
||||||
|
* .network = synor::Network::Mainnet
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* synor::wallet::Client client(config);
|
||||||
|
*
|
||||||
|
* // Create wallet
|
||||||
|
* auto result = client.create_wallet(synor::WalletType::Standard);
|
||||||
|
* std::cout << "Wallet ID: " << result.wallet.id << "\n";
|
||||||
|
* std::cout << "Mnemonic: " << result.mnemonic.value_or("N/A") << "\n";
|
||||||
|
*
|
||||||
|
* // Get balance
|
||||||
|
* auto balance = client.get_balance(result.wallet.addresses[0].address);
|
||||||
|
* std::cout << "Balance: " << balance.total << "\n";
|
||||||
|
*
|
||||||
|
* return 0;
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <optional>
|
||||||
|
#include <future>
|
||||||
|
#include <memory>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
namespace synor {
|
||||||
|
|
||||||
|
// Forward declarations
|
||||||
|
namespace wallet { class Client; }
|
||||||
|
|
||||||
|
/// Network environment
|
||||||
|
enum class Network {
|
||||||
|
Mainnet,
|
||||||
|
Testnet,
|
||||||
|
Devnet
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Wallet type
|
||||||
|
enum class WalletType {
|
||||||
|
Standard,
|
||||||
|
Multisig,
|
||||||
|
Hardware,
|
||||||
|
Stealth
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Transaction priority
|
||||||
|
enum class Priority {
|
||||||
|
Low,
|
||||||
|
Medium,
|
||||||
|
High
|
||||||
|
};
|
||||||
|
|
||||||
|
/// SDK exception
|
||||||
|
class SynorException : public std::runtime_error {
|
||||||
|
public:
|
||||||
|
explicit SynorException(const std::string& message, int status_code = 0)
|
||||||
|
: std::runtime_error(message), status_code_(status_code) {}
|
||||||
|
|
||||||
|
int status_code() const noexcept { return status_code_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
int status_code_;
|
||||||
|
};
|
||||||
|
|
||||||
|
namespace wallet {
|
||||||
|
|
||||||
|
/// Wallet configuration
|
||||||
|
struct Config {
|
||||||
|
std::string api_key;
|
||||||
|
std::string endpoint = "https://wallet.synor.io/v1";
|
||||||
|
Network network = Network::Mainnet;
|
||||||
|
uint32_t timeout_ms = 30000;
|
||||||
|
uint32_t retries = 3;
|
||||||
|
bool debug = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Wallet address
|
||||||
|
struct Address {
|
||||||
|
std::string address;
|
||||||
|
int32_t index;
|
||||||
|
bool is_change = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Wallet information
|
||||||
|
struct Wallet {
|
||||||
|
std::string id;
|
||||||
|
WalletType type;
|
||||||
|
Network network;
|
||||||
|
std::vector<Address> addresses;
|
||||||
|
std::string created_at;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Stealth address for privacy transactions
|
||||||
|
struct StealthAddress {
|
||||||
|
std::string spend_public_key;
|
||||||
|
std::string view_public_key;
|
||||||
|
std::string one_time_address;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Result of wallet creation
|
||||||
|
struct CreateWalletResult {
|
||||||
|
Wallet wallet;
|
||||||
|
std::optional<std::string> mnemonic;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Transaction input (UTXO reference)
|
||||||
|
struct TransactionInput {
|
||||||
|
std::string txid;
|
||||||
|
int32_t vout;
|
||||||
|
int64_t amount;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Transaction output
|
||||||
|
struct TransactionOutput {
|
||||||
|
std::string address;
|
||||||
|
int64_t amount;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Transaction for signing
|
||||||
|
struct Transaction {
|
||||||
|
std::vector<TransactionInput> inputs;
|
||||||
|
std::vector<TransactionOutput> outputs;
|
||||||
|
std::optional<int64_t> fee;
|
||||||
|
Priority priority = Priority::Medium;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Signed transaction
|
||||||
|
struct SignedTransaction {
|
||||||
|
std::string txid;
|
||||||
|
std::string hex;
|
||||||
|
int32_t size;
|
||||||
|
int64_t fee;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Cryptographic signature
|
||||||
|
struct Signature {
|
||||||
|
std::string signature;
|
||||||
|
std::string address;
|
||||||
|
std::optional<int32_t> recovery_id;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Unspent transaction output
|
||||||
|
struct UTXO {
|
||||||
|
std::string txid;
|
||||||
|
int32_t vout;
|
||||||
|
int64_t amount;
|
||||||
|
std::string address;
|
||||||
|
int32_t confirmations;
|
||||||
|
std::optional<std::string> script_pubkey;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Wallet balance
|
||||||
|
struct Balance {
|
||||||
|
int64_t confirmed;
|
||||||
|
int64_t unconfirmed;
|
||||||
|
int64_t total;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Wallet client
|
||||||
|
class Client {
|
||||||
|
public:
|
||||||
|
explicit Client(const Config& config);
|
||||||
|
~Client();
|
||||||
|
|
||||||
|
// Disable copy
|
||||||
|
Client(const Client&) = delete;
|
||||||
|
Client& operator=(const Client&) = delete;
|
||||||
|
|
||||||
|
// Enable move
|
||||||
|
Client(Client&&) noexcept;
|
||||||
|
Client& operator=(Client&&) noexcept;
|
||||||
|
|
||||||
|
/// Create a new wallet
|
||||||
|
CreateWalletResult create_wallet(WalletType type = WalletType::Standard);
|
||||||
|
|
||||||
|
/// Create a new wallet asynchronously
|
||||||
|
std::future<CreateWalletResult> create_wallet_async(WalletType type = WalletType::Standard);
|
||||||
|
|
||||||
|
/// Import a wallet from mnemonic
|
||||||
|
Wallet import_wallet(const std::string& mnemonic,
|
||||||
|
const std::optional<std::string>& passphrase = std::nullopt);
|
||||||
|
|
||||||
|
/// Get a wallet by ID
|
||||||
|
Wallet get_wallet(const std::string& wallet_id);
|
||||||
|
|
||||||
|
/// Generate a new address
|
||||||
|
Address generate_address(const std::string& wallet_id, bool is_change = false);
|
||||||
|
|
||||||
|
/// Get stealth address
|
||||||
|
StealthAddress get_stealth_address(const std::string& wallet_id);
|
||||||
|
|
||||||
|
/// Sign a transaction
|
||||||
|
SignedTransaction sign_transaction(const std::string& wallet_id,
|
||||||
|
const Transaction& transaction);
|
||||||
|
|
||||||
|
/// Sign a message
|
||||||
|
Signature sign_message(const std::string& wallet_id,
|
||||||
|
const std::string& message,
|
||||||
|
int32_t address_index = 0);
|
||||||
|
|
||||||
|
/// Verify a message signature
|
||||||
|
bool verify_message(const std::string& message,
|
||||||
|
const std::string& signature,
|
||||||
|
const std::string& address);
|
||||||
|
|
||||||
|
/// Get balance for an address
|
||||||
|
Balance get_balance(const std::string& address);
|
||||||
|
|
||||||
|
/// Get balance asynchronously
|
||||||
|
std::future<Balance> get_balance_async(const std::string& address);
|
||||||
|
|
||||||
|
/// Get UTXOs for an address
|
||||||
|
std::vector<UTXO> get_utxos(const std::string& address, int32_t min_confirmations = 1);
|
||||||
|
|
||||||
|
/// Estimate transaction fee
|
||||||
|
int64_t estimate_fee(Priority priority = Priority::Medium);
|
||||||
|
|
||||||
|
private:
|
||||||
|
class Impl;
|
||||||
|
std::unique_ptr<Impl> impl_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace wallet
|
||||||
|
} // namespace synor
|
||||||
145
sdk/cpp/src/rpc/rpc.cpp
Normal file
145
sdk/cpp/src/rpc/rpc.cpp
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
/**
|
||||||
|
* @file rpc.cpp
|
||||||
|
* @brief Synor RPC SDK implementation for C++
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "synor/rpc.hpp"
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
namespace synor {
|
||||||
|
namespace rpc {
|
||||||
|
|
||||||
|
// Subscription implementation
|
||||||
|
Subscription::Subscription(const std::string& id, const std::string& channel,
|
||||||
|
std::function<void()> on_cancel)
|
||||||
|
: id_(id), channel_(channel), on_cancel_(std::move(on_cancel)) {}
|
||||||
|
|
||||||
|
Subscription::~Subscription() {
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
Subscription::Subscription(Subscription&&) noexcept = default;
|
||||||
|
Subscription& Subscription::operator=(Subscription&&) noexcept = default;
|
||||||
|
|
||||||
|
void Subscription::cancel() {
|
||||||
|
if (on_cancel_) {
|
||||||
|
on_cancel_();
|
||||||
|
on_cancel_ = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client implementation
|
||||||
|
class Client::Impl {
|
||||||
|
public:
|
||||||
|
explicit Impl(const Config& config) : config_(config) {}
|
||||||
|
|
||||||
|
Config config_;
|
||||||
|
// HTTP client and WebSocket would go here
|
||||||
|
};
|
||||||
|
|
||||||
|
Client::Client(const Config& config)
|
||||||
|
: impl_(std::make_unique<Impl>(config)) {}
|
||||||
|
|
||||||
|
Client::~Client() = default;
|
||||||
|
|
||||||
|
Client::Client(Client&&) noexcept = default;
|
||||||
|
Client& Client::operator=(Client&&) noexcept = default;
|
||||||
|
|
||||||
|
Block Client::get_latest_block() {
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::future<Block> Client::get_latest_block_async() {
|
||||||
|
return std::async(std::launch::async, [this]() {
|
||||||
|
return this->get_latest_block();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Block Client::get_block(const std::string& hash_or_height) {
|
||||||
|
(void)hash_or_height;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
BlockHeader Client::get_block_header(const std::string& hash_or_height) {
|
||||||
|
(void)hash_or_height;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<Block> Client::get_blocks(int64_t start_height, int64_t end_height) {
|
||||||
|
(void)start_height;
|
||||||
|
(void)end_height;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
Transaction Client::get_transaction(const std::string& txid) {
|
||||||
|
(void)txid;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Client::get_raw_transaction(const std::string& txid) {
|
||||||
|
(void)txid;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
SubmitResult Client::send_raw_transaction(const std::string& hex) {
|
||||||
|
(void)hex;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
Transaction Client::decode_raw_transaction(const std::string& hex) {
|
||||||
|
(void)hex;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<Transaction> Client::get_address_transactions(
|
||||||
|
const std::string& address, int32_t limit, int32_t offset) {
|
||||||
|
(void)address;
|
||||||
|
(void)limit;
|
||||||
|
(void)offset;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
FeeEstimate Client::estimate_fee(Priority priority) {
|
||||||
|
(void)priority;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::map<Priority, FeeEstimate> Client::get_all_fee_estimates() {
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
ChainInfo Client::get_chain_info() {
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
MempoolInfo Client::get_mempool_info() {
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> Client::get_mempool_transactions(int32_t limit) {
|
||||||
|
(void)limit;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<Subscription> Client::subscribe_blocks(
|
||||||
|
std::function<void(const Block&)> callback) {
|
||||||
|
(void)callback;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<Subscription> Client::subscribe_address(
|
||||||
|
const std::string& address,
|
||||||
|
std::function<void(const Transaction&)> callback) {
|
||||||
|
(void)address;
|
||||||
|
(void)callback;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<Subscription> Client::subscribe_mempool(
|
||||||
|
std::function<void(const Transaction&)> callback) {
|
||||||
|
(void)callback;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace rpc
|
||||||
|
} // namespace synor
|
||||||
126
sdk/cpp/src/storage/storage.cpp
Normal file
126
sdk/cpp/src/storage/storage.cpp
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
/**
|
||||||
|
* @file storage.cpp
|
||||||
|
* @brief Synor Storage SDK implementation for C++
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "synor/storage.hpp"
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
namespace synor {
|
||||||
|
namespace storage {
|
||||||
|
|
||||||
|
// Client implementation
|
||||||
|
class Client::Impl {
|
||||||
|
public:
|
||||||
|
explicit Impl(const Config& config) : config_(config) {}
|
||||||
|
|
||||||
|
Config config_;
|
||||||
|
// HTTP client would go here
|
||||||
|
};
|
||||||
|
|
||||||
|
Client::Client(const Config& config)
|
||||||
|
: impl_(std::make_unique<Impl>(config)) {}
|
||||||
|
|
||||||
|
Client::~Client() = default;
|
||||||
|
|
||||||
|
Client::Client(Client&&) noexcept = default;
|
||||||
|
Client& Client::operator=(Client&&) noexcept = default;
|
||||||
|
|
||||||
|
UploadResponse Client::upload(std::span<const uint8_t> data,
|
||||||
|
const UploadOptions& options) {
|
||||||
|
(void)data;
|
||||||
|
(void)options;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::future<UploadResponse> Client::upload_async(std::vector<uint8_t> data,
|
||||||
|
UploadOptions options) {
|
||||||
|
return std::async(std::launch::async, [this, data = std::move(data), options]() {
|
||||||
|
return this->upload(std::span{data}, options);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
UploadResponse Client::upload_directory(const std::vector<FileEntry>& files,
|
||||||
|
const std::optional<std::string>& dir_name) {
|
||||||
|
(void)files;
|
||||||
|
(void)dir_name;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<uint8_t> Client::download(const std::string& cid) {
|
||||||
|
(void)cid;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::future<std::vector<uint8_t>> Client::download_async(const std::string& cid) {
|
||||||
|
return std::async(std::launch::async, [this, cid]() {
|
||||||
|
return this->download(cid);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void Client::download_stream(const std::string& cid,
|
||||||
|
std::function<void(std::span<const uint8_t>)> callback) {
|
||||||
|
(void)cid;
|
||||||
|
(void)callback;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Client::get_gateway_url(const std::string& cid,
|
||||||
|
const std::optional<std::string>& path) {
|
||||||
|
std::ostringstream oss;
|
||||||
|
oss << impl_->config_.gateway << "/ipfs/" << cid;
|
||||||
|
if (path) {
|
||||||
|
oss << "/" << *path;
|
||||||
|
}
|
||||||
|
return oss.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
Pin Client::pin(const PinRequest& request) {
|
||||||
|
(void)request;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
void Client::unpin(const std::string& cid) {
|
||||||
|
(void)cid;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
Pin Client::get_pin_status(const std::string& cid) {
|
||||||
|
(void)cid;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<Pin> Client::list_pins(std::optional<PinStatus> status,
|
||||||
|
int32_t limit, int32_t offset) {
|
||||||
|
(void)status;
|
||||||
|
(void)limit;
|
||||||
|
(void)offset;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
CarFile Client::create_car(const std::vector<FileEntry>& entries) {
|
||||||
|
(void)entries;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> Client::import_car(std::span<const uint8_t> car_data) {
|
||||||
|
(void)car_data;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<uint8_t> Client::export_car(const std::string& cid) {
|
||||||
|
(void)cid;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<DirectoryEntry> Client::list_directory(const std::string& cid) {
|
||||||
|
(void)cid;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
StorageStats Client::get_stats() {
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace storage
|
||||||
|
} // namespace synor
|
||||||
113
sdk/cpp/src/wallet/wallet.cpp
Normal file
113
sdk/cpp/src/wallet/wallet.cpp
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
/**
|
||||||
|
* @file wallet.cpp
|
||||||
|
* @brief Synor Wallet SDK implementation for C++
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "synor/wallet.hpp"
|
||||||
|
#include <stdexcept>
|
||||||
|
|
||||||
|
namespace synor {
|
||||||
|
namespace wallet {
|
||||||
|
|
||||||
|
// Implementation class (pimpl pattern)
|
||||||
|
class Client::Impl {
|
||||||
|
public:
|
||||||
|
explicit Impl(const Config& config) : config_(config) {}
|
||||||
|
|
||||||
|
Config config_;
|
||||||
|
// HTTP client would go here
|
||||||
|
};
|
||||||
|
|
||||||
|
Client::Client(const Config& config)
|
||||||
|
: impl_(std::make_unique<Impl>(config)) {}
|
||||||
|
|
||||||
|
Client::~Client() = default;
|
||||||
|
|
||||||
|
Client::Client(Client&&) noexcept = default;
|
||||||
|
Client& Client::operator=(Client&&) noexcept = default;
|
||||||
|
|
||||||
|
CreateWalletResult Client::create_wallet(WalletType type) {
|
||||||
|
// TODO: Implement HTTP request
|
||||||
|
(void)type;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::future<CreateWalletResult> Client::create_wallet_async(WalletType type) {
|
||||||
|
return std::async(std::launch::async, [this, type]() {
|
||||||
|
return this->create_wallet(type);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Wallet Client::import_wallet(const std::string& mnemonic,
|
||||||
|
const std::optional<std::string>& passphrase) {
|
||||||
|
(void)mnemonic;
|
||||||
|
(void)passphrase;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
Wallet Client::get_wallet(const std::string& wallet_id) {
|
||||||
|
(void)wallet_id;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
Address Client::generate_address(const std::string& wallet_id, bool is_change) {
|
||||||
|
(void)wallet_id;
|
||||||
|
(void)is_change;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
StealthAddress Client::get_stealth_address(const std::string& wallet_id) {
|
||||||
|
(void)wallet_id;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
SignedTransaction Client::sign_transaction(const std::string& wallet_id,
|
||||||
|
const Transaction& transaction) {
|
||||||
|
(void)wallet_id;
|
||||||
|
(void)transaction;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
Signature Client::sign_message(const std::string& wallet_id,
|
||||||
|
const std::string& message,
|
||||||
|
int32_t address_index) {
|
||||||
|
(void)wallet_id;
|
||||||
|
(void)message;
|
||||||
|
(void)address_index;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Client::verify_message(const std::string& message,
|
||||||
|
const std::string& signature,
|
||||||
|
const std::string& address) {
|
||||||
|
(void)message;
|
||||||
|
(void)signature;
|
||||||
|
(void)address;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
Balance Client::get_balance(const std::string& address) {
|
||||||
|
(void)address;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::future<Balance> Client::get_balance_async(const std::string& address) {
|
||||||
|
return std::async(std::launch::async, [this, address]() {
|
||||||
|
return this->get_balance(address);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<UTXO> Client::get_utxos(const std::string& address,
|
||||||
|
int32_t min_confirmations) {
|
||||||
|
(void)address;
|
||||||
|
(void)min_confirmations;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
int64_t Client::estimate_fee(Priority priority) {
|
||||||
|
(void)priority;
|
||||||
|
throw SynorException("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace wallet
|
||||||
|
} // namespace synor
|
||||||
477
sdk/csharp/Synor.Sdk/Rpc/SynorRpc.cs
Normal file
477
sdk/csharp/Synor.Sdk/Rpc/SynorRpc.cs
Normal file
|
|
@ -0,0 +1,477 @@
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Synor.Sdk.Rpc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Synor RPC SDK client for C#.
|
||||||
|
///
|
||||||
|
/// Provides blockchain data queries, transaction submission,
|
||||||
|
/// and real-time subscriptions via WebSocket.
|
||||||
|
/// </summary>
|
||||||
|
/// <example>
|
||||||
|
/// <code>
|
||||||
|
/// var rpc = new SynorRpc(new RpcConfig { ApiKey = "your-api-key" });
|
||||||
|
///
|
||||||
|
/// // Get latest block
|
||||||
|
/// var block = await rpc.GetLatestBlockAsync();
|
||||||
|
/// Console.WriteLine($"Block height: {block.Height}");
|
||||||
|
///
|
||||||
|
/// // Subscribe to new blocks
|
||||||
|
/// var subscription = await rpc.SubscribeBlocksAsync(block => {
|
||||||
|
/// Console.WriteLine($"New block: {block.Height}");
|
||||||
|
/// });
|
||||||
|
///
|
||||||
|
/// // Later: cancel subscription
|
||||||
|
/// subscription.Cancel();
|
||||||
|
/// rpc.Dispose();
|
||||||
|
/// </code>
|
||||||
|
/// </example>
|
||||||
|
public class SynorRpc : IDisposable
|
||||||
|
{
|
||||||
|
private readonly RpcConfig _config;
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly JsonSerializerOptions _jsonOptions;
|
||||||
|
private ClientWebSocket? _webSocket;
|
||||||
|
private CancellationTokenSource? _wsCancellation;
|
||||||
|
private readonly Dictionary<string, Action<JsonElement>> _subscriptionCallbacks = new();
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public SynorRpc(RpcConfig config)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
_httpClient = new HttpClient
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(config.Endpoint),
|
||||||
|
Timeout = config.Timeout
|
||||||
|
};
|
||||||
|
_httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {config.ApiKey}");
|
||||||
|
|
||||||
|
_jsonOptions = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the latest block.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Block> GetLatestBlockAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetAsync<Block>("/blocks/latest", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get a block by hash or height.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Block> GetBlockAsync(
|
||||||
|
string hashOrHeight,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetAsync<Block>($"/blocks/{hashOrHeight}", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get a block header by hash or height.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<BlockHeader> GetBlockHeaderAsync(
|
||||||
|
string hashOrHeight,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetAsync<BlockHeader>($"/blocks/{hashOrHeight}/header", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get a range of blocks.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<Block>> GetBlocksAsync(
|
||||||
|
long startHeight,
|
||||||
|
long endHeight,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetAsync<List<Block>>(
|
||||||
|
$"/blocks?start={startHeight}&end={endHeight}",
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get a transaction by ID.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<RpcTransaction> GetTransactionAsync(
|
||||||
|
string txid,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetAsync<RpcTransaction>($"/transactions/{txid}", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get raw transaction hex.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string> GetRawTransactionAsync(
|
||||||
|
string txid,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var response = await GetAsync<JsonElement>($"/transactions/{txid}/raw", cancellationToken);
|
||||||
|
return response.GetProperty("hex").GetString() ?? throw new RpcException("Invalid response");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Send a raw transaction.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<SubmitResult> SendRawTransactionAsync(
|
||||||
|
string hex,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var request = new { hex };
|
||||||
|
return await PostAsync<SubmitResult>("/transactions/send", request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decode a raw transaction.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<RpcTransaction> DecodeRawTransactionAsync(
|
||||||
|
string hex,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var request = new { hex };
|
||||||
|
return await PostAsync<RpcTransaction>("/transactions/decode", request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get transactions for an address.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<RpcTransaction>> GetAddressTransactionsAsync(
|
||||||
|
string address,
|
||||||
|
int limit = 20,
|
||||||
|
int offset = 0,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetAsync<List<RpcTransaction>>(
|
||||||
|
$"/addresses/{address}/transactions?limit={limit}&offset={offset}",
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Estimate transaction fee.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<FeeEstimate> EstimateFeeAsync(
|
||||||
|
RpcPriority priority = RpcPriority.Medium,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetAsync<FeeEstimate>(
|
||||||
|
$"/fees/estimate?priority={priority.ToString().ToLower()}",
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get all fee estimates.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Dictionary<RpcPriority, FeeEstimate>> GetAllFeeEstimatesAsync(
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var estimates = await GetAsync<List<FeeEstimate>>("/fees/estimates", cancellationToken);
|
||||||
|
return estimates.ToDictionary(e => e.Priority, e => e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get chain information.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ChainInfo> GetChainInfoAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetAsync<ChainInfo>("/chain/info", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get mempool information.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<MempoolInfo> GetMempoolInfoAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetAsync<MempoolInfo>("/mempool/info", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get mempool transaction IDs.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<string>> GetMempoolTransactionsAsync(
|
||||||
|
int limit = 100,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetAsync<List<string>>($"/mempool/transactions?limit={limit}", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subscribe to new blocks.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Subscription> SubscribeBlocksAsync(
|
||||||
|
Action<Block> callback,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await EnsureWebSocketConnectedAsync(cancellationToken);
|
||||||
|
|
||||||
|
var subscriptionId = Guid.NewGuid().ToString();
|
||||||
|
_subscriptionCallbacks[subscriptionId] = element =>
|
||||||
|
{
|
||||||
|
var block = element.Deserialize<Block>(_jsonOptions);
|
||||||
|
if (block != null) callback(block);
|
||||||
|
};
|
||||||
|
|
||||||
|
var subscribeMessage = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
type = "subscribe",
|
||||||
|
channel = "blocks",
|
||||||
|
subscription_id = subscriptionId
|
||||||
|
});
|
||||||
|
|
||||||
|
await SendWebSocketMessageAsync(subscribeMessage, cancellationToken);
|
||||||
|
|
||||||
|
return new Subscription(subscriptionId, "blocks", () =>
|
||||||
|
{
|
||||||
|
_subscriptionCallbacks.Remove(subscriptionId);
|
||||||
|
var unsubscribeMessage = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
type = "unsubscribe",
|
||||||
|
subscription_id = subscriptionId
|
||||||
|
});
|
||||||
|
_ = SendWebSocketMessageAsync(unsubscribeMessage, CancellationToken.None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subscribe to address transactions.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Subscription> SubscribeAddressAsync(
|
||||||
|
string address,
|
||||||
|
Action<RpcTransaction> callback,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await EnsureWebSocketConnectedAsync(cancellationToken);
|
||||||
|
|
||||||
|
var subscriptionId = Guid.NewGuid().ToString();
|
||||||
|
_subscriptionCallbacks[subscriptionId] = element =>
|
||||||
|
{
|
||||||
|
var tx = element.Deserialize<RpcTransaction>(_jsonOptions);
|
||||||
|
if (tx != null) callback(tx);
|
||||||
|
};
|
||||||
|
|
||||||
|
var subscribeMessage = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
type = "subscribe",
|
||||||
|
channel = "address",
|
||||||
|
address,
|
||||||
|
subscription_id = subscriptionId
|
||||||
|
});
|
||||||
|
|
||||||
|
await SendWebSocketMessageAsync(subscribeMessage, cancellationToken);
|
||||||
|
|
||||||
|
return new Subscription(subscriptionId, $"address:{address}", () =>
|
||||||
|
{
|
||||||
|
_subscriptionCallbacks.Remove(subscriptionId);
|
||||||
|
var unsubscribeMessage = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
type = "unsubscribe",
|
||||||
|
subscription_id = subscriptionId
|
||||||
|
});
|
||||||
|
_ = SendWebSocketMessageAsync(unsubscribeMessage, CancellationToken.None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subscribe to mempool transactions.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Subscription> SubscribeMempoolAsync(
|
||||||
|
Action<RpcTransaction> callback,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await EnsureWebSocketConnectedAsync(cancellationToken);
|
||||||
|
|
||||||
|
var subscriptionId = Guid.NewGuid().ToString();
|
||||||
|
_subscriptionCallbacks[subscriptionId] = element =>
|
||||||
|
{
|
||||||
|
var tx = element.Deserialize<RpcTransaction>(_jsonOptions);
|
||||||
|
if (tx != null) callback(tx);
|
||||||
|
};
|
||||||
|
|
||||||
|
var subscribeMessage = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
type = "subscribe",
|
||||||
|
channel = "mempool",
|
||||||
|
subscription_id = subscriptionId
|
||||||
|
});
|
||||||
|
|
||||||
|
await SendWebSocketMessageAsync(subscribeMessage, cancellationToken);
|
||||||
|
|
||||||
|
return new Subscription(subscriptionId, "mempool", () =>
|
||||||
|
{
|
||||||
|
_subscriptionCallbacks.Remove(subscriptionId);
|
||||||
|
var unsubscribeMessage = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
type = "unsubscribe",
|
||||||
|
subscription_id = subscriptionId
|
||||||
|
});
|
||||||
|
_ = SendWebSocketMessageAsync(unsubscribeMessage, CancellationToken.None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureWebSocketConnectedAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_webSocket?.State == WebSocketState.Open)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_webSocket?.Dispose();
|
||||||
|
_wsCancellation?.Cancel();
|
||||||
|
_wsCancellation?.Dispose();
|
||||||
|
|
||||||
|
_webSocket = new ClientWebSocket();
|
||||||
|
_webSocket.Options.SetRequestHeader("Authorization", $"Bearer {_config.ApiKey}");
|
||||||
|
_wsCancellation = new CancellationTokenSource();
|
||||||
|
|
||||||
|
await _webSocket.ConnectAsync(new Uri(_config.WsEndpoint), cancellationToken);
|
||||||
|
_ = ReceiveWebSocketMessagesAsync(_wsCancellation.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReceiveWebSocketMessagesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var buffer = new byte[8192];
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (_webSocket?.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var result = await _webSocket.ReceiveAsync(buffer, cancellationToken);
|
||||||
|
|
||||||
|
if (result.MessageType == WebSocketMessageType.Close)
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (result.MessageType == WebSocketMessageType.Text)
|
||||||
|
{
|
||||||
|
var json = Encoding.UTF8.GetString(buffer, 0, result.Count);
|
||||||
|
ProcessWebSocketMessage(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Expected when cancelling
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (_config.Debug)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"WebSocket error: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ProcessWebSocketMessage(string json)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var doc = JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
if (root.TryGetProperty("subscription_id", out var subIdElement))
|
||||||
|
{
|
||||||
|
var subscriptionId = subIdElement.GetString();
|
||||||
|
if (subscriptionId != null && _subscriptionCallbacks.TryGetValue(subscriptionId, out var callback))
|
||||||
|
{
|
||||||
|
if (root.TryGetProperty("data", out var data))
|
||||||
|
{
|
||||||
|
callback(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (_config.Debug)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Error processing WebSocket message: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendWebSocketMessageAsync(string message, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_webSocket?.State == WebSocketState.Open)
|
||||||
|
{
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(message);
|
||||||
|
await _webSocket.SendAsync(bytes, WebSocketMessageType.Text, true, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T> GetAsync<T>(string path, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await ExecuteWithRetryAsync(async () =>
|
||||||
|
{
|
||||||
|
var response = await _httpClient.GetAsync(path, cancellationToken);
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
return await response.Content.ReadFromJsonAsync<T>(_jsonOptions, cancellationToken)
|
||||||
|
?? throw new RpcException("Invalid response");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T> PostAsync<T>(string path, object body, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await ExecuteWithRetryAsync(async () =>
|
||||||
|
{
|
||||||
|
var response = await _httpClient.PostAsJsonAsync(path, body, _jsonOptions, cancellationToken);
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
return await response.Content.ReadFromJsonAsync<T>(_jsonOptions, cancellationToken)
|
||||||
|
?? throw new RpcException("Invalid response");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureSuccessAsync(HttpResponseMessage response)
|
||||||
|
{
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
|
throw new RpcException(
|
||||||
|
$"HTTP error: {content}",
|
||||||
|
statusCode: (int)response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T> ExecuteWithRetryAsync<T>(Func<Task<T>> operation)
|
||||||
|
{
|
||||||
|
Exception? lastException = null;
|
||||||
|
|
||||||
|
for (int attempt = 0; attempt < _config.Retries; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await operation();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
lastException = ex;
|
||||||
|
if (_config.Debug)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Attempt {attempt + 1} failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
if (attempt < _config.Retries - 1)
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(attempt + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastException ?? new RpcException("Unknown error after retries");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (!_disposed)
|
||||||
|
{
|
||||||
|
_wsCancellation?.Cancel();
|
||||||
|
_wsCancellation?.Dispose();
|
||||||
|
_webSocket?.Dispose();
|
||||||
|
_httpClient.Dispose();
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
222
sdk/csharp/Synor.Sdk/Rpc/Types.cs
Normal file
222
sdk/csharp/Synor.Sdk/Rpc/Types.cs
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Synor.Sdk.Rpc;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Network environment.
|
||||||
|
/// </summary>
|
||||||
|
public enum RpcNetwork
|
||||||
|
{
|
||||||
|
[JsonPropertyName("mainnet")] Mainnet,
|
||||||
|
[JsonPropertyName("testnet")] Testnet,
|
||||||
|
[JsonPropertyName("devnet")] Devnet
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Transaction priority.
|
||||||
|
/// </summary>
|
||||||
|
public enum RpcPriority
|
||||||
|
{
|
||||||
|
[JsonPropertyName("low")] Low,
|
||||||
|
[JsonPropertyName("medium")] Medium,
|
||||||
|
[JsonPropertyName("high")] High
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Transaction status.
|
||||||
|
/// </summary>
|
||||||
|
public enum TransactionStatus
|
||||||
|
{
|
||||||
|
[JsonPropertyName("pending")] Pending,
|
||||||
|
[JsonPropertyName("confirmed")] Confirmed,
|
||||||
|
[JsonPropertyName("failed")] Failed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RPC configuration.
|
||||||
|
/// </summary>
|
||||||
|
public class RpcConfig
|
||||||
|
{
|
||||||
|
public required string ApiKey { get; init; }
|
||||||
|
public string Endpoint { get; init; } = "https://rpc.synor.io/v1";
|
||||||
|
public string WsEndpoint { get; init; } = "wss://rpc.synor.io/v1/ws";
|
||||||
|
public RpcNetwork Network { get; init; } = RpcNetwork.Mainnet;
|
||||||
|
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||||
|
public int Retries { get; init; } = 3;
|
||||||
|
public bool Debug { get; init; } = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Block header information.
|
||||||
|
/// </summary>
|
||||||
|
public record BlockHeader(
|
||||||
|
[property: JsonPropertyName("hash")] string Hash,
|
||||||
|
[property: JsonPropertyName("height")] long Height,
|
||||||
|
[property: JsonPropertyName("previous_hash")] string PreviousHash,
|
||||||
|
[property: JsonPropertyName("timestamp")] long Timestamp,
|
||||||
|
[property: JsonPropertyName("version")] int Version,
|
||||||
|
[property: JsonPropertyName("merkle_root")] string MerkleRoot,
|
||||||
|
[property: JsonPropertyName("nonce")] long Nonce,
|
||||||
|
[property: JsonPropertyName("difficulty")] double Difficulty
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Script public key.
|
||||||
|
/// </summary>
|
||||||
|
public record ScriptPubKey(
|
||||||
|
[property: JsonPropertyName("asm")] string Asm,
|
||||||
|
[property: JsonPropertyName("hex")] string Hex,
|
||||||
|
[property: JsonPropertyName("type")] string Type,
|
||||||
|
[property: JsonPropertyName("address")] string? Address = null
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Transaction input.
|
||||||
|
/// </summary>
|
||||||
|
public record RpcTransactionInput(
|
||||||
|
[property: JsonPropertyName("txid")] string Txid,
|
||||||
|
[property: JsonPropertyName("vout")] int Vout,
|
||||||
|
[property: JsonPropertyName("script_sig")] string? ScriptSig,
|
||||||
|
[property: JsonPropertyName("sequence")] long Sequence,
|
||||||
|
[property: JsonPropertyName("witness")] List<string>? Witness = null
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Transaction output.
|
||||||
|
/// </summary>
|
||||||
|
public record RpcTransactionOutput(
|
||||||
|
[property: JsonPropertyName("value")] long Value,
|
||||||
|
[property: JsonPropertyName("n")] int N,
|
||||||
|
[property: JsonPropertyName("script_pubkey")] ScriptPubKey ScriptPubKey
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A blockchain transaction.
|
||||||
|
/// </summary>
|
||||||
|
public record RpcTransaction(
|
||||||
|
[property: JsonPropertyName("txid")] string Txid,
|
||||||
|
[property: JsonPropertyName("hash")] string Hash,
|
||||||
|
[property: JsonPropertyName("version")] int Version,
|
||||||
|
[property: JsonPropertyName("size")] int Size,
|
||||||
|
[property: JsonPropertyName("weight")] int Weight,
|
||||||
|
[property: JsonPropertyName("lock_time")] long LockTime,
|
||||||
|
[property: JsonPropertyName("inputs")] List<RpcTransactionInput> Inputs,
|
||||||
|
[property: JsonPropertyName("outputs")] List<RpcTransactionOutput> Outputs,
|
||||||
|
[property: JsonPropertyName("fee")] long Fee,
|
||||||
|
[property: JsonPropertyName("confirmations")] int Confirmations,
|
||||||
|
[property: JsonPropertyName("block_hash")] string? BlockHash = null,
|
||||||
|
[property: JsonPropertyName("block_height")] long? BlockHeight = null,
|
||||||
|
[property: JsonPropertyName("timestamp")] long? Timestamp = null,
|
||||||
|
[property: JsonPropertyName("status")] TransactionStatus Status = TransactionStatus.Pending
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A blockchain block.
|
||||||
|
/// </summary>
|
||||||
|
public record Block(
|
||||||
|
[property: JsonPropertyName("hash")] string Hash,
|
||||||
|
[property: JsonPropertyName("height")] long Height,
|
||||||
|
[property: JsonPropertyName("previous_hash")] string PreviousHash,
|
||||||
|
[property: JsonPropertyName("timestamp")] long Timestamp,
|
||||||
|
[property: JsonPropertyName("version")] int Version,
|
||||||
|
[property: JsonPropertyName("merkle_root")] string MerkleRoot,
|
||||||
|
[property: JsonPropertyName("nonce")] long Nonce,
|
||||||
|
[property: JsonPropertyName("difficulty")] double Difficulty,
|
||||||
|
[property: JsonPropertyName("transactions")] List<RpcTransaction> Transactions,
|
||||||
|
[property: JsonPropertyName("size")] int Size,
|
||||||
|
[property: JsonPropertyName("weight")] int Weight
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Chain information.
|
||||||
|
/// </summary>
|
||||||
|
public record ChainInfo(
|
||||||
|
[property: JsonPropertyName("chain")] string Chain,
|
||||||
|
[property: JsonPropertyName("blocks")] long Blocks,
|
||||||
|
[property: JsonPropertyName("headers")] long Headers,
|
||||||
|
[property: JsonPropertyName("best_block_hash")] string BestBlockHash,
|
||||||
|
[property: JsonPropertyName("difficulty")] double Difficulty,
|
||||||
|
[property: JsonPropertyName("median_time")] long MedianTime,
|
||||||
|
[property: JsonPropertyName("verification_progress")] double VerificationProgress,
|
||||||
|
[property: JsonPropertyName("chain_work")] string ChainWork,
|
||||||
|
[property: JsonPropertyName("pruned")] bool Pruned
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mempool information.
|
||||||
|
/// </summary>
|
||||||
|
public record MempoolInfo(
|
||||||
|
[property: JsonPropertyName("size")] int Size,
|
||||||
|
[property: JsonPropertyName("bytes")] long Bytes,
|
||||||
|
[property: JsonPropertyName("usage")] long Usage,
|
||||||
|
[property: JsonPropertyName("max_mempool")] long MaxMempool,
|
||||||
|
[property: JsonPropertyName("mempool_min_fee")] double MempoolMinFee,
|
||||||
|
[property: JsonPropertyName("min_relay_tx_fee")] double MinRelayTxFee
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fee estimate.
|
||||||
|
/// </summary>
|
||||||
|
public record FeeEstimate(
|
||||||
|
[property: JsonPropertyName("priority")] RpcPriority Priority,
|
||||||
|
[property: JsonPropertyName("fee_rate")] long FeeRate,
|
||||||
|
[property: JsonPropertyName("estimated_blocks")] int EstimatedBlocks
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Submit result.
|
||||||
|
/// </summary>
|
||||||
|
public record SubmitResult(
|
||||||
|
[property: JsonPropertyName("txid")] string Txid,
|
||||||
|
[property: JsonPropertyName("accepted")] bool Accepted,
|
||||||
|
[property: JsonPropertyName("reason")] string? Reason = null
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subscription handle.
|
||||||
|
/// </summary>
|
||||||
|
public class Subscription : IDisposable
|
||||||
|
{
|
||||||
|
public string Id { get; }
|
||||||
|
public string Channel { get; }
|
||||||
|
private readonly Action _onCancel;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public Subscription(string id, string channel, Action onCancel)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Channel = channel;
|
||||||
|
_onCancel = onCancel;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Cancel()
|
||||||
|
{
|
||||||
|
if (!_disposed)
|
||||||
|
{
|
||||||
|
_onCancel();
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Cancel();
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RPC exception.
|
||||||
|
/// </summary>
|
||||||
|
public class RpcException : Exception
|
||||||
|
{
|
||||||
|
public string? Code { get; }
|
||||||
|
public int? StatusCode { get; }
|
||||||
|
|
||||||
|
public RpcException(string message, string? code = null, int? statusCode = null)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
Code = code;
|
||||||
|
StatusCode = statusCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
392
sdk/csharp/Synor.Sdk/Storage/SynorStorage.cs
Normal file
392
sdk/csharp/Synor.Sdk/Storage/SynorStorage.cs
Normal file
|
|
@ -0,0 +1,392 @@
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Synor.Sdk.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Synor Storage SDK client for C#.
|
||||||
|
///
|
||||||
|
/// Provides decentralized storage operations including upload, download,
|
||||||
|
/// pinning, and directory management.
|
||||||
|
/// </summary>
|
||||||
|
/// <example>
|
||||||
|
/// <code>
|
||||||
|
/// var storage = new SynorStorage(new StorageConfig { ApiKey = "your-api-key" });
|
||||||
|
///
|
||||||
|
/// // Upload a file
|
||||||
|
/// var data = File.ReadAllBytes("document.pdf");
|
||||||
|
/// var result = await storage.UploadAsync(data, new UploadOptions { Name = "document.pdf" });
|
||||||
|
/// Console.WriteLine($"CID: {result.Cid}");
|
||||||
|
///
|
||||||
|
/// // Download a file
|
||||||
|
/// var downloaded = await storage.DownloadAsync(result.Cid);
|
||||||
|
/// File.WriteAllBytes("downloaded.pdf", downloaded);
|
||||||
|
///
|
||||||
|
/// storage.Dispose();
|
||||||
|
/// </code>
|
||||||
|
/// </example>
|
||||||
|
public class SynorStorage : IDisposable
|
||||||
|
{
|
||||||
|
private readonly StorageConfig _config;
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly JsonSerializerOptions _jsonOptions;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public SynorStorage(StorageConfig config)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
_httpClient = new HttpClient
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(config.Endpoint),
|
||||||
|
Timeout = config.Timeout
|
||||||
|
};
|
||||||
|
_httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {config.ApiKey}");
|
||||||
|
|
||||||
|
_jsonOptions = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Upload data to storage.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<UploadResponse> UploadAsync(
|
||||||
|
byte[] data,
|
||||||
|
UploadOptions? options = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
options ??= new UploadOptions();
|
||||||
|
|
||||||
|
using var content = new MultipartFormDataContent();
|
||||||
|
var fileContent = new ByteArrayContent(data);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(options.ContentType))
|
||||||
|
{
|
||||||
|
fileContent.Headers.ContentType = MediaTypeHeaderValue.Parse(options.ContentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
content.Add(fileContent, "file", options.Name ?? "file");
|
||||||
|
|
||||||
|
if (options.Metadata != null)
|
||||||
|
{
|
||||||
|
foreach (var (key, value) in options.Metadata)
|
||||||
|
{
|
||||||
|
content.Add(new StringContent(value), $"metadata[{key}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content.Add(new StringContent(options.Pin.ToString().ToLower()), "pin");
|
||||||
|
content.Add(new StringContent(options.WrapWithDirectory.ToString().ToLower()), "wrap_with_directory");
|
||||||
|
|
||||||
|
return await ExecuteWithRetryAsync(async () =>
|
||||||
|
{
|
||||||
|
var response = await _httpClient.PostAsync("/upload", content, cancellationToken);
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
return await response.Content.ReadFromJsonAsync<UploadResponse>(_jsonOptions, cancellationToken)
|
||||||
|
?? throw new StorageException("Invalid response");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Upload a stream to storage.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<UploadResponse> UploadStreamAsync(
|
||||||
|
Stream stream,
|
||||||
|
UploadOptions? options = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
options ??= new UploadOptions();
|
||||||
|
|
||||||
|
using var content = new MultipartFormDataContent();
|
||||||
|
var streamContent = new StreamContent(stream);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(options.ContentType))
|
||||||
|
{
|
||||||
|
streamContent.Headers.ContentType = MediaTypeHeaderValue.Parse(options.ContentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
content.Add(streamContent, "file", options.Name ?? "file");
|
||||||
|
content.Add(new StringContent(options.Pin.ToString().ToLower()), "pin");
|
||||||
|
|
||||||
|
return await ExecuteWithRetryAsync(async () =>
|
||||||
|
{
|
||||||
|
var response = await _httpClient.PostAsync("/upload", content, cancellationToken);
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
return await response.Content.ReadFromJsonAsync<UploadResponse>(_jsonOptions, cancellationToken)
|
||||||
|
?? throw new StorageException("Invalid response");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Upload a directory of files.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<UploadResponse> UploadDirectoryAsync(
|
||||||
|
IEnumerable<FileEntry> files,
|
||||||
|
string? directoryName = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var content = new MultipartFormDataContent();
|
||||||
|
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
var fileContent = new ByteArrayContent(file.Content);
|
||||||
|
if (!string.IsNullOrEmpty(file.ContentType))
|
||||||
|
{
|
||||||
|
fileContent.Headers.ContentType = MediaTypeHeaderValue.Parse(file.ContentType);
|
||||||
|
}
|
||||||
|
content.Add(fileContent, "files", file.Path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(directoryName))
|
||||||
|
{
|
||||||
|
content.Add(new StringContent(directoryName), "directory_name");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ExecuteWithRetryAsync(async () =>
|
||||||
|
{
|
||||||
|
var response = await _httpClient.PostAsync("/upload/directory", content, cancellationToken);
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
return await response.Content.ReadFromJsonAsync<UploadResponse>(_jsonOptions, cancellationToken)
|
||||||
|
?? throw new StorageException("Invalid response");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Download content by CID.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<byte[]> DownloadAsync(
|
||||||
|
string cid,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await ExecuteWithRetryAsync(async () =>
|
||||||
|
{
|
||||||
|
var response = await _httpClient.GetAsync($"/download/{cid}", cancellationToken);
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Download content as a stream.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Stream> DownloadStreamAsync(
|
||||||
|
string cid,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var response = await _httpClient.GetAsync(
|
||||||
|
$"/download/{cid}",
|
||||||
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
|
cancellationToken);
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
return await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get gateway URL for a CID.
|
||||||
|
/// </summary>
|
||||||
|
public string GetGatewayUrl(string cid, string? path = null)
|
||||||
|
{
|
||||||
|
var url = $"{_config.Gateway}/ipfs/{cid}";
|
||||||
|
if (!string.IsNullOrEmpty(path))
|
||||||
|
{
|
||||||
|
url += $"/{path.TrimStart('/')}";
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pin content by CID.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Pin> PinAsync(
|
||||||
|
PinRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await PostAsync<Pin>("/pins", request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unpin content.
|
||||||
|
/// </summary>
|
||||||
|
public async Task UnpinAsync(
|
||||||
|
string cid,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await ExecuteWithRetryAsync(async () =>
|
||||||
|
{
|
||||||
|
var response = await _httpClient.DeleteAsync($"/pins/{cid}", cancellationToken);
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get pin status.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Pin> GetPinStatusAsync(
|
||||||
|
string cid,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetAsync<Pin>($"/pins/{cid}", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// List pins.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<Pin>> ListPinsAsync(
|
||||||
|
PinStatus? status = null,
|
||||||
|
int limit = 20,
|
||||||
|
int offset = 0,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var query = $"/pins?limit={limit}&offset={offset}";
|
||||||
|
if (status.HasValue)
|
||||||
|
{
|
||||||
|
query += $"&status={status.Value.ToString().ToLower()}";
|
||||||
|
}
|
||||||
|
return await GetAsync<List<Pin>>(query, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a CAR file from entries.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<CarFile> CreateCarAsync(
|
||||||
|
IEnumerable<FileEntry> entries,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var request = entries.Select(e => new
|
||||||
|
{
|
||||||
|
path = e.Path,
|
||||||
|
content = Convert.ToBase64String(e.Content),
|
||||||
|
content_type = e.ContentType
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return await PostAsync<CarFile>("/car/create", request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Import a CAR file.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<string>> ImportCarAsync(
|
||||||
|
byte[] carData,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var content = new ByteArrayContent(carData);
|
||||||
|
content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/vnd.ipld.car");
|
||||||
|
|
||||||
|
return await ExecuteWithRetryAsync(async () =>
|
||||||
|
{
|
||||||
|
var response = await _httpClient.PostAsync("/car/import", content, cancellationToken);
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<JsonElement>(_jsonOptions, cancellationToken);
|
||||||
|
return result.GetProperty("cids").EnumerateArray()
|
||||||
|
.Select(e => e.GetString()!)
|
||||||
|
.ToList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Export content as a CAR file.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<byte[]> ExportCarAsync(
|
||||||
|
string cid,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await ExecuteWithRetryAsync(async () =>
|
||||||
|
{
|
||||||
|
var response = await _httpClient.GetAsync($"/car/export/{cid}", cancellationToken);
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// List directory contents.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<DirectoryEntry>> ListDirectoryAsync(
|
||||||
|
string cid,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetAsync<List<DirectoryEntry>>($"/directory/{cid}", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get storage statistics.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<StorageStats> GetStatsAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetAsync<StorageStats>("/stats", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T> GetAsync<T>(string path, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await ExecuteWithRetryAsync(async () =>
|
||||||
|
{
|
||||||
|
var response = await _httpClient.GetAsync(path, cancellationToken);
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
return await response.Content.ReadFromJsonAsync<T>(_jsonOptions, cancellationToken)
|
||||||
|
?? throw new StorageException("Invalid response");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T> PostAsync<T>(string path, object body, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await ExecuteWithRetryAsync(async () =>
|
||||||
|
{
|
||||||
|
var response = await _httpClient.PostAsJsonAsync(path, body, _jsonOptions, cancellationToken);
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
return await response.Content.ReadFromJsonAsync<T>(_jsonOptions, cancellationToken)
|
||||||
|
?? throw new StorageException("Invalid response");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureSuccessAsync(HttpResponseMessage response)
|
||||||
|
{
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
|
throw new StorageException(
|
||||||
|
$"HTTP error: {content}",
|
||||||
|
statusCode: (int)response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T> ExecuteWithRetryAsync<T>(Func<Task<T>> operation)
|
||||||
|
{
|
||||||
|
Exception? lastException = null;
|
||||||
|
|
||||||
|
for (int attempt = 0; attempt < _config.Retries; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await operation();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
lastException = ex;
|
||||||
|
if (_config.Debug)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Attempt {attempt + 1} failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
if (attempt < _config.Retries - 1)
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(attempt + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastException ?? new StorageException("Unknown error after retries");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (!_disposed)
|
||||||
|
{
|
||||||
|
_httpClient.Dispose();
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
139
sdk/csharp/Synor.Sdk/Storage/Types.cs
Normal file
139
sdk/csharp/Synor.Sdk/Storage/Types.cs
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Synor.Sdk.Storage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Storage network.
|
||||||
|
/// </summary>
|
||||||
|
public enum StorageNetwork
|
||||||
|
{
|
||||||
|
[JsonPropertyName("mainnet")] Mainnet,
|
||||||
|
[JsonPropertyName("testnet")] Testnet,
|
||||||
|
[JsonPropertyName("devnet")] Devnet
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pin status.
|
||||||
|
/// </summary>
|
||||||
|
public enum PinStatus
|
||||||
|
{
|
||||||
|
[JsonPropertyName("queued")] Queued,
|
||||||
|
[JsonPropertyName("pinning")] Pinning,
|
||||||
|
[JsonPropertyName("pinned")] Pinned,
|
||||||
|
[JsonPropertyName("failed")] Failed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Storage configuration.
|
||||||
|
/// </summary>
|
||||||
|
public class StorageConfig
|
||||||
|
{
|
||||||
|
public required string ApiKey { get; init; }
|
||||||
|
public string Endpoint { get; init; } = "https://storage.synor.io/v1";
|
||||||
|
public string Gateway { get; init; } = "https://gateway.synor.io";
|
||||||
|
public StorageNetwork Network { get; init; } = StorageNetwork.Mainnet;
|
||||||
|
public TimeSpan Timeout { get; init; } = TimeSpan.FromMinutes(5);
|
||||||
|
public int Retries { get; init; } = 3;
|
||||||
|
public bool Debug { get; init; } = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Upload options.
|
||||||
|
/// </summary>
|
||||||
|
public class UploadOptions
|
||||||
|
{
|
||||||
|
public string? Name { get; init; }
|
||||||
|
public string? ContentType { get; init; }
|
||||||
|
public bool Pin { get; init; } = true;
|
||||||
|
public Dictionary<string, string>? Metadata { get; init; }
|
||||||
|
public bool WrapWithDirectory { get; init; } = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Upload response.
|
||||||
|
/// </summary>
|
||||||
|
public record UploadResponse(
|
||||||
|
[property: JsonPropertyName("cid")] string Cid,
|
||||||
|
[property: JsonPropertyName("size")] long Size,
|
||||||
|
[property: JsonPropertyName("name")] string? Name = null,
|
||||||
|
[property: JsonPropertyName("pinned")] bool Pinned = true
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pin request.
|
||||||
|
/// </summary>
|
||||||
|
public class PinRequest
|
||||||
|
{
|
||||||
|
public required string Cid { get; init; }
|
||||||
|
public string? Name { get; init; }
|
||||||
|
public Dictionary<string, string>? Metadata { get; init; }
|
||||||
|
public string[]? Origins { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pin information.
|
||||||
|
/// </summary>
|
||||||
|
public record Pin(
|
||||||
|
[property: JsonPropertyName("cid")] string Cid,
|
||||||
|
[property: JsonPropertyName("name")] string? Name,
|
||||||
|
[property: JsonPropertyName("status")] PinStatus Status,
|
||||||
|
[property: JsonPropertyName("size")] long Size,
|
||||||
|
[property: JsonPropertyName("created_at")] DateTime CreatedAt,
|
||||||
|
[property: JsonPropertyName("metadata")] Dictionary<string, string>? Metadata = null
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// File entry for directory operations.
|
||||||
|
/// </summary>
|
||||||
|
public class FileEntry
|
||||||
|
{
|
||||||
|
public required string Path { get; init; }
|
||||||
|
public required byte[] Content { get; init; }
|
||||||
|
public string? ContentType { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Directory entry.
|
||||||
|
/// </summary>
|
||||||
|
public record DirectoryEntry(
|
||||||
|
[property: JsonPropertyName("name")] string Name,
|
||||||
|
[property: JsonPropertyName("cid")] string Cid,
|
||||||
|
[property: JsonPropertyName("size")] long Size,
|
||||||
|
[property: JsonPropertyName("type")] string Type
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CAR file.
|
||||||
|
/// </summary>
|
||||||
|
public record CarFile(
|
||||||
|
[property: JsonPropertyName("root_cid")] string RootCid,
|
||||||
|
[property: JsonPropertyName("data")] byte[] Data,
|
||||||
|
[property: JsonPropertyName("size")] long Size
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Storage statistics.
|
||||||
|
/// </summary>
|
||||||
|
public record StorageStats(
|
||||||
|
[property: JsonPropertyName("total_size")] long TotalSize,
|
||||||
|
[property: JsonPropertyName("pin_count")] int PinCount,
|
||||||
|
[property: JsonPropertyName("bandwidth_used")] long BandwidthUsed,
|
||||||
|
[property: JsonPropertyName("storage_limit")] long StorageLimit,
|
||||||
|
[property: JsonPropertyName("bandwidth_limit")] long BandwidthLimit
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Storage exception.
|
||||||
|
/// </summary>
|
||||||
|
public class StorageException : Exception
|
||||||
|
{
|
||||||
|
public string? Code { get; }
|
||||||
|
public int? StatusCode { get; }
|
||||||
|
|
||||||
|
public StorageException(string message, string? code = null, int? statusCode = null)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
Code = code;
|
||||||
|
StatusCode = statusCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
sdk/csharp/Synor.Sdk/Synor.Sdk.csproj
Normal file
22
sdk/csharp/Synor.Sdk/Synor.Sdk.csproj
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<Version>0.1.0</Version>
|
||||||
|
<Authors>Synor</Authors>
|
||||||
|
<Company>Synor</Company>
|
||||||
|
<Description>C# SDK for Synor - Compute, Wallet, RPC, and Storage</Description>
|
||||||
|
<PackageId>Synor.Sdk</PackageId>
|
||||||
|
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||||
|
<PackageProjectUrl>https://synor.cc</PackageProjectUrl>
|
||||||
|
<RepositoryUrl>https://github.com/synor/synor</RepositoryUrl>
|
||||||
|
<PackageTags>synor;blockchain;wallet;rpc;storage;ipfs</PackageTags>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="System.Text.Json" Version="8.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
256
sdk/csharp/Synor.Sdk/Wallet/SynorWallet.cs
Normal file
256
sdk/csharp/Synor.Sdk/Wallet/SynorWallet.cs
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace Synor.Sdk.Wallet;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Synor Wallet SDK client for C#.
|
||||||
|
///
|
||||||
|
/// Provides key management, transaction signing, and balance queries
|
||||||
|
/// for the Synor blockchain.
|
||||||
|
/// </summary>
|
||||||
|
/// <example>
|
||||||
|
/// <code>
|
||||||
|
/// var wallet = new SynorWallet(new WalletConfig { ApiKey = "your-api-key" });
|
||||||
|
///
|
||||||
|
/// // Create a new wallet
|
||||||
|
/// var result = await wallet.CreateWalletAsync();
|
||||||
|
/// Console.WriteLine($"Wallet ID: {result.Wallet.Id}");
|
||||||
|
/// Console.WriteLine($"Mnemonic: {result.Mnemonic}");
|
||||||
|
///
|
||||||
|
/// // Get balance
|
||||||
|
/// var balance = await wallet.GetBalanceAsync(result.Wallet.Addresses[0].AddressString);
|
||||||
|
/// Console.WriteLine($"Balance: {balance.Total}");
|
||||||
|
///
|
||||||
|
/// wallet.Dispose();
|
||||||
|
/// </code>
|
||||||
|
/// </example>
|
||||||
|
public class SynorWallet : IDisposable
|
||||||
|
{
|
||||||
|
private readonly WalletConfig _config;
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly JsonSerializerOptions _jsonOptions;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public SynorWallet(WalletConfig config)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
_httpClient = new HttpClient
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(config.Endpoint),
|
||||||
|
Timeout = config.Timeout
|
||||||
|
};
|
||||||
|
_httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {config.ApiKey}");
|
||||||
|
|
||||||
|
_jsonOptions = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new wallet.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<CreateWalletResult> CreateWalletAsync(
|
||||||
|
WalletType type = WalletType.Standard,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var request = new { type = type.ToString().ToLower(), network = _config.Network.ToString().ToLower() };
|
||||||
|
return await PostAsync<CreateWalletResult>("/wallets", request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Import a wallet from mnemonic.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Wallet> ImportWalletAsync(
|
||||||
|
string mnemonic,
|
||||||
|
string? passphrase = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var request = new
|
||||||
|
{
|
||||||
|
mnemonic,
|
||||||
|
passphrase,
|
||||||
|
network = _config.Network.ToString().ToLower()
|
||||||
|
};
|
||||||
|
return await PostAsync<Wallet>("/wallets/import", request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get a wallet by ID.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Wallet> GetWalletAsync(
|
||||||
|
string walletId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetAsync<Wallet>($"/wallets/{walletId}", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generate a new address for a wallet.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Address> GenerateAddressAsync(
|
||||||
|
string walletId,
|
||||||
|
bool isChange = false,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var request = new { is_change = isChange };
|
||||||
|
return await PostAsync<Address>($"/wallets/{walletId}/addresses", request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get a stealth address for privacy transactions.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<StealthAddress> GetStealthAddressAsync(
|
||||||
|
string walletId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetAsync<StealthAddress>($"/wallets/{walletId}/stealth-address", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sign a transaction.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<SignedTransaction> SignTransactionAsync(
|
||||||
|
string walletId,
|
||||||
|
Transaction transaction,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var request = new { wallet_id = walletId, transaction };
|
||||||
|
return await PostAsync<SignedTransaction>("/transactions/sign", request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sign a message.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Signature> SignMessageAsync(
|
||||||
|
string walletId,
|
||||||
|
string message,
|
||||||
|
int addressIndex = 0,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var request = new { wallet_id = walletId, message, address_index = addressIndex };
|
||||||
|
return await PostAsync<Signature>("/messages/sign", request, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verify a message signature.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> VerifyMessageAsync(
|
||||||
|
string message,
|
||||||
|
string signature,
|
||||||
|
string address,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var request = new { message, signature, address };
|
||||||
|
var response = await PostAsync<JsonElement>("/messages/verify", request, cancellationToken);
|
||||||
|
return response.GetProperty("valid").GetBoolean();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get balance for an address.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<Balance> GetBalanceAsync(
|
||||||
|
string address,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetAsync<Balance>($"/addresses/{address}/balance", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get UTXOs for an address.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<UTXO>> GetUTXOsAsync(
|
||||||
|
string address,
|
||||||
|
int minConfirmations = 1,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await GetAsync<List<UTXO>>(
|
||||||
|
$"/addresses/{address}/utxos?min_confirmations={minConfirmations}",
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Estimate transaction fee.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<long> EstimateFeeAsync(
|
||||||
|
Priority priority = Priority.Medium,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var response = await GetAsync<JsonElement>(
|
||||||
|
$"/fees/estimate?priority={priority.ToString().ToLower()}",
|
||||||
|
cancellationToken);
|
||||||
|
return response.GetProperty("fee_per_byte").GetInt64();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T> GetAsync<T>(string path, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await ExecuteWithRetryAsync(async () =>
|
||||||
|
{
|
||||||
|
var response = await _httpClient.GetAsync(path, cancellationToken);
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
return await response.Content.ReadFromJsonAsync<T>(_jsonOptions, cancellationToken)
|
||||||
|
?? throw new WalletException("Invalid response");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T> PostAsync<T>(string path, object body, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await ExecuteWithRetryAsync(async () =>
|
||||||
|
{
|
||||||
|
var response = await _httpClient.PostAsJsonAsync(path, body, _jsonOptions, cancellationToken);
|
||||||
|
await EnsureSuccessAsync(response);
|
||||||
|
return await response.Content.ReadFromJsonAsync<T>(_jsonOptions, cancellationToken)
|
||||||
|
?? throw new WalletException("Invalid response");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureSuccessAsync(HttpResponseMessage response)
|
||||||
|
{
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
|
throw new WalletException(
|
||||||
|
$"HTTP error: {content}",
|
||||||
|
statusCode: (int)response.StatusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T> ExecuteWithRetryAsync<T>(Func<Task<T>> operation)
|
||||||
|
{
|
||||||
|
Exception? lastException = null;
|
||||||
|
|
||||||
|
for (int attempt = 0; attempt < _config.Retries; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await operation();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
lastException = ex;
|
||||||
|
if (_config.Debug)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Attempt {attempt + 1} failed: {ex.Message}");
|
||||||
|
}
|
||||||
|
if (attempt < _config.Retries - 1)
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(attempt + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastException ?? new WalletException("Unknown error after retries");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (!_disposed)
|
||||||
|
{
|
||||||
|
_httpClient.Dispose();
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
167
sdk/csharp/Synor.Sdk/Wallet/Types.cs
Normal file
167
sdk/csharp/Synor.Sdk/Wallet/Types.cs
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Synor.Sdk.Wallet;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Network environment for wallet operations.
|
||||||
|
/// </summary>
|
||||||
|
public enum Network
|
||||||
|
{
|
||||||
|
[JsonPropertyName("mainnet")] Mainnet,
|
||||||
|
[JsonPropertyName("testnet")] Testnet,
|
||||||
|
[JsonPropertyName("devnet")] Devnet
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Type of wallet to create.
|
||||||
|
/// </summary>
|
||||||
|
public enum WalletType
|
||||||
|
{
|
||||||
|
[JsonPropertyName("standard")] Standard,
|
||||||
|
[JsonPropertyName("multisig")] Multisig,
|
||||||
|
[JsonPropertyName("hardware")] Hardware,
|
||||||
|
[JsonPropertyName("stealth")] Stealth
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Transaction priority for fee estimation.
|
||||||
|
/// </summary>
|
||||||
|
public enum Priority
|
||||||
|
{
|
||||||
|
[JsonPropertyName("low")] Low,
|
||||||
|
[JsonPropertyName("medium")] Medium,
|
||||||
|
[JsonPropertyName("high")] High
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration for the wallet client.
|
||||||
|
/// </summary>
|
||||||
|
public class WalletConfig
|
||||||
|
{
|
||||||
|
public required string ApiKey { get; init; }
|
||||||
|
public string Endpoint { get; init; } = "https://wallet.synor.io/v1";
|
||||||
|
public Network Network { get; init; } = Network.Mainnet;
|
||||||
|
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||||
|
public int Retries { get; init; } = 3;
|
||||||
|
public bool Debug { get; init; } = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A wallet address.
|
||||||
|
/// </summary>
|
||||||
|
public record Address(
|
||||||
|
[property: JsonPropertyName("address")] string AddressString,
|
||||||
|
[property: JsonPropertyName("index")] int Index,
|
||||||
|
[property: JsonPropertyName("is_change")] bool IsChange = false
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A Synor wallet with addresses and keys.
|
||||||
|
/// </summary>
|
||||||
|
public record Wallet(
|
||||||
|
[property: JsonPropertyName("id")] string Id,
|
||||||
|
[property: JsonPropertyName("type")] WalletType Type,
|
||||||
|
[property: JsonPropertyName("network")] Network Network,
|
||||||
|
[property: JsonPropertyName("addresses")] List<Address> Addresses,
|
||||||
|
[property: JsonPropertyName("created_at")] string CreatedAt
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stealth address for privacy transactions.
|
||||||
|
/// </summary>
|
||||||
|
public record StealthAddress(
|
||||||
|
[property: JsonPropertyName("spend_public_key")] string SpendPublicKey,
|
||||||
|
[property: JsonPropertyName("view_public_key")] string ViewPublicKey,
|
||||||
|
[property: JsonPropertyName("one_time_address")] string OneTimeAddress
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Result of wallet creation.
|
||||||
|
/// </summary>
|
||||||
|
public record CreateWalletResult(
|
||||||
|
[property: JsonPropertyName("wallet")] Wallet Wallet,
|
||||||
|
[property: JsonPropertyName("mnemonic")] string? Mnemonic = null
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Transaction input (UTXO reference).
|
||||||
|
/// </summary>
|
||||||
|
public record TransactionInput(
|
||||||
|
[property: JsonPropertyName("txid")] string Txid,
|
||||||
|
[property: JsonPropertyName("vout")] int Vout,
|
||||||
|
[property: JsonPropertyName("amount")] long Amount
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Transaction output.
|
||||||
|
/// </summary>
|
||||||
|
public record TransactionOutput(
|
||||||
|
[property: JsonPropertyName("address")] string Address,
|
||||||
|
[property: JsonPropertyName("amount")] long Amount
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Transaction for signing.
|
||||||
|
/// </summary>
|
||||||
|
public record Transaction(
|
||||||
|
List<TransactionInput> Inputs,
|
||||||
|
List<TransactionOutput> Outputs,
|
||||||
|
long? Fee = null,
|
||||||
|
Priority Priority = Priority.Medium
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Signed transaction ready for broadcast.
|
||||||
|
/// </summary>
|
||||||
|
public record SignedTransaction(
|
||||||
|
[property: JsonPropertyName("txid")] string Txid,
|
||||||
|
[property: JsonPropertyName("hex")] string Hex,
|
||||||
|
[property: JsonPropertyName("size")] int Size,
|
||||||
|
[property: JsonPropertyName("fee")] long Fee
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Cryptographic signature.
|
||||||
|
/// </summary>
|
||||||
|
public record Signature(
|
||||||
|
[property: JsonPropertyName("signature")] string SignatureString,
|
||||||
|
[property: JsonPropertyName("address")] string Address,
|
||||||
|
[property: JsonPropertyName("recovery_id")] int? RecoveryId = null
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unspent transaction output.
|
||||||
|
/// </summary>
|
||||||
|
public record UTXO(
|
||||||
|
[property: JsonPropertyName("txid")] string Txid,
|
||||||
|
[property: JsonPropertyName("vout")] int Vout,
|
||||||
|
[property: JsonPropertyName("amount")] long Amount,
|
||||||
|
[property: JsonPropertyName("address")] string Address,
|
||||||
|
[property: JsonPropertyName("confirmations")] int Confirmations,
|
||||||
|
[property: JsonPropertyName("script_pubkey")] string? ScriptPubKey = null
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wallet balance information.
|
||||||
|
/// </summary>
|
||||||
|
public record Balance(
|
||||||
|
[property: JsonPropertyName("confirmed")] long Confirmed,
|
||||||
|
[property: JsonPropertyName("unconfirmed")] long Unconfirmed,
|
||||||
|
[property: JsonPropertyName("total")] long Total
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exception thrown by wallet operations.
|
||||||
|
/// </summary>
|
||||||
|
public class WalletException : Exception
|
||||||
|
{
|
||||||
|
public string? Code { get; }
|
||||||
|
public int? StatusCode { get; }
|
||||||
|
|
||||||
|
public WalletException(string message, string? code = null, int? statusCode = null)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
Code = code;
|
||||||
|
StatusCode = statusCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -404,7 +404,7 @@
|
||||||
"languageVersion": "3.4"
|
"languageVersion": "3.4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "synor_compute",
|
"name": "synor_sdk",
|
||||||
"rootUri": "../",
|
"rootUri": "../",
|
||||||
"packageUri": "lib/",
|
"packageUri": "lib/",
|
||||||
"languageVersion": "3.0"
|
"languageVersion": "3.0"
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
335
sdk/flutter/lib/src/rpc/synor_rpc.dart
Normal file
335
sdk/flutter/lib/src/rpc/synor_rpc.dart
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
/// Synor RPC SDK client for Flutter/Dart.
|
||||||
|
library synor_rpc;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
import 'types.dart';
|
||||||
|
|
||||||
|
export 'types.dart';
|
||||||
|
|
||||||
|
/// Synor RPC SDK client for Flutter/Dart.
|
||||||
|
///
|
||||||
|
/// Provides blockchain queries, transaction submission, and real-time
|
||||||
|
/// subscriptions via WebSocket.
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
/// ```dart
|
||||||
|
/// final rpc = SynorRpc(RpcConfig(apiKey: 'your-api-key'));
|
||||||
|
///
|
||||||
|
/// // Get latest block
|
||||||
|
/// final block = await rpc.getLatestBlock();
|
||||||
|
/// print('Latest block: ${block.height}');
|
||||||
|
///
|
||||||
|
/// // Subscribe to new blocks
|
||||||
|
/// final subscription = rpc.subscribeBlocks((block) {
|
||||||
|
/// print('New block: ${block.height}');
|
||||||
|
/// });
|
||||||
|
///
|
||||||
|
/// // Later: cancel subscription
|
||||||
|
/// subscription.cancel();
|
||||||
|
/// rpc.close();
|
||||||
|
/// ```
|
||||||
|
class SynorRpc {
|
||||||
|
final RpcConfig config;
|
||||||
|
final http.Client _client;
|
||||||
|
WebSocketChannel? _wsChannel;
|
||||||
|
StreamSubscription? _wsSubscription;
|
||||||
|
final Map<String, void Function(String)> _subscriptions = {};
|
||||||
|
|
||||||
|
SynorRpc(this.config) : _client = http.Client();
|
||||||
|
|
||||||
|
// Block operations
|
||||||
|
|
||||||
|
/// Get the latest block.
|
||||||
|
Future<Block> getLatestBlock() async {
|
||||||
|
return _get('/blocks/latest', Block.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a block by hash or height.
|
||||||
|
Future<Block> getBlock(String hashOrHeight) async {
|
||||||
|
return _get('/blocks/$hashOrHeight', Block.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a block header by hash or height.
|
||||||
|
Future<BlockHeader> getBlockHeader(String hashOrHeight) async {
|
||||||
|
return _get('/blocks/$hashOrHeight/header', BlockHeader.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get blocks in a range.
|
||||||
|
Future<List<Block>> getBlocks(int startHeight, int endHeight) async {
|
||||||
|
return _getList(
|
||||||
|
'/blocks?start=$startHeight&end=$endHeight',
|
||||||
|
Block.fromJson,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction operations
|
||||||
|
|
||||||
|
/// Get a transaction by ID.
|
||||||
|
Future<RpcTransaction> getTransaction(String txid) async {
|
||||||
|
return _get('/transactions/$txid', RpcTransaction.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get raw transaction hex.
|
||||||
|
Future<String> getRawTransaction(String txid) async {
|
||||||
|
final response = await _get<Map<String, dynamic>>(
|
||||||
|
'/transactions/$txid/raw',
|
||||||
|
(json) => json,
|
||||||
|
);
|
||||||
|
return response['hex'] as String;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a raw transaction.
|
||||||
|
Future<SubmitResult> sendRawTransaction(String hex) async {
|
||||||
|
return _post('/transactions/send', {'hex': hex}, SubmitResult.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a raw transaction without broadcasting.
|
||||||
|
Future<RpcTransaction> decodeRawTransaction(String hex) async {
|
||||||
|
return _post('/transactions/decode', {'hex': hex}, RpcTransaction.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get transactions for an address.
|
||||||
|
Future<List<RpcTransaction>> getAddressTransactions(
|
||||||
|
String address, {
|
||||||
|
int limit = 50,
|
||||||
|
int offset = 0,
|
||||||
|
}) async {
|
||||||
|
return _getList(
|
||||||
|
'/addresses/$address/transactions?limit=$limit&offset=$offset',
|
||||||
|
RpcTransaction.fromJson,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fee estimation
|
||||||
|
|
||||||
|
/// Estimate fee for a given priority.
|
||||||
|
Future<FeeEstimate> estimateFee({RpcPriority priority = RpcPriority.medium}) async {
|
||||||
|
return _get(
|
||||||
|
'/fees/estimate?priority=${priority.toJson()}',
|
||||||
|
FeeEstimate.fromJson,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all fee estimates.
|
||||||
|
Future<Map<RpcPriority, FeeEstimate>> getAllFeeEstimates() async {
|
||||||
|
final estimates = await _getList('/fees/estimates', FeeEstimate.fromJson);
|
||||||
|
return {for (var e in estimates) e.priority: e};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chain information
|
||||||
|
|
||||||
|
/// Get chain information.
|
||||||
|
Future<ChainInfo> getChainInfo() async {
|
||||||
|
return _get('/chain/info', ChainInfo.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get mempool information.
|
||||||
|
Future<MempoolInfo> getMempoolInfo() async {
|
||||||
|
return _get('/mempool/info', MempoolInfo.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get mempool transactions.
|
||||||
|
Future<List<String>> getMempoolTransactions({int limit = 100}) async {
|
||||||
|
return _getList('/mempool/transactions?limit=$limit', (json) => json['txid'] as String);
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket subscriptions
|
||||||
|
|
||||||
|
/// Subscribe to new blocks.
|
||||||
|
Subscription subscribeBlocks(void Function(Block) callback) {
|
||||||
|
return _subscribe('blocks', null, (data) {
|
||||||
|
final block = Block.fromJson(jsonDecode(data) as Map<String, dynamic>);
|
||||||
|
callback(block);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribe to transactions for a specific address.
|
||||||
|
Subscription subscribeAddress(String address, void Function(RpcTransaction) callback) {
|
||||||
|
return _subscribe('address', address, (data) {
|
||||||
|
final tx = RpcTransaction.fromJson(jsonDecode(data) as Map<String, dynamic>);
|
||||||
|
callback(tx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscribe to mempool transactions.
|
||||||
|
Subscription subscribeMempool(void Function(RpcTransaction) callback) {
|
||||||
|
return _subscribe('mempool', null, (data) {
|
||||||
|
final tx = RpcTransaction.fromJson(jsonDecode(data) as Map<String, dynamic>);
|
||||||
|
callback(tx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Subscription _subscribe(
|
||||||
|
String channel,
|
||||||
|
String? filter,
|
||||||
|
void Function(String) callback,
|
||||||
|
) {
|
||||||
|
_ensureWebSocketConnection();
|
||||||
|
|
||||||
|
final subscriptionId = DateTime.now().millisecondsSinceEpoch.toString();
|
||||||
|
_subscriptions[subscriptionId] = callback;
|
||||||
|
|
||||||
|
final message = jsonEncode({
|
||||||
|
'type': 'subscribe',
|
||||||
|
'channel': channel,
|
||||||
|
'filter': filter,
|
||||||
|
});
|
||||||
|
_wsChannel?.sink.add(message);
|
||||||
|
|
||||||
|
return Subscription(
|
||||||
|
id: subscriptionId,
|
||||||
|
channel: channel,
|
||||||
|
onCancel: () {
|
||||||
|
_subscriptions.remove(subscriptionId);
|
||||||
|
final unsubMessage = jsonEncode({
|
||||||
|
'type': 'unsubscribe',
|
||||||
|
'channel': channel,
|
||||||
|
});
|
||||||
|
_wsChannel?.sink.add(unsubMessage);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _ensureWebSocketConnection() {
|
||||||
|
if (_wsChannel != null) return;
|
||||||
|
|
||||||
|
final uri = Uri.parse('${config.wsEndpoint}?token=${config.apiKey}');
|
||||||
|
_wsChannel = WebSocketChannel.connect(uri);
|
||||||
|
|
||||||
|
_wsSubscription = _wsChannel!.stream.listen(
|
||||||
|
(message) {
|
||||||
|
try {
|
||||||
|
final data = jsonDecode(message as String) as Map<String, dynamic>;
|
||||||
|
if (data['type'] == 'data' && data['data'] != null) {
|
||||||
|
final messageData = data['data'] as String;
|
||||||
|
for (final callback in _subscriptions.values) {
|
||||||
|
callback(messageData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (config.debug) {
|
||||||
|
print('Failed to parse WS message: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) {
|
||||||
|
if (config.debug) {
|
||||||
|
print('WebSocket error: $error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
_wsChannel = null;
|
||||||
|
_wsSubscription = null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP helper methods
|
||||||
|
|
||||||
|
Future<T> _get<T>(String path, T Function(Map<String, dynamic>) fromJson) async {
|
||||||
|
return _executeWithRetry(() async {
|
||||||
|
final response = await _client
|
||||||
|
.get(
|
||||||
|
Uri.parse('${config.endpoint}$path'),
|
||||||
|
headers: _headers,
|
||||||
|
)
|
||||||
|
.timeout(config.timeout);
|
||||||
|
|
||||||
|
return _handleResponse(response, fromJson);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<T>> _getList<T>(
|
||||||
|
String path,
|
||||||
|
T Function(Map<String, dynamic>) fromJson,
|
||||||
|
) async {
|
||||||
|
return _executeWithRetry(() async {
|
||||||
|
final response = await _client
|
||||||
|
.get(
|
||||||
|
Uri.parse('${config.endpoint}$path'),
|
||||||
|
headers: _headers,
|
||||||
|
)
|
||||||
|
.timeout(config.timeout);
|
||||||
|
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
final List<dynamic> jsonList = jsonDecode(response.body);
|
||||||
|
return jsonList
|
||||||
|
.map((e) => fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw RpcException(
|
||||||
|
'HTTP error: ${response.body}',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<T> _post<T>(
|
||||||
|
String path,
|
||||||
|
Map<String, dynamic> body,
|
||||||
|
T Function(Map<String, dynamic>) fromJson,
|
||||||
|
) async {
|
||||||
|
return _executeWithRetry(() async {
|
||||||
|
final response = await _client
|
||||||
|
.post(
|
||||||
|
Uri.parse('${config.endpoint}$path'),
|
||||||
|
headers: _headers,
|
||||||
|
body: jsonEncode(body),
|
||||||
|
)
|
||||||
|
.timeout(config.timeout);
|
||||||
|
|
||||||
|
return _handleResponse(response, fromJson);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> get _headers => {
|
||||||
|
'Authorization': 'Bearer ${config.apiKey}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
T _handleResponse<T>(
|
||||||
|
http.Response response,
|
||||||
|
T Function(Map<String, dynamic>) fromJson,
|
||||||
|
) {
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw RpcException(
|
||||||
|
'HTTP error: ${response.body}',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<T> _executeWithRetry<T>(Future<T> Function() operation) async {
|
||||||
|
Exception? lastError;
|
||||||
|
|
||||||
|
for (var attempt = 0; attempt < config.retries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e as Exception;
|
||||||
|
if (config.debug) {
|
||||||
|
print('Attempt ${attempt + 1} failed: $e');
|
||||||
|
}
|
||||||
|
if (attempt < config.retries - 1) {
|
||||||
|
await Future.delayed(Duration(seconds: attempt + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError ?? RpcException('Unknown error after ${config.retries} retries');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the client and release resources.
|
||||||
|
void close() {
|
||||||
|
_wsSubscription?.cancel();
|
||||||
|
_wsChannel?.sink.close();
|
||||||
|
_client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
424
sdk/flutter/lib/src/rpc/types.dart
Normal file
424
sdk/flutter/lib/src/rpc/types.dart
Normal file
|
|
@ -0,0 +1,424 @@
|
||||||
|
/// Types for Synor RPC SDK.
|
||||||
|
library synor_rpc_types;
|
||||||
|
|
||||||
|
/// Network environment.
|
||||||
|
enum RpcNetwork {
|
||||||
|
mainnet,
|
||||||
|
testnet,
|
||||||
|
devnet;
|
||||||
|
|
||||||
|
String toJson() => name;
|
||||||
|
|
||||||
|
static RpcNetwork fromJson(String json) {
|
||||||
|
return RpcNetwork.values.firstWhere(
|
||||||
|
(e) => e.name == json,
|
||||||
|
orElse: () => RpcNetwork.mainnet,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transaction priority for fee estimation.
|
||||||
|
enum RpcPriority {
|
||||||
|
low,
|
||||||
|
medium,
|
||||||
|
high;
|
||||||
|
|
||||||
|
String toJson() => name;
|
||||||
|
|
||||||
|
static RpcPriority fromJson(String json) {
|
||||||
|
return RpcPriority.values.firstWhere(
|
||||||
|
(e) => e.name == json,
|
||||||
|
orElse: () => RpcPriority.medium,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transaction status.
|
||||||
|
enum TransactionStatus {
|
||||||
|
pending,
|
||||||
|
confirmed,
|
||||||
|
failed;
|
||||||
|
|
||||||
|
String toJson() => name;
|
||||||
|
|
||||||
|
static TransactionStatus fromJson(String json) {
|
||||||
|
return TransactionStatus.values.firstWhere(
|
||||||
|
(e) => e.name == json,
|
||||||
|
orElse: () => TransactionStatus.pending,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for the RPC client.
|
||||||
|
class RpcConfig {
|
||||||
|
final String apiKey;
|
||||||
|
final String endpoint;
|
||||||
|
final String wsEndpoint;
|
||||||
|
final RpcNetwork network;
|
||||||
|
final Duration timeout;
|
||||||
|
final int retries;
|
||||||
|
final bool debug;
|
||||||
|
|
||||||
|
const RpcConfig({
|
||||||
|
required this.apiKey,
|
||||||
|
this.endpoint = 'https://rpc.synor.io/v1',
|
||||||
|
this.wsEndpoint = 'wss://rpc.synor.io/v1/ws',
|
||||||
|
this.network = RpcNetwork.mainnet,
|
||||||
|
this.timeout = const Duration(seconds: 30),
|
||||||
|
this.retries = 3,
|
||||||
|
this.debug = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block header information.
|
||||||
|
class BlockHeader {
|
||||||
|
final String hash;
|
||||||
|
final int height;
|
||||||
|
final String previousHash;
|
||||||
|
final int timestamp;
|
||||||
|
final int version;
|
||||||
|
final String merkleRoot;
|
||||||
|
final int nonce;
|
||||||
|
final double difficulty;
|
||||||
|
|
||||||
|
const BlockHeader({
|
||||||
|
required this.hash,
|
||||||
|
required this.height,
|
||||||
|
required this.previousHash,
|
||||||
|
required this.timestamp,
|
||||||
|
required this.version,
|
||||||
|
required this.merkleRoot,
|
||||||
|
required this.nonce,
|
||||||
|
required this.difficulty,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory BlockHeader.fromJson(Map<String, dynamic> json) {
|
||||||
|
return BlockHeader(
|
||||||
|
hash: json['hash'] as String,
|
||||||
|
height: json['height'] as int,
|
||||||
|
previousHash: json['previous_hash'] as String,
|
||||||
|
timestamp: json['timestamp'] as int,
|
||||||
|
version: json['version'] as int,
|
||||||
|
merkleRoot: json['merkle_root'] as String,
|
||||||
|
nonce: json['nonce'] as int,
|
||||||
|
difficulty: (json['difficulty'] as num).toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A blockchain block.
|
||||||
|
class Block {
|
||||||
|
final String hash;
|
||||||
|
final int height;
|
||||||
|
final String previousHash;
|
||||||
|
final int timestamp;
|
||||||
|
final int version;
|
||||||
|
final String merkleRoot;
|
||||||
|
final int nonce;
|
||||||
|
final double difficulty;
|
||||||
|
final List<RpcTransaction> transactions;
|
||||||
|
final int size;
|
||||||
|
final int weight;
|
||||||
|
|
||||||
|
const Block({
|
||||||
|
required this.hash,
|
||||||
|
required this.height,
|
||||||
|
required this.previousHash,
|
||||||
|
required this.timestamp,
|
||||||
|
required this.version,
|
||||||
|
required this.merkleRoot,
|
||||||
|
required this.nonce,
|
||||||
|
required this.difficulty,
|
||||||
|
required this.transactions,
|
||||||
|
required this.size,
|
||||||
|
required this.weight,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Block.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Block(
|
||||||
|
hash: json['hash'] as String,
|
||||||
|
height: json['height'] as int,
|
||||||
|
previousHash: json['previous_hash'] as String,
|
||||||
|
timestamp: json['timestamp'] as int,
|
||||||
|
version: json['version'] as int,
|
||||||
|
merkleRoot: json['merkle_root'] as String,
|
||||||
|
nonce: json['nonce'] as int,
|
||||||
|
difficulty: (json['difficulty'] as num).toDouble(),
|
||||||
|
transactions: (json['transactions'] as List)
|
||||||
|
.map((e) => RpcTransaction.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
size: json['size'] as int,
|
||||||
|
weight: json['weight'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A blockchain transaction.
|
||||||
|
class RpcTransaction {
|
||||||
|
final String txid;
|
||||||
|
final String hash;
|
||||||
|
final int version;
|
||||||
|
final int size;
|
||||||
|
final int weight;
|
||||||
|
final int lockTime;
|
||||||
|
final List<RpcTransactionInput> inputs;
|
||||||
|
final List<RpcTransactionOutput> outputs;
|
||||||
|
final int fee;
|
||||||
|
final int confirmations;
|
||||||
|
final String? blockHash;
|
||||||
|
final int? blockHeight;
|
||||||
|
final int? timestamp;
|
||||||
|
final TransactionStatus status;
|
||||||
|
|
||||||
|
const RpcTransaction({
|
||||||
|
required this.txid,
|
||||||
|
required this.hash,
|
||||||
|
required this.version,
|
||||||
|
required this.size,
|
||||||
|
required this.weight,
|
||||||
|
required this.lockTime,
|
||||||
|
required this.inputs,
|
||||||
|
required this.outputs,
|
||||||
|
required this.fee,
|
||||||
|
required this.confirmations,
|
||||||
|
this.blockHash,
|
||||||
|
this.blockHeight,
|
||||||
|
this.timestamp,
|
||||||
|
this.status = TransactionStatus.pending,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory RpcTransaction.fromJson(Map<String, dynamic> json) {
|
||||||
|
return RpcTransaction(
|
||||||
|
txid: json['txid'] as String,
|
||||||
|
hash: json['hash'] as String,
|
||||||
|
version: json['version'] as int,
|
||||||
|
size: json['size'] as int,
|
||||||
|
weight: json['weight'] as int,
|
||||||
|
lockTime: json['lock_time'] as int,
|
||||||
|
inputs: (json['inputs'] as List)
|
||||||
|
.map((e) => RpcTransactionInput.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
outputs: (json['outputs'] as List)
|
||||||
|
.map((e) => RpcTransactionOutput.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
fee: json['fee'] as int,
|
||||||
|
confirmations: json['confirmations'] as int,
|
||||||
|
blockHash: json['block_hash'] as String?,
|
||||||
|
blockHeight: json['block_height'] as int?,
|
||||||
|
timestamp: json['timestamp'] as int?,
|
||||||
|
status: TransactionStatus.fromJson(json['status'] as String? ?? 'pending'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transaction input.
|
||||||
|
class RpcTransactionInput {
|
||||||
|
final String txid;
|
||||||
|
final int vout;
|
||||||
|
final String? scriptSig;
|
||||||
|
final int sequence;
|
||||||
|
final List<String>? witness;
|
||||||
|
|
||||||
|
const RpcTransactionInput({
|
||||||
|
required this.txid,
|
||||||
|
required this.vout,
|
||||||
|
this.scriptSig,
|
||||||
|
required this.sequence,
|
||||||
|
this.witness,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory RpcTransactionInput.fromJson(Map<String, dynamic> json) {
|
||||||
|
return RpcTransactionInput(
|
||||||
|
txid: json['txid'] as String,
|
||||||
|
vout: json['vout'] as int,
|
||||||
|
scriptSig: json['script_sig'] as String?,
|
||||||
|
sequence: json['sequence'] as int,
|
||||||
|
witness: (json['witness'] as List?)?.cast<String>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transaction output.
|
||||||
|
class RpcTransactionOutput {
|
||||||
|
final int value;
|
||||||
|
final int n;
|
||||||
|
final ScriptPubKey scriptPubKey;
|
||||||
|
|
||||||
|
const RpcTransactionOutput({
|
||||||
|
required this.value,
|
||||||
|
required this.n,
|
||||||
|
required this.scriptPubKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory RpcTransactionOutput.fromJson(Map<String, dynamic> json) {
|
||||||
|
return RpcTransactionOutput(
|
||||||
|
value: json['value'] as int,
|
||||||
|
n: json['n'] as int,
|
||||||
|
scriptPubKey:
|
||||||
|
ScriptPubKey.fromJson(json['script_pubkey'] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Script public key.
|
||||||
|
class ScriptPubKey {
|
||||||
|
final String asm;
|
||||||
|
final String hex;
|
||||||
|
final String type;
|
||||||
|
final String? address;
|
||||||
|
|
||||||
|
const ScriptPubKey({
|
||||||
|
required this.asm,
|
||||||
|
required this.hex,
|
||||||
|
required this.type,
|
||||||
|
this.address,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ScriptPubKey.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ScriptPubKey(
|
||||||
|
asm: json['asm'] as String,
|
||||||
|
hex: json['hex'] as String,
|
||||||
|
type: json['type'] as String,
|
||||||
|
address: json['address'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chain information.
|
||||||
|
class ChainInfo {
|
||||||
|
final String chain;
|
||||||
|
final int blocks;
|
||||||
|
final int headers;
|
||||||
|
final String bestBlockHash;
|
||||||
|
final double difficulty;
|
||||||
|
final int medianTime;
|
||||||
|
final double verificationProgress;
|
||||||
|
final String chainWork;
|
||||||
|
final bool pruned;
|
||||||
|
|
||||||
|
const ChainInfo({
|
||||||
|
required this.chain,
|
||||||
|
required this.blocks,
|
||||||
|
required this.headers,
|
||||||
|
required this.bestBlockHash,
|
||||||
|
required this.difficulty,
|
||||||
|
required this.medianTime,
|
||||||
|
required this.verificationProgress,
|
||||||
|
required this.chainWork,
|
||||||
|
required this.pruned,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ChainInfo.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ChainInfo(
|
||||||
|
chain: json['chain'] as String,
|
||||||
|
blocks: json['blocks'] as int,
|
||||||
|
headers: json['headers'] as int,
|
||||||
|
bestBlockHash: json['best_block_hash'] as String,
|
||||||
|
difficulty: (json['difficulty'] as num).toDouble(),
|
||||||
|
medianTime: json['median_time'] as int,
|
||||||
|
verificationProgress: (json['verification_progress'] as num).toDouble(),
|
||||||
|
chainWork: json['chain_work'] as String,
|
||||||
|
pruned: json['pruned'] as bool,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mempool information.
|
||||||
|
class MempoolInfo {
|
||||||
|
final int size;
|
||||||
|
final int bytes;
|
||||||
|
final int usage;
|
||||||
|
final int maxMempool;
|
||||||
|
final double mempoolMinFee;
|
||||||
|
final double minRelayTxFee;
|
||||||
|
|
||||||
|
const MempoolInfo({
|
||||||
|
required this.size,
|
||||||
|
required this.bytes,
|
||||||
|
required this.usage,
|
||||||
|
required this.maxMempool,
|
||||||
|
required this.mempoolMinFee,
|
||||||
|
required this.minRelayTxFee,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory MempoolInfo.fromJson(Map<String, dynamic> json) {
|
||||||
|
return MempoolInfo(
|
||||||
|
size: json['size'] as int,
|
||||||
|
bytes: json['bytes'] as int,
|
||||||
|
usage: json['usage'] as int,
|
||||||
|
maxMempool: json['max_mempool'] as int,
|
||||||
|
mempoolMinFee: (json['mempool_min_fee'] as num).toDouble(),
|
||||||
|
minRelayTxFee: (json['min_relay_tx_fee'] as num).toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fee estimate for transactions.
|
||||||
|
class FeeEstimate {
|
||||||
|
final RpcPriority priority;
|
||||||
|
final int feeRate;
|
||||||
|
final int estimatedBlocks;
|
||||||
|
|
||||||
|
const FeeEstimate({
|
||||||
|
required this.priority,
|
||||||
|
required this.feeRate,
|
||||||
|
required this.estimatedBlocks,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory FeeEstimate.fromJson(Map<String, dynamic> json) {
|
||||||
|
return FeeEstimate(
|
||||||
|
priority: RpcPriority.fromJson(json['priority'] as String),
|
||||||
|
feeRate: json['fee_rate'] as int,
|
||||||
|
estimatedBlocks: json['estimated_blocks'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of transaction submission.
|
||||||
|
class SubmitResult {
|
||||||
|
final String txid;
|
||||||
|
final bool accepted;
|
||||||
|
final String? reason;
|
||||||
|
|
||||||
|
const SubmitResult({
|
||||||
|
required this.txid,
|
||||||
|
required this.accepted,
|
||||||
|
this.reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory SubmitResult.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SubmitResult(
|
||||||
|
txid: json['txid'] as String,
|
||||||
|
accepted: json['accepted'] as bool,
|
||||||
|
reason: json['reason'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subscription handle for WebSocket events.
|
||||||
|
class Subscription {
|
||||||
|
final String id;
|
||||||
|
final String channel;
|
||||||
|
final void Function() _onCancel;
|
||||||
|
|
||||||
|
Subscription({
|
||||||
|
required this.id,
|
||||||
|
required this.channel,
|
||||||
|
required void Function() onCancel,
|
||||||
|
}) : _onCancel = onCancel;
|
||||||
|
|
||||||
|
/// Cancel the subscription.
|
||||||
|
void cancel() => _onCancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exception thrown by RPC operations.
|
||||||
|
class RpcException implements Exception {
|
||||||
|
final String message;
|
||||||
|
final String? code;
|
||||||
|
final int? statusCode;
|
||||||
|
|
||||||
|
const RpcException(this.message, {this.code, this.statusCode});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'RpcException: $message';
|
||||||
|
}
|
||||||
436
sdk/flutter/lib/src/storage/synor_storage.dart
Normal file
436
sdk/flutter/lib/src/storage/synor_storage.dart
Normal file
|
|
@ -0,0 +1,436 @@
|
||||||
|
/// Synor Storage SDK client for Flutter/Dart.
|
||||||
|
library synor_storage;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'types.dart';
|
||||||
|
|
||||||
|
export 'types.dart';
|
||||||
|
|
||||||
|
/// Synor Storage SDK client for Flutter/Dart.
|
||||||
|
///
|
||||||
|
/// Provides IPFS-compatible decentralized storage with upload, download,
|
||||||
|
/// pinning, and CAR file operations.
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
/// ```dart
|
||||||
|
/// final storage = SynorStorage(StorageConfig(apiKey: 'your-api-key'));
|
||||||
|
///
|
||||||
|
/// // Upload a file
|
||||||
|
/// final data = utf8.encode('Hello, World!');
|
||||||
|
/// final result = await storage.upload(
|
||||||
|
/// Uint8List.fromList(data),
|
||||||
|
/// options: UploadOptions(name: 'hello.txt'),
|
||||||
|
/// );
|
||||||
|
/// print('CID: ${result.cid}');
|
||||||
|
///
|
||||||
|
/// // Download content
|
||||||
|
/// final content = await storage.download(result.cid);
|
||||||
|
/// print('Content: ${utf8.decode(content)}');
|
||||||
|
///
|
||||||
|
/// // Pin content
|
||||||
|
/// final pin = await storage.pin(PinRequest(cid: result.cid, durationDays: 30));
|
||||||
|
/// print('Pin status: ${pin.status}');
|
||||||
|
///
|
||||||
|
/// storage.close();
|
||||||
|
/// ```
|
||||||
|
class SynorStorage {
|
||||||
|
final StorageConfig config;
|
||||||
|
final http.Client _client;
|
||||||
|
|
||||||
|
SynorStorage(this.config) : _client = http.Client();
|
||||||
|
|
||||||
|
// Upload operations
|
||||||
|
|
||||||
|
/// Upload data to storage.
|
||||||
|
Future<UploadResponse> upload(
|
||||||
|
Uint8List data, {
|
||||||
|
UploadOptions options = const UploadOptions(),
|
||||||
|
}) async {
|
||||||
|
return _executeWithRetry(() async {
|
||||||
|
final request = http.MultipartRequest(
|
||||||
|
'POST',
|
||||||
|
Uri.parse('${config.endpoint}/upload'),
|
||||||
|
);
|
||||||
|
|
||||||
|
request.headers['Authorization'] = 'Bearer ${config.apiKey}';
|
||||||
|
|
||||||
|
request.files.add(http.MultipartFile.fromBytes(
|
||||||
|
'file',
|
||||||
|
data,
|
||||||
|
filename: options.name ?? 'file',
|
||||||
|
));
|
||||||
|
|
||||||
|
if (options.name != null) {
|
||||||
|
request.fields['name'] = options.name!;
|
||||||
|
}
|
||||||
|
request.fields['wrap_with_directory'] = options.wrapWithDirectory.toString();
|
||||||
|
request.fields['hash_algorithm'] = options.hashAlgorithm.toJson();
|
||||||
|
if (options.chunkSize != null) {
|
||||||
|
request.fields['chunk_size'] = options.chunkSize.toString();
|
||||||
|
}
|
||||||
|
if (options.pinDurationDays != null) {
|
||||||
|
request.fields['pin_duration_days'] = options.pinDurationDays.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
final streamedResponse = await request.send().timeout(config.timeout);
|
||||||
|
final response = await http.Response.fromStream(streamedResponse);
|
||||||
|
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return UploadResponse.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw StorageException(
|
||||||
|
'Upload failed: ${response.body}',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload multiple files as a directory.
|
||||||
|
Future<UploadResponse> uploadDirectory(
|
||||||
|
List<FileEntry> files, {
|
||||||
|
String? dirName,
|
||||||
|
}) async {
|
||||||
|
return _executeWithRetry(() async {
|
||||||
|
final request = http.MultipartRequest(
|
||||||
|
'POST',
|
||||||
|
Uri.parse('${config.endpoint}/upload/directory'),
|
||||||
|
);
|
||||||
|
|
||||||
|
request.headers['Authorization'] = 'Bearer ${config.apiKey}';
|
||||||
|
|
||||||
|
for (final file in files) {
|
||||||
|
request.files.add(http.MultipartFile.fromBytes(
|
||||||
|
'files',
|
||||||
|
file.content,
|
||||||
|
filename: file.path,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dirName != null) {
|
||||||
|
request.fields['name'] = dirName;
|
||||||
|
}
|
||||||
|
|
||||||
|
final streamedResponse = await request.send().timeout(config.timeout);
|
||||||
|
final response = await http.Response.fromStream(streamedResponse);
|
||||||
|
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return UploadResponse.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw StorageException(
|
||||||
|
'Directory upload failed: ${response.body}',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download operations
|
||||||
|
|
||||||
|
/// Download content by CID.
|
||||||
|
Future<Uint8List> download(String cid) async {
|
||||||
|
return _executeWithRetry(() async {
|
||||||
|
final response = await _client
|
||||||
|
.get(
|
||||||
|
Uri.parse('${config.endpoint}/content/$cid'),
|
||||||
|
headers: {'Authorization': 'Bearer ${config.apiKey}'},
|
||||||
|
)
|
||||||
|
.timeout(config.timeout);
|
||||||
|
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
return response.bodyBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw StorageException(
|
||||||
|
'Download failed: ${response.body}',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download content as a stream.
|
||||||
|
Stream<List<int>> downloadStream(String cid) async* {
|
||||||
|
final request = http.Request(
|
||||||
|
'GET',
|
||||||
|
Uri.parse('${config.endpoint}/content/$cid'),
|
||||||
|
);
|
||||||
|
request.headers['Authorization'] = 'Bearer ${config.apiKey}';
|
||||||
|
|
||||||
|
final response = await _client.send(request);
|
||||||
|
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throw StorageException(
|
||||||
|
'Download failed',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* response.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the gateway URL for content.
|
||||||
|
String getGatewayUrl(String cid, {String? path}) {
|
||||||
|
if (path != null) {
|
||||||
|
return '${config.gateway}/ipfs/$cid/$path';
|
||||||
|
}
|
||||||
|
return '${config.gateway}/ipfs/$cid';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pinning operations
|
||||||
|
|
||||||
|
/// Pin content to ensure availability.
|
||||||
|
Future<Pin> pin(PinRequest request) async {
|
||||||
|
return _post('/pins', request.toJson(), Pin.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unpin content.
|
||||||
|
Future<void> unpin(String cid) async {
|
||||||
|
await _delete('/pins/$cid');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get pin status.
|
||||||
|
Future<Pin> getPinStatus(String cid) async {
|
||||||
|
return _get('/pins/$cid', Pin.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all pins.
|
||||||
|
Future<List<Pin>> listPins({
|
||||||
|
PinStatus? status,
|
||||||
|
int limit = 50,
|
||||||
|
int offset = 0,
|
||||||
|
}) async {
|
||||||
|
var path = '/pins?limit=$limit&offset=$offset';
|
||||||
|
if (status != null) {
|
||||||
|
path += '&status=${status.toJson()}';
|
||||||
|
}
|
||||||
|
return _getList(path, Pin.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CAR file operations
|
||||||
|
|
||||||
|
/// Create a CAR file from entries.
|
||||||
|
Future<CarFile> createCar(List<CarEntry> entries) async {
|
||||||
|
return _executeWithRetry(() async {
|
||||||
|
final request = http.MultipartRequest(
|
||||||
|
'POST',
|
||||||
|
Uri.parse('${config.endpoint}/car'),
|
||||||
|
);
|
||||||
|
|
||||||
|
request.headers['Authorization'] = 'Bearer ${config.apiKey}';
|
||||||
|
|
||||||
|
for (final entry in entries) {
|
||||||
|
request.files.add(http.MultipartFile.fromBytes(
|
||||||
|
'files',
|
||||||
|
entry.content,
|
||||||
|
filename: entry.path,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
final streamedResponse = await request.send().timeout(config.timeout);
|
||||||
|
final response = await http.Response.fromStream(streamedResponse);
|
||||||
|
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return CarFile.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw StorageException(
|
||||||
|
'CAR creation failed: ${response.body}',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import a CAR file.
|
||||||
|
Future<List<String>> importCar(Uint8List carData) async {
|
||||||
|
return _executeWithRetry(() async {
|
||||||
|
final request = http.MultipartRequest(
|
||||||
|
'POST',
|
||||||
|
Uri.parse('${config.endpoint}/car/import'),
|
||||||
|
);
|
||||||
|
|
||||||
|
request.headers['Authorization'] = 'Bearer ${config.apiKey}';
|
||||||
|
|
||||||
|
request.files.add(http.MultipartFile.fromBytes(
|
||||||
|
'file',
|
||||||
|
carData,
|
||||||
|
filename: 'archive.car',
|
||||||
|
));
|
||||||
|
|
||||||
|
final streamedResponse = await request.send().timeout(config.timeout);
|
||||||
|
final response = await http.Response.fromStream(streamedResponse);
|
||||||
|
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return (json['cids'] as List).cast<String>();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw StorageException(
|
||||||
|
'CAR import failed: ${response.body}',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export content as a CAR file.
|
||||||
|
Future<Uint8List> exportCar(String cid) async {
|
||||||
|
return _executeWithRetry(() async {
|
||||||
|
final response = await _client
|
||||||
|
.get(
|
||||||
|
Uri.parse('${config.endpoint}/car/$cid'),
|
||||||
|
headers: {'Authorization': 'Bearer ${config.apiKey}'},
|
||||||
|
)
|
||||||
|
.timeout(config.timeout);
|
||||||
|
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
return response.bodyBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw StorageException(
|
||||||
|
'CAR export failed: ${response.body}',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directory operations
|
||||||
|
|
||||||
|
/// List directory contents.
|
||||||
|
Future<List<DirectoryEntry>> listDirectory(String cid) async {
|
||||||
|
return _getList('/content/$cid/ls', DirectoryEntry.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
|
||||||
|
/// Get storage statistics.
|
||||||
|
Future<StorageStats> getStats() async {
|
||||||
|
return _get('/stats', StorageStats.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP helper methods
|
||||||
|
|
||||||
|
Future<T> _get<T>(String path, T Function(Map<String, dynamic>) fromJson) async {
|
||||||
|
return _executeWithRetry(() async {
|
||||||
|
final response = await _client
|
||||||
|
.get(
|
||||||
|
Uri.parse('${config.endpoint}$path'),
|
||||||
|
headers: _headers,
|
||||||
|
)
|
||||||
|
.timeout(config.timeout);
|
||||||
|
|
||||||
|
return _handleResponse(response, fromJson);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<T>> _getList<T>(
|
||||||
|
String path,
|
||||||
|
T Function(Map<String, dynamic>) fromJson,
|
||||||
|
) async {
|
||||||
|
return _executeWithRetry(() async {
|
||||||
|
final response = await _client
|
||||||
|
.get(
|
||||||
|
Uri.parse('${config.endpoint}$path'),
|
||||||
|
headers: _headers,
|
||||||
|
)
|
||||||
|
.timeout(config.timeout);
|
||||||
|
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
final List<dynamic> jsonList = jsonDecode(response.body);
|
||||||
|
return jsonList
|
||||||
|
.map((e) => fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw StorageException(
|
||||||
|
'HTTP error: ${response.body}',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<T> _post<T>(
|
||||||
|
String path,
|
||||||
|
Map<String, dynamic> body,
|
||||||
|
T Function(Map<String, dynamic>) fromJson,
|
||||||
|
) async {
|
||||||
|
return _executeWithRetry(() async {
|
||||||
|
final response = await _client
|
||||||
|
.post(
|
||||||
|
Uri.parse('${config.endpoint}$path'),
|
||||||
|
headers: _headers,
|
||||||
|
body: jsonEncode(body),
|
||||||
|
)
|
||||||
|
.timeout(config.timeout);
|
||||||
|
|
||||||
|
return _handleResponse(response, fromJson);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _delete(String path) async {
|
||||||
|
return _executeWithRetry(() async {
|
||||||
|
final response = await _client
|
||||||
|
.delete(
|
||||||
|
Uri.parse('${config.endpoint}$path'),
|
||||||
|
headers: _headers,
|
||||||
|
)
|
||||||
|
.timeout(config.timeout);
|
||||||
|
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
throw StorageException(
|
||||||
|
'Delete failed: ${response.body}',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> get _headers => {
|
||||||
|
'Authorization': 'Bearer ${config.apiKey}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
T _handleResponse<T>(
|
||||||
|
http.Response response,
|
||||||
|
T Function(Map<String, dynamic>) fromJson,
|
||||||
|
) {
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw StorageException(
|
||||||
|
'HTTP error: ${response.body}',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<T> _executeWithRetry<T>(Future<T> Function() operation) async {
|
||||||
|
Exception? lastError;
|
||||||
|
|
||||||
|
for (var attempt = 0; attempt < config.retries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e as Exception;
|
||||||
|
if (config.debug) {
|
||||||
|
print('Attempt ${attempt + 1} failed: $e');
|
||||||
|
}
|
||||||
|
if (attempt < config.retries - 1) {
|
||||||
|
await Future.delayed(Duration(seconds: attempt + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError ?? StorageException('Unknown error after ${config.retries} retries');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the client and release resources.
|
||||||
|
void close() {
|
||||||
|
_client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
279
sdk/flutter/lib/src/storage/types.dart
Normal file
279
sdk/flutter/lib/src/storage/types.dart
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
/// Types for Synor Storage SDK.
|
||||||
|
library synor_storage_types;
|
||||||
|
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
/// Status of a pinned item.
|
||||||
|
enum PinStatus {
|
||||||
|
queued,
|
||||||
|
pinning,
|
||||||
|
pinned,
|
||||||
|
failed,
|
||||||
|
unpinned;
|
||||||
|
|
||||||
|
String toJson() => name;
|
||||||
|
|
||||||
|
static PinStatus fromJson(String json) {
|
||||||
|
return PinStatus.values.firstWhere(
|
||||||
|
(e) => e.name == json,
|
||||||
|
orElse: () => PinStatus.queued,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash algorithm for content addressing.
|
||||||
|
enum HashAlgorithm {
|
||||||
|
sha2_256('sha2-256'),
|
||||||
|
blake3('blake3');
|
||||||
|
|
||||||
|
final String value;
|
||||||
|
const HashAlgorithm(this.value);
|
||||||
|
|
||||||
|
String toJson() => value;
|
||||||
|
|
||||||
|
static HashAlgorithm fromJson(String json) {
|
||||||
|
return HashAlgorithm.values.firstWhere(
|
||||||
|
(e) => e.value == json,
|
||||||
|
orElse: () => HashAlgorithm.sha2_256,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type of directory entry.
|
||||||
|
enum EntryType {
|
||||||
|
file,
|
||||||
|
directory;
|
||||||
|
|
||||||
|
String toJson() => name;
|
||||||
|
|
||||||
|
static EntryType fromJson(String json) {
|
||||||
|
return EntryType.values.firstWhere(
|
||||||
|
(e) => e.name == json,
|
||||||
|
orElse: () => EntryType.file,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for the storage client.
|
||||||
|
class StorageConfig {
|
||||||
|
final String apiKey;
|
||||||
|
final String endpoint;
|
||||||
|
final String gateway;
|
||||||
|
final Duration timeout;
|
||||||
|
final int retries;
|
||||||
|
final bool debug;
|
||||||
|
|
||||||
|
const StorageConfig({
|
||||||
|
required this.apiKey,
|
||||||
|
this.endpoint = 'https://storage.synor.io/v1',
|
||||||
|
this.gateway = 'https://gateway.synor.io',
|
||||||
|
this.timeout = const Duration(seconds: 60),
|
||||||
|
this.retries = 3,
|
||||||
|
this.debug = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Options for file upload.
|
||||||
|
class UploadOptions {
|
||||||
|
final String? name;
|
||||||
|
final bool wrapWithDirectory;
|
||||||
|
final HashAlgorithm hashAlgorithm;
|
||||||
|
final int? chunkSize;
|
||||||
|
final int? pinDurationDays;
|
||||||
|
|
||||||
|
const UploadOptions({
|
||||||
|
this.name,
|
||||||
|
this.wrapWithDirectory = false,
|
||||||
|
this.hashAlgorithm = HashAlgorithm.sha2_256,
|
||||||
|
this.chunkSize,
|
||||||
|
this.pinDurationDays,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response from upload operation.
|
||||||
|
class UploadResponse {
|
||||||
|
final String cid;
|
||||||
|
final int size;
|
||||||
|
final String? name;
|
||||||
|
final String createdAt;
|
||||||
|
|
||||||
|
const UploadResponse({
|
||||||
|
required this.cid,
|
||||||
|
required this.size,
|
||||||
|
this.name,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory UploadResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
return UploadResponse(
|
||||||
|
cid: json['cid'] as String,
|
||||||
|
size: json['size'] as int,
|
||||||
|
name: json['name'] as String?,
|
||||||
|
createdAt: json['created_at'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pin information.
|
||||||
|
class Pin {
|
||||||
|
final String cid;
|
||||||
|
final String? name;
|
||||||
|
final PinStatus status;
|
||||||
|
final int size;
|
||||||
|
final String createdAt;
|
||||||
|
final String? expiresAt;
|
||||||
|
final List<String>? delegates;
|
||||||
|
|
||||||
|
const Pin({
|
||||||
|
required this.cid,
|
||||||
|
this.name,
|
||||||
|
required this.status,
|
||||||
|
required this.size,
|
||||||
|
required this.createdAt,
|
||||||
|
this.expiresAt,
|
||||||
|
this.delegates,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Pin.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Pin(
|
||||||
|
cid: json['cid'] as String,
|
||||||
|
name: json['name'] as String?,
|
||||||
|
status: PinStatus.fromJson(json['status'] as String),
|
||||||
|
size: json['size'] as int,
|
||||||
|
createdAt: json['created_at'] as String,
|
||||||
|
expiresAt: json['expires_at'] as String?,
|
||||||
|
delegates: (json['delegates'] as List?)?.cast<String>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request to pin content.
|
||||||
|
class PinRequest {
|
||||||
|
final String cid;
|
||||||
|
final String? name;
|
||||||
|
final int? durationDays;
|
||||||
|
final List<String>? origins;
|
||||||
|
final Map<String, String>? meta;
|
||||||
|
|
||||||
|
const PinRequest({
|
||||||
|
required this.cid,
|
||||||
|
this.name,
|
||||||
|
this.durationDays,
|
||||||
|
this.origins,
|
||||||
|
this.meta,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'cid': cid,
|
||||||
|
if (name != null) 'name': name,
|
||||||
|
if (durationDays != null) 'duration_days': durationDays,
|
||||||
|
if (origins != null) 'origins': origins,
|
||||||
|
if (meta != null) 'meta': meta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CAR (Content Addressable Archive) file.
|
||||||
|
class CarFile {
|
||||||
|
final String cid;
|
||||||
|
final int size;
|
||||||
|
final List<String> roots;
|
||||||
|
final String createdAt;
|
||||||
|
|
||||||
|
const CarFile({
|
||||||
|
required this.cid,
|
||||||
|
required this.size,
|
||||||
|
required this.roots,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory CarFile.fromJson(Map<String, dynamic> json) {
|
||||||
|
return CarFile(
|
||||||
|
cid: json['cid'] as String,
|
||||||
|
size: json['size'] as int,
|
||||||
|
roots: (json['roots'] as List).cast<String>(),
|
||||||
|
createdAt: json['created_at'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entry in a CAR file for creation.
|
||||||
|
class CarEntry {
|
||||||
|
final String path;
|
||||||
|
final Uint8List content;
|
||||||
|
|
||||||
|
const CarEntry({
|
||||||
|
required this.path,
|
||||||
|
required this.content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Directory entry.
|
||||||
|
class DirectoryEntry {
|
||||||
|
final String name;
|
||||||
|
final String cid;
|
||||||
|
final int size;
|
||||||
|
final EntryType type;
|
||||||
|
|
||||||
|
const DirectoryEntry({
|
||||||
|
required this.name,
|
||||||
|
required this.cid,
|
||||||
|
required this.size,
|
||||||
|
required this.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory DirectoryEntry.fromJson(Map<String, dynamic> json) {
|
||||||
|
return DirectoryEntry(
|
||||||
|
name: json['name'] as String,
|
||||||
|
cid: json['cid'] as String,
|
||||||
|
size: json['size'] as int,
|
||||||
|
type: EntryType.fromJson(json['type'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File entry for directory creation.
|
||||||
|
class FileEntry {
|
||||||
|
final String path;
|
||||||
|
final Uint8List content;
|
||||||
|
|
||||||
|
const FileEntry({
|
||||||
|
required this.path,
|
||||||
|
required this.content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Storage usage statistics.
|
||||||
|
class StorageStats {
|
||||||
|
final int totalSize;
|
||||||
|
final int pinCount;
|
||||||
|
final int bandwidthUsed;
|
||||||
|
final double quotaUsed;
|
||||||
|
|
||||||
|
const StorageStats({
|
||||||
|
required this.totalSize,
|
||||||
|
required this.pinCount,
|
||||||
|
required this.bandwidthUsed,
|
||||||
|
required this.quotaUsed,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory StorageStats.fromJson(Map<String, dynamic> json) {
|
||||||
|
return StorageStats(
|
||||||
|
totalSize: json['total_size'] as int,
|
||||||
|
pinCount: json['pin_count'] as int,
|
||||||
|
bandwidthUsed: json['bandwidth_used'] as int,
|
||||||
|
quotaUsed: (json['quota_used'] as num).toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exception thrown by storage operations.
|
||||||
|
class StorageException implements Exception {
|
||||||
|
final String message;
|
||||||
|
final String? code;
|
||||||
|
final int? statusCode;
|
||||||
|
|
||||||
|
const StorageException(this.message, {this.code, this.statusCode});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'StorageException: $message';
|
||||||
|
}
|
||||||
244
sdk/flutter/lib/src/wallet/synor_wallet.dart
Normal file
244
sdk/flutter/lib/src/wallet/synor_wallet.dart
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
/// Synor Wallet SDK client for Flutter/Dart.
|
||||||
|
library synor_wallet;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'types.dart';
|
||||||
|
|
||||||
|
export 'types.dart';
|
||||||
|
|
||||||
|
/// Synor Wallet SDK client for Flutter/Dart.
|
||||||
|
///
|
||||||
|
/// Provides key management, transaction signing, and balance queries
|
||||||
|
/// for the Synor blockchain.
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
/// ```dart
|
||||||
|
/// final wallet = SynorWallet(WalletConfig(apiKey: 'your-api-key'));
|
||||||
|
///
|
||||||
|
/// // Create a new wallet
|
||||||
|
/// final result = await wallet.createWallet(type: WalletType.standard);
|
||||||
|
/// print('Wallet ID: ${result.wallet.id}');
|
||||||
|
/// print('Mnemonic: ${result.mnemonic}');
|
||||||
|
///
|
||||||
|
/// // Get balance
|
||||||
|
/// final balance = await wallet.getBalance(result.wallet.addresses[0].address);
|
||||||
|
/// print('Balance: ${balance.total}');
|
||||||
|
///
|
||||||
|
/// wallet.close();
|
||||||
|
/// ```
|
||||||
|
class SynorWallet {
|
||||||
|
final WalletConfig config;
|
||||||
|
final http.Client _client;
|
||||||
|
|
||||||
|
SynorWallet(this.config) : _client = http.Client();
|
||||||
|
|
||||||
|
/// Create a new wallet.
|
||||||
|
Future<CreateWalletResult> createWallet({
|
||||||
|
WalletType type = WalletType.standard,
|
||||||
|
}) async {
|
||||||
|
final body = {
|
||||||
|
'type': type.toJson(),
|
||||||
|
'network': config.network.toJson(),
|
||||||
|
};
|
||||||
|
return _post('/wallets', body, CreateWalletResult.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import a wallet from a mnemonic phrase.
|
||||||
|
Future<Wallet> importWallet(String mnemonic, {String? passphrase}) async {
|
||||||
|
final body = {
|
||||||
|
'mnemonic': mnemonic,
|
||||||
|
'passphrase': passphrase,
|
||||||
|
'network': config.network.toJson(),
|
||||||
|
};
|
||||||
|
return _post('/wallets/import', body, Wallet.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a wallet by ID.
|
||||||
|
Future<Wallet> getWallet(String walletId) async {
|
||||||
|
return _get('/wallets/$walletId', Wallet.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a new address for a wallet.
|
||||||
|
Future<Address> generateAddress(String walletId, {bool isChange = false}) async {
|
||||||
|
final body = {'is_change': isChange};
|
||||||
|
return _post('/wallets/$walletId/addresses', body, Address.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a stealth address for privacy transactions.
|
||||||
|
Future<StealthAddress> getStealthAddress(String walletId) async {
|
||||||
|
return _get('/wallets/$walletId/stealth-address', StealthAddress.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign a transaction.
|
||||||
|
Future<SignedTransaction> signTransaction(
|
||||||
|
String walletId,
|
||||||
|
Transaction transaction,
|
||||||
|
) async {
|
||||||
|
final body = {
|
||||||
|
'wallet_id': walletId,
|
||||||
|
'transaction': transaction.toJson(),
|
||||||
|
};
|
||||||
|
return _post('/transactions/sign', body, SignedTransaction.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign a message with a wallet address.
|
||||||
|
Future<Signature> signMessage(
|
||||||
|
String walletId,
|
||||||
|
String message, {
|
||||||
|
int addressIndex = 0,
|
||||||
|
}) async {
|
||||||
|
final body = {
|
||||||
|
'wallet_id': walletId,
|
||||||
|
'message': message,
|
||||||
|
'address_index': addressIndex,
|
||||||
|
};
|
||||||
|
return _post('/messages/sign', body, Signature.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a message signature.
|
||||||
|
Future<bool> verifyMessage(
|
||||||
|
String message,
|
||||||
|
String signature,
|
||||||
|
String address,
|
||||||
|
) async {
|
||||||
|
final body = {
|
||||||
|
'message': message,
|
||||||
|
'signature': signature,
|
||||||
|
'address': address,
|
||||||
|
};
|
||||||
|
final response = await _post<Map<String, dynamic>>(
|
||||||
|
'/messages/verify',
|
||||||
|
body,
|
||||||
|
(json) => json,
|
||||||
|
);
|
||||||
|
return response['valid'] as bool? ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the balance for an address.
|
||||||
|
Future<Balance> getBalance(String address) async {
|
||||||
|
return _get('/addresses/$address/balance', Balance.fromJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get UTXOs for an address.
|
||||||
|
Future<List<UTXO>> getUTXOs(String address, {int minConfirmations = 1}) async {
|
||||||
|
return _getList(
|
||||||
|
'/addresses/$address/utxos?min_confirmations=$minConfirmations',
|
||||||
|
UTXO.fromJson,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estimate transaction fee.
|
||||||
|
Future<int> estimateFee({Priority priority = Priority.medium}) async {
|
||||||
|
final response = await _get<Map<String, dynamic>>(
|
||||||
|
'/fees/estimate?priority=${priority.toJson()}',
|
||||||
|
(json) => json,
|
||||||
|
);
|
||||||
|
return response['fee_per_byte'] as int? ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP helper methods
|
||||||
|
|
||||||
|
Future<T> _get<T>(String path, T Function(Map<String, dynamic>) fromJson) async {
|
||||||
|
return _executeWithRetry(() async {
|
||||||
|
final response = await _client
|
||||||
|
.get(
|
||||||
|
Uri.parse('${config.endpoint}$path'),
|
||||||
|
headers: _headers,
|
||||||
|
)
|
||||||
|
.timeout(config.timeout);
|
||||||
|
|
||||||
|
return _handleResponse(response, fromJson);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<T>> _getList<T>(
|
||||||
|
String path,
|
||||||
|
T Function(Map<String, dynamic>) fromJson,
|
||||||
|
) async {
|
||||||
|
return _executeWithRetry(() async {
|
||||||
|
final response = await _client
|
||||||
|
.get(
|
||||||
|
Uri.parse('${config.endpoint}$path'),
|
||||||
|
headers: _headers,
|
||||||
|
)
|
||||||
|
.timeout(config.timeout);
|
||||||
|
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
final List<dynamic> jsonList = jsonDecode(response.body);
|
||||||
|
return jsonList
|
||||||
|
.map((e) => fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw WalletException(
|
||||||
|
'HTTP error: ${response.body}',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<T> _post<T>(
|
||||||
|
String path,
|
||||||
|
Map<String, dynamic> body,
|
||||||
|
T Function(Map<String, dynamic>) fromJson,
|
||||||
|
) async {
|
||||||
|
return _executeWithRetry(() async {
|
||||||
|
final response = await _client
|
||||||
|
.post(
|
||||||
|
Uri.parse('${config.endpoint}$path'),
|
||||||
|
headers: _headers,
|
||||||
|
body: jsonEncode(body),
|
||||||
|
)
|
||||||
|
.timeout(config.timeout);
|
||||||
|
|
||||||
|
return _handleResponse(response, fromJson);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> get _headers => {
|
||||||
|
'Authorization': 'Bearer ${config.apiKey}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
T _handleResponse<T>(
|
||||||
|
http.Response response,
|
||||||
|
T Function(Map<String, dynamic>) fromJson,
|
||||||
|
) {
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw WalletException(
|
||||||
|
'HTTP error: ${response.body}',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<T> _executeWithRetry<T>(Future<T> Function() operation) async {
|
||||||
|
Exception? lastError;
|
||||||
|
|
||||||
|
for (var attempt = 0; attempt < config.retries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e as Exception;
|
||||||
|
if (config.debug) {
|
||||||
|
print('Attempt ${attempt + 1} failed: $e');
|
||||||
|
}
|
||||||
|
if (attempt < config.retries - 1) {
|
||||||
|
await Future.delayed(Duration(seconds: attempt + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError ?? WalletException('Unknown error after ${config.retries} retries');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the client and release resources.
|
||||||
|
void close() {
|
||||||
|
_client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
330
sdk/flutter/lib/src/wallet/types.dart
Normal file
330
sdk/flutter/lib/src/wallet/types.dart
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
/// Types for Synor Wallet SDK.
|
||||||
|
library synor_wallet_types;
|
||||||
|
|
||||||
|
/// Network environment for wallet operations.
|
||||||
|
enum Network {
|
||||||
|
mainnet,
|
||||||
|
testnet,
|
||||||
|
devnet;
|
||||||
|
|
||||||
|
String toJson() => name;
|
||||||
|
|
||||||
|
static Network fromJson(String json) {
|
||||||
|
return Network.values.firstWhere(
|
||||||
|
(e) => e.name == json,
|
||||||
|
orElse: () => Network.mainnet,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type of wallet to create.
|
||||||
|
enum WalletType {
|
||||||
|
standard,
|
||||||
|
multisig,
|
||||||
|
hardware,
|
||||||
|
stealth;
|
||||||
|
|
||||||
|
String toJson() => name;
|
||||||
|
|
||||||
|
static WalletType fromJson(String json) {
|
||||||
|
return WalletType.values.firstWhere(
|
||||||
|
(e) => e.name == json,
|
||||||
|
orElse: () => WalletType.standard,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transaction priority for fee estimation.
|
||||||
|
enum Priority {
|
||||||
|
low,
|
||||||
|
medium,
|
||||||
|
high;
|
||||||
|
|
||||||
|
String toJson() => name;
|
||||||
|
|
||||||
|
static Priority fromJson(String json) {
|
||||||
|
return Priority.values.firstWhere(
|
||||||
|
(e) => e.name == json,
|
||||||
|
orElse: () => Priority.medium,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configuration for the wallet client.
|
||||||
|
class WalletConfig {
|
||||||
|
final String apiKey;
|
||||||
|
final String endpoint;
|
||||||
|
final Network network;
|
||||||
|
final Duration timeout;
|
||||||
|
final int retries;
|
||||||
|
final bool debug;
|
||||||
|
|
||||||
|
const WalletConfig({
|
||||||
|
required this.apiKey,
|
||||||
|
this.endpoint = 'https://wallet.synor.io/v1',
|
||||||
|
this.network = Network.mainnet,
|
||||||
|
this.timeout = const Duration(seconds: 30),
|
||||||
|
this.retries = 3,
|
||||||
|
this.debug = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A wallet address.
|
||||||
|
class Address {
|
||||||
|
final String address;
|
||||||
|
final int index;
|
||||||
|
final bool isChange;
|
||||||
|
|
||||||
|
const Address({
|
||||||
|
required this.address,
|
||||||
|
required this.index,
|
||||||
|
this.isChange = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Address.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Address(
|
||||||
|
address: json['address'] as String,
|
||||||
|
index: json['index'] as int,
|
||||||
|
isChange: json['is_change'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'address': address,
|
||||||
|
'index': index,
|
||||||
|
'is_change': isChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Synor wallet with addresses and keys.
|
||||||
|
class Wallet {
|
||||||
|
final String id;
|
||||||
|
final WalletType type;
|
||||||
|
final Network network;
|
||||||
|
final List<Address> addresses;
|
||||||
|
final String createdAt;
|
||||||
|
|
||||||
|
const Wallet({
|
||||||
|
required this.id,
|
||||||
|
required this.type,
|
||||||
|
required this.network,
|
||||||
|
required this.addresses,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Wallet.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Wallet(
|
||||||
|
id: json['id'] as String,
|
||||||
|
type: WalletType.fromJson(json['type'] as String),
|
||||||
|
network: Network.fromJson(json['network'] as String),
|
||||||
|
addresses: (json['addresses'] as List)
|
||||||
|
.map((e) => Address.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
createdAt: json['created_at'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stealth address for privacy transactions.
|
||||||
|
class StealthAddress {
|
||||||
|
final String spendPublicKey;
|
||||||
|
final String viewPublicKey;
|
||||||
|
final String oneTimeAddress;
|
||||||
|
|
||||||
|
const StealthAddress({
|
||||||
|
required this.spendPublicKey,
|
||||||
|
required this.viewPublicKey,
|
||||||
|
required this.oneTimeAddress,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory StealthAddress.fromJson(Map<String, dynamic> json) {
|
||||||
|
return StealthAddress(
|
||||||
|
spendPublicKey: json['spend_public_key'] as String,
|
||||||
|
viewPublicKey: json['view_public_key'] as String,
|
||||||
|
oneTimeAddress: json['one_time_address'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of wallet creation.
|
||||||
|
class CreateWalletResult {
|
||||||
|
final Wallet wallet;
|
||||||
|
final String? mnemonic;
|
||||||
|
|
||||||
|
const CreateWalletResult({
|
||||||
|
required this.wallet,
|
||||||
|
this.mnemonic,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory CreateWalletResult.fromJson(Map<String, dynamic> json) {
|
||||||
|
return CreateWalletResult(
|
||||||
|
wallet: Wallet.fromJson(json['wallet'] as Map<String, dynamic>),
|
||||||
|
mnemonic: json['mnemonic'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transaction input (UTXO reference).
|
||||||
|
class TransactionInput {
|
||||||
|
final String txid;
|
||||||
|
final int vout;
|
||||||
|
final int amount;
|
||||||
|
|
||||||
|
const TransactionInput({
|
||||||
|
required this.txid,
|
||||||
|
required this.vout,
|
||||||
|
required this.amount,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'txid': txid,
|
||||||
|
'vout': vout,
|
||||||
|
'amount': amount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transaction output.
|
||||||
|
class TransactionOutput {
|
||||||
|
final String address;
|
||||||
|
final int amount;
|
||||||
|
|
||||||
|
const TransactionOutput({
|
||||||
|
required this.address,
|
||||||
|
required this.amount,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'address': address,
|
||||||
|
'amount': amount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A blockchain transaction for signing.
|
||||||
|
class Transaction {
|
||||||
|
final List<TransactionInput> inputs;
|
||||||
|
final List<TransactionOutput> outputs;
|
||||||
|
final int? fee;
|
||||||
|
final Priority priority;
|
||||||
|
|
||||||
|
const Transaction({
|
||||||
|
required this.inputs,
|
||||||
|
required this.outputs,
|
||||||
|
this.fee,
|
||||||
|
this.priority = Priority.medium,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'inputs': inputs.map((e) => e.toJson()).toList(),
|
||||||
|
'outputs': outputs.map((e) => e.toJson()).toList(),
|
||||||
|
if (fee != null) 'fee': fee,
|
||||||
|
'priority': priority.toJson(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A signed transaction ready for broadcast.
|
||||||
|
class SignedTransaction {
|
||||||
|
final String txid;
|
||||||
|
final String hex;
|
||||||
|
final int size;
|
||||||
|
final int fee;
|
||||||
|
|
||||||
|
const SignedTransaction({
|
||||||
|
required this.txid,
|
||||||
|
required this.hex,
|
||||||
|
required this.size,
|
||||||
|
required this.fee,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory SignedTransaction.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SignedTransaction(
|
||||||
|
txid: json['txid'] as String,
|
||||||
|
hex: json['hex'] as String,
|
||||||
|
size: json['size'] as int,
|
||||||
|
fee: json['fee'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cryptographic signature.
|
||||||
|
class Signature {
|
||||||
|
final String signature;
|
||||||
|
final String address;
|
||||||
|
final int? recoveryId;
|
||||||
|
|
||||||
|
const Signature({
|
||||||
|
required this.signature,
|
||||||
|
required this.address,
|
||||||
|
this.recoveryId,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Signature.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Signature(
|
||||||
|
signature: json['signature'] as String,
|
||||||
|
address: json['address'] as String,
|
||||||
|
recoveryId: json['recovery_id'] as int?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unspent transaction output.
|
||||||
|
class UTXO {
|
||||||
|
final String txid;
|
||||||
|
final int vout;
|
||||||
|
final int amount;
|
||||||
|
final String address;
|
||||||
|
final int confirmations;
|
||||||
|
final String? scriptPubKey;
|
||||||
|
|
||||||
|
const UTXO({
|
||||||
|
required this.txid,
|
||||||
|
required this.vout,
|
||||||
|
required this.amount,
|
||||||
|
required this.address,
|
||||||
|
required this.confirmations,
|
||||||
|
this.scriptPubKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory UTXO.fromJson(Map<String, dynamic> json) {
|
||||||
|
return UTXO(
|
||||||
|
txid: json['txid'] as String,
|
||||||
|
vout: json['vout'] as int,
|
||||||
|
amount: json['amount'] as int,
|
||||||
|
address: json['address'] as String,
|
||||||
|
confirmations: json['confirmations'] as int,
|
||||||
|
scriptPubKey: json['script_pubkey'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wallet balance information.
|
||||||
|
class Balance {
|
||||||
|
final int confirmed;
|
||||||
|
final int unconfirmed;
|
||||||
|
final int total;
|
||||||
|
|
||||||
|
const Balance({
|
||||||
|
required this.confirmed,
|
||||||
|
required this.unconfirmed,
|
||||||
|
required this.total,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Balance.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Balance(
|
||||||
|
confirmed: json['confirmed'] as int,
|
||||||
|
unconfirmed: json['unconfirmed'] as int,
|
||||||
|
total: json['total'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exception thrown by wallet operations.
|
||||||
|
class WalletException implements Exception {
|
||||||
|
final String message;
|
||||||
|
final String? code;
|
||||||
|
final int? statusCode;
|
||||||
|
|
||||||
|
const WalletException(this.message, {this.code, this.statusCode});
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'WalletException: $message';
|
||||||
|
}
|
||||||
60
sdk/flutter/lib/synor_sdk.dart
Normal file
60
sdk/flutter/lib/synor_sdk.dart
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
/// Synor SDK for Flutter/Dart
|
||||||
|
///
|
||||||
|
/// A comprehensive SDK for the Synor blockchain platform including:
|
||||||
|
/// - **Compute**: Distributed heterogeneous computing
|
||||||
|
/// - **Wallet**: Key management and transaction signing
|
||||||
|
/// - **RPC**: Blockchain queries and subscriptions
|
||||||
|
/// - **Storage**: IPFS-compatible decentralized storage
|
||||||
|
///
|
||||||
|
/// ## Quick Start
|
||||||
|
///
|
||||||
|
/// ```dart
|
||||||
|
/// import 'package:synor_sdk/synor_sdk.dart';
|
||||||
|
///
|
||||||
|
/// void main() async {
|
||||||
|
/// // Wallet operations
|
||||||
|
/// final wallet = SynorWallet(WalletConfig(apiKey: 'your-api-key'));
|
||||||
|
/// final result = await wallet.createWallet();
|
||||||
|
/// print('Wallet ID: ${result.wallet.id}');
|
||||||
|
///
|
||||||
|
/// // RPC queries
|
||||||
|
/// final rpc = SynorRpc(RpcConfig(apiKey: 'your-api-key'));
|
||||||
|
/// final block = await rpc.getLatestBlock();
|
||||||
|
/// print('Latest block: ${block.height}');
|
||||||
|
///
|
||||||
|
/// // Storage operations
|
||||||
|
/// final storage = SynorStorage(StorageConfig(apiKey: 'your-api-key'));
|
||||||
|
/// final data = utf8.encode('Hello, World!');
|
||||||
|
/// final upload = await storage.upload(Uint8List.fromList(data));
|
||||||
|
/// print('CID: ${upload.cid}');
|
||||||
|
///
|
||||||
|
/// // Clean up
|
||||||
|
/// wallet.close();
|
||||||
|
/// rpc.close();
|
||||||
|
/// storage.close();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ## Naming Conventions
|
||||||
|
///
|
||||||
|
/// Some types have service-specific prefixes to avoid conflicts:
|
||||||
|
/// - Wallet: `Network`, `WalletType`, `Priority`
|
||||||
|
/// - RPC: `RpcNetwork`, `RpcPriority`, `RpcTransaction`
|
||||||
|
/// - Storage: `PinStatus`, `HashAlgorithm`, `EntryType`
|
||||||
|
/// - Compute: `Precision`, `ProcessorType`, `Priority` (as `ComputePriority`)
|
||||||
|
library synor_sdk;
|
||||||
|
|
||||||
|
// Compute SDK - hide Priority to avoid conflict with Wallet
|
||||||
|
export 'synor_compute.dart' hide Priority;
|
||||||
|
|
||||||
|
// Wallet SDK
|
||||||
|
export 'src/wallet/synor_wallet.dart';
|
||||||
|
|
||||||
|
// RPC SDK
|
||||||
|
export 'src/rpc/synor_rpc.dart';
|
||||||
|
|
||||||
|
// Storage SDK
|
||||||
|
export 'src/storage/synor_storage.dart';
|
||||||
|
|
||||||
|
// Re-export Compute Priority with alias-friendly access
|
||||||
|
// Users can import compute directly for Priority: import 'package:synor_sdk/synor_compute.dart';
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
name: synor_compute
|
name: synor_sdk
|
||||||
description: Flutter/Dart SDK for Synor Compute - distributed heterogeneous computing platform
|
description: Flutter/Dart SDK for Synor - Compute, Wallet, RPC, and Storage
|
||||||
version: 0.1.0
|
version: 0.1.0
|
||||||
homepage: https://github.com/mrgulshanyadav/Blockchain.cc
|
homepage: https://github.com/mrgulshanyadav/Blockchain.cc
|
||||||
repository: https://github.com/mrgulshanyadav/Blockchain.cc/tree/main/sdk/flutter
|
repository: https://github.com/mrgulshanyadav/Blockchain.cc/tree/main/sdk/flutter
|
||||||
|
|
|
||||||
625
sdk/go/bridge/client.go
Normal file
625
sdk/go/bridge/client.go
Normal file
|
|
@ -0,0 +1,625 @@
|
||||||
|
// Package bridge provides cross-chain asset transfer functionality.
|
||||||
|
package bridge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is a Synor Bridge client for cross-chain transfers.
|
||||||
|
type Client struct {
|
||||||
|
config Config
|
||||||
|
httpClient *http.Client
|
||||||
|
closed atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new bridge client with the given configuration.
|
||||||
|
func NewClient(config Config) *Client {
|
||||||
|
if config.Endpoint == "" {
|
||||||
|
config.Endpoint = DefaultEndpoint
|
||||||
|
}
|
||||||
|
if config.Timeout == 0 {
|
||||||
|
config.Timeout = 60 * time.Second
|
||||||
|
}
|
||||||
|
if config.Retries == 0 {
|
||||||
|
config.Retries = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Client{
|
||||||
|
config: config,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: config.Timeout,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Chain Operations ====================
|
||||||
|
|
||||||
|
// GetSupportedChains returns all supported chains.
|
||||||
|
func (c *Client) GetSupportedChains(ctx context.Context) ([]Chain, error) {
|
||||||
|
var resp struct {
|
||||||
|
Chains []Chain `json:"chains"`
|
||||||
|
}
|
||||||
|
if err := c.request(ctx, "GET", "/chains", nil, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resp.Chains, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChain returns chain information by ID.
|
||||||
|
func (c *Client) GetChain(ctx context.Context, chainID ChainID) (*Chain, error) {
|
||||||
|
var chain Chain
|
||||||
|
if err := c.request(ctx, "GET", fmt.Sprintf("/chains/%s", chainID), nil, &chain); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &chain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsChainSupported checks if a chain is supported.
|
||||||
|
func (c *Client) IsChainSupported(ctx context.Context, chainID ChainID) bool {
|
||||||
|
chain, err := c.GetChain(ctx, chainID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return chain.Supported
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Asset Operations ====================
|
||||||
|
|
||||||
|
// GetSupportedAssets returns supported assets for a chain.
|
||||||
|
func (c *Client) GetSupportedAssets(ctx context.Context, chainID ChainID) ([]Asset, error) {
|
||||||
|
var resp struct {
|
||||||
|
Assets []Asset `json:"assets"`
|
||||||
|
}
|
||||||
|
if err := c.request(ctx, "GET", fmt.Sprintf("/chains/%s/assets", chainID), nil, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resp.Assets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAsset returns asset information by ID.
|
||||||
|
func (c *Client) GetAsset(ctx context.Context, assetID string) (*Asset, error) {
|
||||||
|
var asset Asset
|
||||||
|
if err := c.request(ctx, "GET", fmt.Sprintf("/assets/%s", assetID), nil, &asset); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &asset, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWrappedAsset returns the wrapped asset mapping.
|
||||||
|
func (c *Client) GetWrappedAsset(ctx context.Context, originalAssetID string, targetChain ChainID) (*WrappedAsset, error) {
|
||||||
|
var wrapped WrappedAsset
|
||||||
|
path := fmt.Sprintf("/assets/%s/wrapped/%s", originalAssetID, targetChain)
|
||||||
|
if err := c.request(ctx, "GET", path, nil, &wrapped); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &wrapped, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWrappedAssets returns all wrapped assets for a chain.
|
||||||
|
func (c *Client) GetWrappedAssets(ctx context.Context, chainID ChainID) ([]WrappedAsset, error) {
|
||||||
|
var resp struct {
|
||||||
|
Assets []WrappedAsset `json:"assets"`
|
||||||
|
}
|
||||||
|
if err := c.request(ctx, "GET", fmt.Sprintf("/chains/%s/wrapped", chainID), nil, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resp.Assets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Fee & Rate Operations ====================
|
||||||
|
|
||||||
|
// EstimateFee estimates the bridge fee for a transfer.
|
||||||
|
func (c *Client) EstimateFee(ctx context.Context, asset, amount string, sourceChain, targetChain ChainID) (*FeeEstimate, error) {
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"asset": asset,
|
||||||
|
"amount": amount,
|
||||||
|
"sourceChain": sourceChain,
|
||||||
|
"targetChain": targetChain,
|
||||||
|
}
|
||||||
|
var estimate FeeEstimate
|
||||||
|
if err := c.request(ctx, "POST", "/fees/estimate", body, &estimate); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &estimate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExchangeRate returns the exchange rate between two assets.
|
||||||
|
func (c *Client) GetExchangeRate(ctx context.Context, fromAsset, toAsset string) (*ExchangeRate, error) {
|
||||||
|
var rate ExchangeRate
|
||||||
|
path := fmt.Sprintf("/rates/%s/%s", url.PathEscape(fromAsset), url.PathEscape(toAsset))
|
||||||
|
if err := c.request(ctx, "GET", path, nil, &rate); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &rate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Lock-Mint Flow ====================
|
||||||
|
|
||||||
|
// Lock locks assets on the source chain for cross-chain transfer.
|
||||||
|
// This is step 1 of the lock-mint flow.
|
||||||
|
func (c *Client) Lock(ctx context.Context, asset, amount string, targetChain ChainID, opts *LockOptions) (*LockReceipt, error) {
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"asset": asset,
|
||||||
|
"amount": amount,
|
||||||
|
"targetChain": targetChain,
|
||||||
|
}
|
||||||
|
if opts != nil {
|
||||||
|
if opts.Recipient != "" {
|
||||||
|
body["recipient"] = opts.Recipient
|
||||||
|
}
|
||||||
|
if opts.Deadline != 0 {
|
||||||
|
body["deadline"] = opts.Deadline
|
||||||
|
}
|
||||||
|
if opts.Slippage != 0 {
|
||||||
|
body["slippage"] = opts.Slippage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var receipt LockReceipt
|
||||||
|
if err := c.request(ctx, "POST", "/transfers/lock", body, &receipt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &receipt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLockProof gets the lock proof for minting.
|
||||||
|
// This is step 2 of the lock-mint flow.
|
||||||
|
func (c *Client) GetLockProof(ctx context.Context, lockReceiptID string) (*LockProof, error) {
|
||||||
|
var proof LockProof
|
||||||
|
path := fmt.Sprintf("/transfers/lock/%s/proof", lockReceiptID)
|
||||||
|
if err := c.request(ctx, "GET", path, nil, &proof); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &proof, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitForLockProof waits for the lock proof to be ready.
|
||||||
|
func (c *Client) WaitForLockProof(ctx context.Context, lockReceiptID string, pollInterval, maxWait time.Duration) (*LockProof, error) {
|
||||||
|
if pollInterval == 0 {
|
||||||
|
pollInterval = 5 * time.Second
|
||||||
|
}
|
||||||
|
if maxWait == 0 {
|
||||||
|
maxWait = 10 * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(maxWait)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
proof, err := c.GetLockProof(ctx, lockReceiptID)
|
||||||
|
if err == nil {
|
||||||
|
return proof, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if bridgeErr, ok := err.(*Error); ok && bridgeErr.Code == "CONFIRMATIONS_PENDING" {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(pollInterval):
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, &Error{Message: "Timeout waiting for lock proof", Code: "CONFIRMATIONS_PENDING"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint mints wrapped tokens on the target chain.
|
||||||
|
// This is step 3 of the lock-mint flow.
|
||||||
|
func (c *Client) Mint(ctx context.Context, proof *LockProof, targetAddress string, opts *MintOptions) (*SignedTransaction, error) {
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"proof": proof,
|
||||||
|
"targetAddress": targetAddress,
|
||||||
|
}
|
||||||
|
if opts != nil {
|
||||||
|
if opts.GasLimit != "" {
|
||||||
|
body["gasLimit"] = opts.GasLimit
|
||||||
|
}
|
||||||
|
if opts.MaxFeePerGas != "" {
|
||||||
|
body["maxFeePerGas"] = opts.MaxFeePerGas
|
||||||
|
}
|
||||||
|
if opts.MaxPriorityFeePerGas != "" {
|
||||||
|
body["maxPriorityFeePerGas"] = opts.MaxPriorityFeePerGas
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tx SignedTransaction
|
||||||
|
if err := c.request(ctx, "POST", "/transfers/mint", body, &tx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &tx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Burn-Unlock Flow ====================
|
||||||
|
|
||||||
|
// Burn burns wrapped tokens on the current chain.
|
||||||
|
// This is step 1 of the burn-unlock flow.
|
||||||
|
func (c *Client) Burn(ctx context.Context, wrappedAsset, amount string, opts *BurnOptions) (*BurnReceipt, error) {
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"wrappedAsset": wrappedAsset,
|
||||||
|
"amount": amount,
|
||||||
|
}
|
||||||
|
if opts != nil {
|
||||||
|
if opts.Recipient != "" {
|
||||||
|
body["recipient"] = opts.Recipient
|
||||||
|
}
|
||||||
|
if opts.Deadline != 0 {
|
||||||
|
body["deadline"] = opts.Deadline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var receipt BurnReceipt
|
||||||
|
if err := c.request(ctx, "POST", "/transfers/burn", body, &receipt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &receipt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBurnProof gets the burn proof for unlocking.
|
||||||
|
// This is step 2 of the burn-unlock flow.
|
||||||
|
func (c *Client) GetBurnProof(ctx context.Context, burnReceiptID string) (*BurnProof, error) {
|
||||||
|
var proof BurnProof
|
||||||
|
path := fmt.Sprintf("/transfers/burn/%s/proof", burnReceiptID)
|
||||||
|
if err := c.request(ctx, "GET", path, nil, &proof); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &proof, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitForBurnProof waits for the burn proof to be ready.
|
||||||
|
func (c *Client) WaitForBurnProof(ctx context.Context, burnReceiptID string, pollInterval, maxWait time.Duration) (*BurnProof, error) {
|
||||||
|
if pollInterval == 0 {
|
||||||
|
pollInterval = 5 * time.Second
|
||||||
|
}
|
||||||
|
if maxWait == 0 {
|
||||||
|
maxWait = 10 * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(maxWait)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
proof, err := c.GetBurnProof(ctx, burnReceiptID)
|
||||||
|
if err == nil {
|
||||||
|
return proof, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if bridgeErr, ok := err.(*Error); ok && bridgeErr.Code == "CONFIRMATIONS_PENDING" {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(pollInterval):
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, &Error{Message: "Timeout waiting for burn proof", Code: "CONFIRMATIONS_PENDING"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock unlocks original tokens on the source chain.
|
||||||
|
// This is step 3 of the burn-unlock flow.
|
||||||
|
func (c *Client) Unlock(ctx context.Context, proof *BurnProof, opts *UnlockOptions) (*SignedTransaction, error) {
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"proof": proof,
|
||||||
|
}
|
||||||
|
if opts != nil {
|
||||||
|
if opts.GasLimit != "" {
|
||||||
|
body["gasLimit"] = opts.GasLimit
|
||||||
|
}
|
||||||
|
if opts.GasPrice != "" {
|
||||||
|
body["gasPrice"] = opts.GasPrice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tx SignedTransaction
|
||||||
|
if err := c.request(ctx, "POST", "/transfers/unlock", body, &tx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &tx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Transfer Management ====================
|
||||||
|
|
||||||
|
// GetTransfer returns a transfer by ID.
|
||||||
|
func (c *Client) GetTransfer(ctx context.Context, transferID string) (*Transfer, error) {
|
||||||
|
var transfer Transfer
|
||||||
|
if err := c.request(ctx, "GET", fmt.Sprintf("/transfers/%s", transferID), nil, &transfer); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &transfer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTransferStatus returns the status of a transfer.
|
||||||
|
func (c *Client) GetTransferStatus(ctx context.Context, transferID string) (TransferStatus, error) {
|
||||||
|
transfer, err := c.GetTransfer(ctx, transferID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return transfer.Status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTransfers returns transfers matching the filter.
|
||||||
|
func (c *Client) ListTransfers(ctx context.Context, filter *TransferFilter) ([]Transfer, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
if filter != nil {
|
||||||
|
if filter.Status != "" {
|
||||||
|
params.Set("status", string(filter.Status))
|
||||||
|
}
|
||||||
|
if filter.SourceChain != "" {
|
||||||
|
params.Set("sourceChain", string(filter.SourceChain))
|
||||||
|
}
|
||||||
|
if filter.TargetChain != "" {
|
||||||
|
params.Set("targetChain", string(filter.TargetChain))
|
||||||
|
}
|
||||||
|
if filter.Asset != "" {
|
||||||
|
params.Set("asset", filter.Asset)
|
||||||
|
}
|
||||||
|
if filter.Sender != "" {
|
||||||
|
params.Set("sender", filter.Sender)
|
||||||
|
}
|
||||||
|
if filter.Recipient != "" {
|
||||||
|
params.Set("recipient", filter.Recipient)
|
||||||
|
}
|
||||||
|
if filter.FromDate != nil {
|
||||||
|
params.Set("fromDate", strconv.FormatInt(filter.FromDate.Unix(), 10))
|
||||||
|
}
|
||||||
|
if filter.ToDate != nil {
|
||||||
|
params.Set("toDate", strconv.FormatInt(filter.ToDate.Unix(), 10))
|
||||||
|
}
|
||||||
|
if filter.Limit > 0 {
|
||||||
|
params.Set("limit", strconv.Itoa(filter.Limit))
|
||||||
|
}
|
||||||
|
if filter.Offset > 0 {
|
||||||
|
params.Set("offset", strconv.Itoa(filter.Offset))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path := "/transfers"
|
||||||
|
if len(params) > 0 {
|
||||||
|
path = "/transfers?" + params.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Transfers []Transfer `json:"transfers"`
|
||||||
|
}
|
||||||
|
if err := c.request(ctx, "GET", path, nil, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resp.Transfers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitForTransfer waits for a transfer to complete.
|
||||||
|
func (c *Client) WaitForTransfer(ctx context.Context, transferID string, pollInterval, maxWait time.Duration) (*Transfer, error) {
|
||||||
|
if pollInterval == 0 {
|
||||||
|
pollInterval = 10 * time.Second
|
||||||
|
}
|
||||||
|
if maxWait == 0 {
|
||||||
|
maxWait = 30 * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
finalStatuses := map[TransferStatus]bool{
|
||||||
|
TransferStatusCompleted: true,
|
||||||
|
TransferStatusFailed: true,
|
||||||
|
TransferStatusRefunded: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(maxWait)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
transfer, err := c.GetTransfer(ctx, transferID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if finalStatuses[transfer.Status] {
|
||||||
|
return transfer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-time.After(pollInterval):
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, &Error{Message: "Timeout waiting for transfer completion"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Convenience Methods ====================
|
||||||
|
|
||||||
|
// BridgeTo executes a complete lock-mint transfer.
|
||||||
|
func (c *Client) BridgeTo(ctx context.Context, asset, amount string, targetChain ChainID, targetAddress string, lockOpts *LockOptions, mintOpts *MintOptions) (*Transfer, error) {
|
||||||
|
// Lock on source chain
|
||||||
|
lockReceipt, err := c.Lock(ctx, asset, amount, targetChain, lockOpts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.config.Debug {
|
||||||
|
fmt.Printf("Locked: %s, waiting for confirmations...\n", lockReceipt.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for proof
|
||||||
|
proof, err := c.WaitForLockProof(ctx, lockReceipt.ID, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.config.Debug {
|
||||||
|
fmt.Printf("Proof ready, minting on %s...\n", targetChain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint on target chain
|
||||||
|
_, err = c.Mint(ctx, proof, targetAddress, mintOpts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return final transfer status
|
||||||
|
return c.WaitForTransfer(ctx, lockReceipt.ID, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BridgeBack executes a complete burn-unlock transfer.
|
||||||
|
func (c *Client) BridgeBack(ctx context.Context, wrappedAsset, amount string, burnOpts *BurnOptions, unlockOpts *UnlockOptions) (*Transfer, error) {
|
||||||
|
// Burn wrapped tokens
|
||||||
|
burnReceipt, err := c.Burn(ctx, wrappedAsset, amount, burnOpts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.config.Debug {
|
||||||
|
fmt.Printf("Burned: %s, waiting for confirmations...\n", burnReceipt.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for proof
|
||||||
|
proof, err := c.WaitForBurnProof(ctx, burnReceipt.ID, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.config.Debug {
|
||||||
|
fmt.Printf("Proof ready, unlocking on %s...\n", burnReceipt.TargetChain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock on original chain
|
||||||
|
_, err = c.Unlock(ctx, proof, unlockOpts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return final transfer status
|
||||||
|
return c.WaitForTransfer(ctx, burnReceipt.ID, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Lifecycle ====================
|
||||||
|
|
||||||
|
// Close closes the client.
|
||||||
|
func (c *Client) Close() {
|
||||||
|
c.closed.Store(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsClosed returns whether the client is closed.
|
||||||
|
func (c *Client) IsClosed() bool {
|
||||||
|
return c.closed.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthCheck performs a health check.
|
||||||
|
func (c *Client) HealthCheck(ctx context.Context) bool {
|
||||||
|
var resp struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
if err := c.request(ctx, "GET", "/health", nil, &resp); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return resp.Status == "healthy"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Private Methods ====================
|
||||||
|
|
||||||
|
func (c *Client) request(ctx context.Context, method, path string, body interface{}, result interface{}) error {
|
||||||
|
if c.IsClosed() {
|
||||||
|
return &Error{Message: "Client has been closed"}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < c.config.Retries; attempt++ {
|
||||||
|
err := c.doRequest(ctx, method, path, body, result)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.config.Debug {
|
||||||
|
fmt.Printf("Attempt %d failed: %v\n", attempt+1, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastErr = err
|
||||||
|
if attempt < c.config.Retries-1 {
|
||||||
|
time.Sleep(time.Duration(1<<attempt) * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doRequest(ctx context.Context, method, path string, body interface{}, result interface{}) error {
|
||||||
|
url := c.config.Endpoint + path
|
||||||
|
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
jsonBody, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
bodyReader = bytes.NewReader(jsonBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-SDK-Version", "go/0.1.0")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
var errorResp struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(respBody, &errorResp)
|
||||||
|
|
||||||
|
message := errorResp.Message
|
||||||
|
if message == "" {
|
||||||
|
message = errorResp.Error
|
||||||
|
}
|
||||||
|
if message == "" {
|
||||||
|
message = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Error{
|
||||||
|
Message: message,
|
||||||
|
Code: errorResp.Code,
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil {
|
||||||
|
if err := json.Unmarshal(respBody, result); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error represents a bridge error.
|
||||||
|
type Error struct {
|
||||||
|
Message string
|
||||||
|
Code string
|
||||||
|
StatusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Error() string {
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
271
sdk/go/bridge/types.go
Normal file
271
sdk/go/bridge/types.go
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
// Package bridge provides cross-chain asset transfer functionality.
|
||||||
|
//
|
||||||
|
// The Bridge SDK enables transferring assets between Synor and other blockchain
|
||||||
|
// networks using a lock-mint and burn-unlock pattern.
|
||||||
|
package bridge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChainID represents a supported blockchain network.
|
||||||
|
type ChainID string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ChainSynor ChainID = "synor"
|
||||||
|
ChainEthereum ChainID = "ethereum"
|
||||||
|
ChainPolygon ChainID = "polygon"
|
||||||
|
ChainArbitrum ChainID = "arbitrum"
|
||||||
|
ChainOptimism ChainID = "optimism"
|
||||||
|
ChainBSC ChainID = "bsc"
|
||||||
|
ChainAvalanche ChainID = "avalanche"
|
||||||
|
ChainSolana ChainID = "solana"
|
||||||
|
ChainCosmos ChainID = "cosmos"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NativeCurrency represents a chain's native currency.
|
||||||
|
type NativeCurrency struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
Decimals int `json:"decimals"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chain represents a supported blockchain network.
|
||||||
|
type Chain struct {
|
||||||
|
ID ChainID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ChainID int64 `json:"chainId"`
|
||||||
|
RPCURL string `json:"rpcUrl"`
|
||||||
|
ExplorerURL string `json:"explorerUrl"`
|
||||||
|
NativeCurrency NativeCurrency `json:"nativeCurrency"`
|
||||||
|
Confirmations int `json:"confirmations"`
|
||||||
|
EstimatedBlockTime int `json:"estimatedBlockTime"` // seconds
|
||||||
|
Supported bool `json:"supported"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetType represents the type of asset.
|
||||||
|
type AssetType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AssetTypeNative AssetType = "native"
|
||||||
|
AssetTypeERC20 AssetType = "erc20"
|
||||||
|
AssetTypeERC721 AssetType = "erc721"
|
||||||
|
AssetTypeERC1155 AssetType = "erc1155"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Asset represents a blockchain asset.
|
||||||
|
type Asset struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type AssetType `json:"type"`
|
||||||
|
Chain ChainID `json:"chain"`
|
||||||
|
ContractAddress string `json:"contractAddress,omitempty"`
|
||||||
|
Decimals int `json:"decimals"`
|
||||||
|
LogoURL string `json:"logoUrl,omitempty"`
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WrappedAsset represents a mapping between original and wrapped assets.
|
||||||
|
type WrappedAsset struct {
|
||||||
|
OriginalAsset Asset `json:"originalAsset"`
|
||||||
|
WrappedAsset Asset `json:"wrappedAsset"`
|
||||||
|
Chain ChainID `json:"chain"`
|
||||||
|
BridgeContract string `json:"bridgeContract"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransferStatus represents the status of a bridge transfer.
|
||||||
|
type TransferStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TransferStatusPending TransferStatus = "pending"
|
||||||
|
TransferStatusLocked TransferStatus = "locked"
|
||||||
|
TransferStatusConfirming TransferStatus = "confirming"
|
||||||
|
TransferStatusMinting TransferStatus = "minting"
|
||||||
|
TransferStatusCompleted TransferStatus = "completed"
|
||||||
|
TransferStatusFailed TransferStatus = "failed"
|
||||||
|
TransferStatusRefunded TransferStatus = "refunded"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TransferDirection represents the direction of a bridge transfer.
|
||||||
|
type TransferDirection string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TransferDirectionLockMint TransferDirection = "lock_mint"
|
||||||
|
TransferDirectionBurnUnlock TransferDirection = "burn_unlock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidatorSignature represents a validator's signature for proof.
|
||||||
|
type ValidatorSignature struct {
|
||||||
|
Validator string `json:"validator"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LockReceipt represents a lock receipt from the source chain.
|
||||||
|
type LockReceipt struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TxHash string `json:"txHash"`
|
||||||
|
SourceChain ChainID `json:"sourceChain"`
|
||||||
|
TargetChain ChainID `json:"targetChain"`
|
||||||
|
Asset Asset `json:"asset"`
|
||||||
|
Amount string `json:"amount"`
|
||||||
|
Sender string `json:"sender"`
|
||||||
|
Recipient string `json:"recipient"`
|
||||||
|
LockTimestamp int64 `json:"lockTimestamp"`
|
||||||
|
Confirmations int `json:"confirmations"`
|
||||||
|
RequiredConfirmations int `json:"requiredConfirmations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LockProof represents proof for minting on the target chain.
|
||||||
|
type LockProof struct {
|
||||||
|
LockReceipt LockReceipt `json:"lockReceipt"`
|
||||||
|
MerkleProof []string `json:"merkleProof"`
|
||||||
|
BlockHeader string `json:"blockHeader"`
|
||||||
|
Signatures []ValidatorSignature `json:"signatures"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BurnReceipt represents a burn receipt for unlocking.
|
||||||
|
type BurnReceipt struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TxHash string `json:"txHash"`
|
||||||
|
SourceChain ChainID `json:"sourceChain"`
|
||||||
|
TargetChain ChainID `json:"targetChain"`
|
||||||
|
WrappedAsset Asset `json:"wrappedAsset"`
|
||||||
|
OriginalAsset Asset `json:"originalAsset"`
|
||||||
|
Amount string `json:"amount"`
|
||||||
|
Sender string `json:"sender"`
|
||||||
|
Recipient string `json:"recipient"`
|
||||||
|
BurnTimestamp int64 `json:"burnTimestamp"`
|
||||||
|
Confirmations int `json:"confirmations"`
|
||||||
|
RequiredConfirmations int `json:"requiredConfirmations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BurnProof represents proof for unlocking on the original chain.
|
||||||
|
type BurnProof struct {
|
||||||
|
BurnReceipt BurnReceipt `json:"burnReceipt"`
|
||||||
|
MerkleProof []string `json:"merkleProof"`
|
||||||
|
BlockHeader string `json:"blockHeader"`
|
||||||
|
Signatures []ValidatorSignature `json:"signatures"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer represents a complete bridge transfer record.
|
||||||
|
type Transfer struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Direction TransferDirection `json:"direction"`
|
||||||
|
Status TransferStatus `json:"status"`
|
||||||
|
SourceChain ChainID `json:"sourceChain"`
|
||||||
|
TargetChain ChainID `json:"targetChain"`
|
||||||
|
Asset Asset `json:"asset"`
|
||||||
|
Amount string `json:"amount"`
|
||||||
|
Sender string `json:"sender"`
|
||||||
|
Recipient string `json:"recipient"`
|
||||||
|
SourceTxHash string `json:"sourceTxHash,omitempty"`
|
||||||
|
TargetTxHash string `json:"targetTxHash,omitempty"`
|
||||||
|
Fee string `json:"fee"`
|
||||||
|
FeeAsset Asset `json:"feeAsset"`
|
||||||
|
CreatedAt int64 `json:"createdAt"`
|
||||||
|
UpdatedAt int64 `json:"updatedAt"`
|
||||||
|
CompletedAt int64 `json:"completedAt,omitempty"`
|
||||||
|
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeeEstimate represents a fee estimate for a bridge transfer.
|
||||||
|
type FeeEstimate struct {
|
||||||
|
BridgeFee string `json:"bridgeFee"`
|
||||||
|
GasFeeSource string `json:"gasFeeSource"`
|
||||||
|
GasFeeTarget string `json:"gasFeeTarget"`
|
||||||
|
TotalFee string `json:"totalFee"`
|
||||||
|
FeeAsset Asset `json:"feeAsset"`
|
||||||
|
EstimatedTime int `json:"estimatedTime"` // seconds
|
||||||
|
ExchangeRate string `json:"exchangeRate,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExchangeRate represents an exchange rate between assets.
|
||||||
|
type ExchangeRate struct {
|
||||||
|
FromAsset Asset `json:"fromAsset"`
|
||||||
|
ToAsset Asset `json:"toAsset"`
|
||||||
|
Rate string `json:"rate"`
|
||||||
|
InverseRate string `json:"inverseRate"`
|
||||||
|
LastUpdated int64 `json:"lastUpdated"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignedTransaction represents a signed transaction result.
|
||||||
|
type SignedTransaction struct {
|
||||||
|
TxHash string `json:"txHash"`
|
||||||
|
Chain ChainID `json:"chain"`
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
GasLimit string `json:"gasLimit"`
|
||||||
|
GasPrice string `json:"gasPrice,omitempty"`
|
||||||
|
MaxFeePerGas string `json:"maxFeePerGas,omitempty"`
|
||||||
|
MaxPriorityFeePerGas string `json:"maxPriorityFeePerGas,omitempty"`
|
||||||
|
Nonce int `json:"nonce"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransferFilter represents filters for listing transfers.
|
||||||
|
type TransferFilter struct {
|
||||||
|
Status TransferStatus
|
||||||
|
SourceChain ChainID
|
||||||
|
TargetChain ChainID
|
||||||
|
Asset string
|
||||||
|
Sender string
|
||||||
|
Recipient string
|
||||||
|
FromDate *time.Time
|
||||||
|
ToDate *time.Time
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
// LockOptions represents options for the lock operation.
|
||||||
|
type LockOptions struct {
|
||||||
|
Recipient string
|
||||||
|
Deadline int64
|
||||||
|
Slippage float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// MintOptions represents options for the mint operation.
|
||||||
|
type MintOptions struct {
|
||||||
|
GasLimit string
|
||||||
|
MaxFeePerGas string
|
||||||
|
MaxPriorityFeePerGas string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BurnOptions represents options for the burn operation.
|
||||||
|
type BurnOptions struct {
|
||||||
|
Recipient string
|
||||||
|
Deadline int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnlockOptions represents options for the unlock operation.
|
||||||
|
type UnlockOptions struct {
|
||||||
|
GasLimit string
|
||||||
|
GasPrice string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config represents the bridge client configuration.
|
||||||
|
type Config struct {
|
||||||
|
APIKey string
|
||||||
|
Endpoint string
|
||||||
|
Timeout time.Duration
|
||||||
|
Retries int
|
||||||
|
Debug bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultEndpoint is the default bridge API endpoint.
|
||||||
|
const DefaultEndpoint = "https://bridge.synor.io/v1"
|
||||||
|
|
||||||
|
// DefaultConfig returns a configuration with default values.
|
||||||
|
func DefaultConfig(apiKey string) Config {
|
||||||
|
return Config{
|
||||||
|
APIKey: apiKey,
|
||||||
|
Endpoint: DefaultEndpoint,
|
||||||
|
Timeout: 60 * time.Second,
|
||||||
|
Retries: 3,
|
||||||
|
Debug: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
658
sdk/go/database/client.go
Normal file
658
sdk/go/database/client.go
Normal file
|
|
@ -0,0 +1,658 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is the Synor Database SDK client
|
||||||
|
type Client struct {
|
||||||
|
config Config
|
||||||
|
httpClient *http.Client
|
||||||
|
closed bool
|
||||||
|
|
||||||
|
// Stores
|
||||||
|
KV *KeyValueStore
|
||||||
|
Documents *DocumentStore
|
||||||
|
Vectors *VectorStore
|
||||||
|
TimeSeries *TimeSeriesStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Database client
|
||||||
|
func New(config Config) *Client {
|
||||||
|
if config.Endpoint == "" {
|
||||||
|
config.Endpoint = "https://database.synor.io/v1"
|
||||||
|
}
|
||||||
|
if config.Timeout == 0 {
|
||||||
|
config.Timeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
if config.Retries == 0 {
|
||||||
|
config.Retries = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &Client{
|
||||||
|
config: config,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: config.Timeout,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
client.KV = &KeyValueStore{client: client}
|
||||||
|
client.Documents = &DocumentStore{client: client}
|
||||||
|
client.Vectors = &VectorStore{client: client}
|
||||||
|
client.TimeSeries = &TimeSeriesStore{client: client}
|
||||||
|
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats returns database statistics
|
||||||
|
func (c *Client) GetStats(ctx context.Context) (*DatabaseStats, error) {
|
||||||
|
var stats DatabaseStats
|
||||||
|
err := c.request(ctx, "GET", "/stats", nil, &stats)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthCheck performs a health check
|
||||||
|
func (c *Client) HealthCheck(ctx context.Context) bool {
|
||||||
|
var result struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
err := c.request(ctx, "GET", "/health", nil, &result)
|
||||||
|
return err == nil && result.Status == "healthy"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the client
|
||||||
|
func (c *Client) Close() {
|
||||||
|
c.closed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsClosed returns whether the client is closed
|
||||||
|
func (c *Client) IsClosed() bool {
|
||||||
|
return c.closed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) request(ctx context.Context, method, path string, body interface{}, result interface{}) error {
|
||||||
|
if c.closed {
|
||||||
|
return NewDatabaseError("Client has been closed", "CLIENT_CLOSED", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for attempt := 0; attempt < c.config.Retries; attempt++ {
|
||||||
|
err := c.doRequest(ctx, method, path, body, result)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lastErr = err
|
||||||
|
if c.config.Debug {
|
||||||
|
fmt.Printf("Attempt %d failed: %v\n", attempt+1, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if attempt < c.config.Retries-1 {
|
||||||
|
time.Sleep(time.Duration(attempt+1) * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doRequest(ctx context.Context, method, path string, body interface{}, result interface{}) error {
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
jsonBody, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
bodyReader = bytes.NewReader(jsonBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, c.config.Endpoint+path, bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-SDK-Version", "go/0.1.0")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
var errResp struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(respBody, &errResp)
|
||||||
|
|
||||||
|
msg := errResp.Message
|
||||||
|
if msg == "" {
|
||||||
|
msg = errResp.Error
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewDatabaseError(msg, errResp.Code, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil && len(respBody) > 0 {
|
||||||
|
return json.Unmarshal(respBody, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Key-Value Store ====================
|
||||||
|
|
||||||
|
// KeyValueStore provides key-value operations
|
||||||
|
type KeyValueStore struct {
|
||||||
|
client *Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a value by key
|
||||||
|
func (kv *KeyValueStore) Get(ctx context.Context, key string) (interface{}, error) {
|
||||||
|
var result struct {
|
||||||
|
Value interface{} `json:"value"`
|
||||||
|
}
|
||||||
|
err := kv.client.request(ctx, "GET", "/kv/"+url.PathEscape(key), nil, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result.Value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEntry retrieves a full entry with metadata
|
||||||
|
func (kv *KeyValueStore) GetEntry(ctx context.Context, key string) (*KeyValueEntry, error) {
|
||||||
|
var entry KeyValueEntry
|
||||||
|
err := kv.client.request(ctx, "GET", "/kv/"+url.PathEscape(key)+"/entry", nil, &entry)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set sets a value
|
||||||
|
func (kv *KeyValueStore) Set(ctx context.Context, key string, value interface{}, opts *SetOptions) error {
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"value": value,
|
||||||
|
}
|
||||||
|
if opts != nil {
|
||||||
|
if opts.TTL != nil {
|
||||||
|
body["ttl"] = *opts.TTL
|
||||||
|
}
|
||||||
|
if opts.Metadata != nil {
|
||||||
|
body["metadata"] = opts.Metadata
|
||||||
|
}
|
||||||
|
if opts.IfNotExists {
|
||||||
|
body["if_not_exists"] = true
|
||||||
|
}
|
||||||
|
if opts.IfExists {
|
||||||
|
body["if_exists"] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kv.client.request(ctx, "PUT", "/kv/"+url.PathEscape(key), body, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes a key
|
||||||
|
func (kv *KeyValueStore) Delete(ctx context.Context, key string) (bool, error) {
|
||||||
|
var result struct {
|
||||||
|
Deleted bool `json:"deleted"`
|
||||||
|
}
|
||||||
|
err := kv.client.request(ctx, "DELETE", "/kv/"+url.PathEscape(key), nil, &result)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return result.Deleted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exists checks if a key exists
|
||||||
|
func (kv *KeyValueStore) Exists(ctx context.Context, key string) (bool, error) {
|
||||||
|
var result struct {
|
||||||
|
Exists bool `json:"exists"`
|
||||||
|
}
|
||||||
|
err := kv.client.request(ctx, "GET", "/kv/"+url.PathEscape(key)+"/exists", nil, &result)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return result.Exists, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List lists keys with optional prefix filtering
|
||||||
|
func (kv *KeyValueStore) List(ctx context.Context, opts *ListOptions) (*ListResult, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
if opts != nil {
|
||||||
|
if opts.Prefix != "" {
|
||||||
|
params.Set("prefix", opts.Prefix)
|
||||||
|
}
|
||||||
|
if opts.Cursor != "" {
|
||||||
|
params.Set("cursor", opts.Cursor)
|
||||||
|
}
|
||||||
|
if opts.Limit > 0 {
|
||||||
|
params.Set("limit", strconv.Itoa(opts.Limit))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path := "/kv"
|
||||||
|
if len(params) > 0 {
|
||||||
|
path += "?" + params.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
var result ListResult
|
||||||
|
err := kv.client.request(ctx, "GET", path, nil, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MGet retrieves multiple values at once
|
||||||
|
func (kv *KeyValueStore) MGet(ctx context.Context, keys []string) (map[string]interface{}, error) {
|
||||||
|
var result struct {
|
||||||
|
Entries []struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value interface{} `json:"value"`
|
||||||
|
} `json:"entries"`
|
||||||
|
}
|
||||||
|
err := kv.client.request(ctx, "POST", "/kv/mget", map[string]interface{}{"keys": keys}, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
values := make(map[string]interface{})
|
||||||
|
for _, e := range result.Entries {
|
||||||
|
values[e.Key] = e.Value
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MSet sets multiple values at once
|
||||||
|
func (kv *KeyValueStore) MSet(ctx context.Context, entries map[string]interface{}) error {
|
||||||
|
entriesList := make([]map[string]interface{}, 0, len(entries))
|
||||||
|
for k, v := range entries {
|
||||||
|
entriesList = append(entriesList, map[string]interface{}{"key": k, "value": v})
|
||||||
|
}
|
||||||
|
return kv.client.request(ctx, "POST", "/kv/mset", map[string]interface{}{"entries": entriesList}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incr increments a numeric value
|
||||||
|
func (kv *KeyValueStore) Incr(ctx context.Context, key string, by int64) (int64, error) {
|
||||||
|
var result struct {
|
||||||
|
Value int64 `json:"value"`
|
||||||
|
}
|
||||||
|
err := kv.client.request(ctx, "POST", "/kv/"+url.PathEscape(key)+"/incr", map[string]interface{}{"by": by}, &result)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.Value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Document Store ====================
|
||||||
|
|
||||||
|
// DocumentStore provides document operations
|
||||||
|
type DocumentStore struct {
|
||||||
|
client *Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a new document
|
||||||
|
func (ds *DocumentStore) Create(ctx context.Context, collection string, data map[string]interface{}, opts *CreateDocumentOptions) (*Document, error) {
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"data": data,
|
||||||
|
}
|
||||||
|
if opts != nil {
|
||||||
|
if opts.ID != "" {
|
||||||
|
body["id"] = opts.ID
|
||||||
|
}
|
||||||
|
if opts.Metadata != nil {
|
||||||
|
body["metadata"] = opts.Metadata
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var doc Document
|
||||||
|
err := ds.client.request(ctx, "POST", "/documents/"+url.PathEscape(collection), body, &doc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &doc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a document by ID
|
||||||
|
func (ds *DocumentStore) Get(ctx context.Context, collection, id string) (*Document, error) {
|
||||||
|
var doc Document
|
||||||
|
err := ds.client.request(ctx, "GET", "/documents/"+url.PathEscape(collection)+"/"+url.PathEscape(id), nil, &doc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &doc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates a document
|
||||||
|
func (ds *DocumentStore) Update(ctx context.Context, collection, id string, update map[string]interface{}, opts *UpdateDocumentOptions) (*Document, error) {
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"update": update,
|
||||||
|
}
|
||||||
|
if opts != nil {
|
||||||
|
if opts.Upsert {
|
||||||
|
body["upsert"] = true
|
||||||
|
}
|
||||||
|
if opts.ReturnDocument != "" {
|
||||||
|
body["return_document"] = opts.ReturnDocument
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var doc Document
|
||||||
|
err := ds.client.request(ctx, "PATCH", "/documents/"+url.PathEscape(collection)+"/"+url.PathEscape(id), body, &doc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &doc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes a document
|
||||||
|
func (ds *DocumentStore) Delete(ctx context.Context, collection, id string) (bool, error) {
|
||||||
|
var result struct {
|
||||||
|
Deleted bool `json:"deleted"`
|
||||||
|
}
|
||||||
|
err := ds.client.request(ctx, "DELETE", "/documents/"+url.PathEscape(collection)+"/"+url.PathEscape(id), nil, &result)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return result.Deleted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query queries documents
|
||||||
|
func (ds *DocumentStore) Query(ctx context.Context, collection string, opts *QueryOptions) (*QueryResult, error) {
|
||||||
|
body := make(map[string]interface{})
|
||||||
|
if opts != nil {
|
||||||
|
if opts.Filter != nil {
|
||||||
|
body["filter"] = opts.Filter
|
||||||
|
}
|
||||||
|
if opts.Sort != nil {
|
||||||
|
body["sort"] = opts.Sort
|
||||||
|
}
|
||||||
|
if opts.Skip > 0 {
|
||||||
|
body["skip"] = opts.Skip
|
||||||
|
}
|
||||||
|
if opts.Limit > 0 {
|
||||||
|
body["limit"] = opts.Limit
|
||||||
|
}
|
||||||
|
if opts.Projection != nil {
|
||||||
|
body["projection"] = opts.Projection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result QueryResult
|
||||||
|
err := ds.client.request(ctx, "POST", "/documents/"+url.PathEscape(collection)+"/query", body, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count counts documents matching a filter
|
||||||
|
func (ds *DocumentStore) Count(ctx context.Context, collection string, filter QueryFilter) (int, error) {
|
||||||
|
var result struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
err := ds.client.request(ctx, "POST", "/documents/"+url.PathEscape(collection)+"/count", map[string]interface{}{"filter": filter}, &result)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.Count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate runs an aggregation pipeline
|
||||||
|
func (ds *DocumentStore) Aggregate(ctx context.Context, collection string, pipeline []AggregateStage) ([]interface{}, error) {
|
||||||
|
var result struct {
|
||||||
|
Results []interface{} `json:"results"`
|
||||||
|
}
|
||||||
|
err := ds.client.request(ctx, "POST", "/documents/"+url.PathEscape(collection)+"/aggregate", map[string]interface{}{"pipeline": pipeline}, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result.Results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Vector Store ====================
|
||||||
|
|
||||||
|
// VectorStore provides vector operations
|
||||||
|
type VectorStore struct {
|
||||||
|
client *Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCollection creates a vector collection
|
||||||
|
func (vs *VectorStore) CreateCollection(ctx context.Context, config VectorCollectionConfig) error {
|
||||||
|
return vs.client.request(ctx, "POST", "/vectors/collections", config, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCollection deletes a vector collection
|
||||||
|
func (vs *VectorStore) DeleteCollection(ctx context.Context, name string) error {
|
||||||
|
return vs.client.request(ctx, "DELETE", "/vectors/collections/"+url.PathEscape(name), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCollectionStats gets collection statistics
|
||||||
|
func (vs *VectorStore) GetCollectionStats(ctx context.Context, name string) (*VectorCollectionStats, error) {
|
||||||
|
var stats VectorCollectionStats
|
||||||
|
err := vs.client.request(ctx, "GET", "/vectors/collections/"+url.PathEscape(name)+"/stats", nil, &stats)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert upserts vectors
|
||||||
|
func (vs *VectorStore) Upsert(ctx context.Context, collection string, vectors []VectorEntry, opts *UpsertVectorOptions) (int, error) {
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"vectors": vectors,
|
||||||
|
}
|
||||||
|
if opts != nil && opts.Namespace != "" {
|
||||||
|
body["namespace"] = opts.Namespace
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Upserted int `json:"upserted"`
|
||||||
|
}
|
||||||
|
err := vs.client.request(ctx, "POST", "/vectors/"+url.PathEscape(collection)+"/upsert", body, &result)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.Upserted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search searches for similar vectors
|
||||||
|
func (vs *VectorStore) Search(ctx context.Context, collection string, vector []float64, opts *SearchOptions) ([]SearchResult, error) {
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"vector": vector,
|
||||||
|
}
|
||||||
|
if opts != nil {
|
||||||
|
if opts.Namespace != "" {
|
||||||
|
body["namespace"] = opts.Namespace
|
||||||
|
}
|
||||||
|
if opts.TopK > 0 {
|
||||||
|
body["top_k"] = opts.TopK
|
||||||
|
}
|
||||||
|
if opts.Filter != nil {
|
||||||
|
body["filter"] = opts.Filter
|
||||||
|
}
|
||||||
|
body["include_metadata"] = opts.IncludeMetadata
|
||||||
|
body["include_vectors"] = opts.IncludeVectors
|
||||||
|
if opts.MinScore != nil {
|
||||||
|
body["min_score"] = *opts.MinScore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Results []SearchResult `json:"results"`
|
||||||
|
}
|
||||||
|
err := vs.client.request(ctx, "POST", "/vectors/"+url.PathEscape(collection)+"/search", body, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result.Results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes vectors by ID
|
||||||
|
func (vs *VectorStore) Delete(ctx context.Context, collection string, ids []string, namespace string) (int, error) {
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"ids": ids,
|
||||||
|
}
|
||||||
|
if namespace != "" {
|
||||||
|
body["namespace"] = namespace
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Deleted int `json:"deleted"`
|
||||||
|
}
|
||||||
|
err := vs.client.request(ctx, "POST", "/vectors/"+url.PathEscape(collection)+"/delete", body, &result)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.Deleted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fetches vectors by ID
|
||||||
|
func (vs *VectorStore) Fetch(ctx context.Context, collection string, ids []string, namespace string) ([]VectorEntry, error) {
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"ids": ids,
|
||||||
|
}
|
||||||
|
if namespace != "" {
|
||||||
|
body["namespace"] = namespace
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Vectors []VectorEntry `json:"vectors"`
|
||||||
|
}
|
||||||
|
err := vs.client.request(ctx, "POST", "/vectors/"+url.PathEscape(collection)+"/fetch", body, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result.Vectors, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Time Series Store ====================
|
||||||
|
|
||||||
|
// TimeSeriesStore provides time series operations
|
||||||
|
type TimeSeriesStore struct {
|
||||||
|
client *Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write writes data points to a series
|
||||||
|
func (ts *TimeSeriesStore) Write(ctx context.Context, series string, points []DataPoint, opts *WritePointsOptions) (int, error) {
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"points": points,
|
||||||
|
}
|
||||||
|
if opts != nil && opts.Precision != "" {
|
||||||
|
body["precision"] = opts.Precision
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Written int `json:"written"`
|
||||||
|
}
|
||||||
|
err := ts.client.request(ctx, "POST", "/timeseries/"+url.PathEscape(series)+"/write", body, &result)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.Written, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query queries a time series
|
||||||
|
func (ts *TimeSeriesStore) Query(ctx context.Context, series string, timeRange TimeRange, opts *TimeSeriesQueryOptions) (*TimeSeriesResult, error) {
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"range": timeRange,
|
||||||
|
}
|
||||||
|
if opts != nil {
|
||||||
|
if opts.Tags != nil {
|
||||||
|
body["tags"] = opts.Tags
|
||||||
|
}
|
||||||
|
if opts.Aggregation != "" {
|
||||||
|
body["aggregation"] = opts.Aggregation
|
||||||
|
}
|
||||||
|
if opts.Interval != "" {
|
||||||
|
body["interval"] = opts.Interval
|
||||||
|
}
|
||||||
|
if opts.Fill != nil {
|
||||||
|
body["fill"] = opts.Fill
|
||||||
|
}
|
||||||
|
if opts.Limit > 0 {
|
||||||
|
body["limit"] = opts.Limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result TimeSeriesResult
|
||||||
|
err := ts.client.request(ctx, "POST", "/timeseries/"+url.PathEscape(series)+"/query", body, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete deletes data points in a range
|
||||||
|
func (ts *TimeSeriesStore) Delete(ctx context.Context, series string, timeRange TimeRange, tags map[string]string) (int, error) {
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"range": timeRange,
|
||||||
|
}
|
||||||
|
if tags != nil {
|
||||||
|
body["tags"] = tags
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Deleted int `json:"deleted"`
|
||||||
|
}
|
||||||
|
err := ts.client.request(ctx, "POST", "/timeseries/"+url.PathEscape(series)+"/delete", body, &result)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.Deleted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSeriesInfo gets series information
|
||||||
|
func (ts *TimeSeriesStore) GetSeriesInfo(ctx context.Context, series string) (*SeriesInfo, error) {
|
||||||
|
var info SeriesInfo
|
||||||
|
err := ts.client.request(ctx, "GET", "/timeseries/"+url.PathEscape(series)+"/info", nil, &info)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSeries lists all series
|
||||||
|
func (ts *TimeSeriesStore) ListSeries(ctx context.Context, prefix string) ([]SeriesInfo, error) {
|
||||||
|
path := "/timeseries"
|
||||||
|
if prefix != "" {
|
||||||
|
path += "?prefix=" + url.QueryEscape(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Series []SeriesInfo `json:"series"`
|
||||||
|
}
|
||||||
|
err := ts.client.request(ctx, "GET", path, nil, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result.Series, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRetention sets retention policy for a series
|
||||||
|
func (ts *TimeSeriesStore) SetRetention(ctx context.Context, series string, retentionDays int) error {
|
||||||
|
return ts.client.request(ctx, "PUT", "/timeseries/"+url.PathEscape(series)+"/retention", map[string]interface{}{"retention_days": retentionDays}, nil)
|
||||||
|
}
|
||||||
278
sdk/go/database/types.go
Normal file
278
sdk/go/database/types.go
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
// Package database provides the Synor Database SDK for Go.
|
||||||
|
// Multi-model database: Key-Value, Document, Vector, and Time Series.
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Network represents the network environment
|
||||||
|
type Network string
|
||||||
|
|
||||||
|
const (
|
||||||
|
NetworkMainnet Network = "mainnet"
|
||||||
|
NetworkTestnet Network = "testnet"
|
||||||
|
NetworkDevnet Network = "devnet"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config represents the database client configuration
|
||||||
|
type Config struct {
|
||||||
|
APIKey string
|
||||||
|
Endpoint string
|
||||||
|
Network Network
|
||||||
|
Timeout time.Duration
|
||||||
|
Retries int
|
||||||
|
Debug bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns the default configuration
|
||||||
|
func DefaultConfig(apiKey string) Config {
|
||||||
|
return Config{
|
||||||
|
APIKey: apiKey,
|
||||||
|
Endpoint: "https://database.synor.io/v1",
|
||||||
|
Network: NetworkMainnet,
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
Retries: 3,
|
||||||
|
Debug: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Key-Value Types ====================
|
||||||
|
|
||||||
|
// KeyValueEntry represents a key-value entry with metadata
|
||||||
|
type KeyValueEntry struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value interface{} `json:"value"`
|
||||||
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
|
ExpiresAt *int64 `json:"expires_at,omitempty"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOptions represents options for setting a key-value pair
|
||||||
|
type SetOptions struct {
|
||||||
|
TTL *int `json:"ttl,omitempty"`
|
||||||
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
|
IfNotExists bool `json:"if_not_exists,omitempty"`
|
||||||
|
IfExists bool `json:"if_exists,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOptions represents options for listing keys
|
||||||
|
type ListOptions struct {
|
||||||
|
Prefix string `json:"prefix,omitempty"`
|
||||||
|
Cursor string `json:"cursor,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListResult represents the result of listing keys
|
||||||
|
type ListResult struct {
|
||||||
|
Entries []KeyValueEntry `json:"entries"`
|
||||||
|
Cursor string `json:"cursor,omitempty"`
|
||||||
|
HasMore bool `json:"has_more"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Document Store Types ====================
|
||||||
|
|
||||||
|
// Document represents a document with metadata
|
||||||
|
type Document struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Data map[string]interface{} `json:"data"`
|
||||||
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
Version int `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDocumentOptions represents options for creating a document
|
||||||
|
type CreateDocumentOptions struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateDocumentOptions represents options for updating a document
|
||||||
|
type UpdateDocumentOptions struct {
|
||||||
|
Upsert bool `json:"upsert,omitempty"`
|
||||||
|
ReturnDocument string `json:"return_document,omitempty"` // "before" or "after"
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryFilter represents a query filter
|
||||||
|
type QueryFilter map[string]interface{}
|
||||||
|
|
||||||
|
// SortOrder represents a sort order
|
||||||
|
type SortOrder string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SortAsc SortOrder = "asc"
|
||||||
|
SortDesc SortOrder = "desc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// QueryOptions represents options for querying documents
|
||||||
|
type QueryOptions struct {
|
||||||
|
Filter QueryFilter `json:"filter,omitempty"`
|
||||||
|
Sort map[string]SortOrder `json:"sort,omitempty"`
|
||||||
|
Skip int `json:"skip,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
Projection []string `json:"projection,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryResult represents the result of a document query
|
||||||
|
type QueryResult struct {
|
||||||
|
Documents []Document `json:"documents"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
HasMore bool `json:"has_more"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggregateStage represents a stage in an aggregation pipeline
|
||||||
|
type AggregateStage map[string]interface{}
|
||||||
|
|
||||||
|
// ==================== Vector Store Types ====================
|
||||||
|
|
||||||
|
// VectorEntry represents a vector entry
|
||||||
|
type VectorEntry struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Vector []float64 `json:"vector"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DistanceMetric represents a vector distance metric
|
||||||
|
type DistanceMetric string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DistanceCosine DistanceMetric = "cosine"
|
||||||
|
DistanceEuclidean DistanceMetric = "euclidean"
|
||||||
|
DistanceDotProduct DistanceMetric = "dotProduct"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpsertVectorOptions represents options for upserting vectors
|
||||||
|
type UpsertVectorOptions struct {
|
||||||
|
Namespace string `json:"namespace,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchOptions represents options for vector search
|
||||||
|
type SearchOptions struct {
|
||||||
|
Namespace string `json:"namespace,omitempty"`
|
||||||
|
TopK int `json:"top_k,omitempty"`
|
||||||
|
Filter QueryFilter `json:"filter,omitempty"`
|
||||||
|
IncludeMetadata bool `json:"include_metadata,omitempty"`
|
||||||
|
IncludeVectors bool `json:"include_vectors,omitempty"`
|
||||||
|
MinScore *float64 `json:"min_score,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchResult represents a vector search result
|
||||||
|
type SearchResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
Vector []float64 `json:"vector,omitempty"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VectorCollectionConfig represents configuration for a vector collection
|
||||||
|
type VectorCollectionConfig struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Dimension int `json:"dimension"`
|
||||||
|
Metric DistanceMetric `json:"metric,omitempty"`
|
||||||
|
IndexType string `json:"index_type,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VectorCollectionStats represents statistics for a vector collection
|
||||||
|
type VectorCollectionStats struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
VectorCount int `json:"vector_count"`
|
||||||
|
Dimension int `json:"dimension"`
|
||||||
|
IndexSize int64 `json:"index_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Time Series Types ====================
|
||||||
|
|
||||||
|
// DataPoint represents a time series data point
|
||||||
|
type DataPoint struct {
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
Value float64 `json:"value"`
|
||||||
|
Tags map[string]string `json:"tags,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WritePointsOptions represents options for writing data points
|
||||||
|
type WritePointsOptions struct {
|
||||||
|
Precision string `json:"precision,omitempty"` // "ns", "us", "ms", "s"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregation represents a time series aggregation function
|
||||||
|
type Aggregation string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AggMean Aggregation = "mean"
|
||||||
|
AggSum Aggregation = "sum"
|
||||||
|
AggCount Aggregation = "count"
|
||||||
|
AggMin Aggregation = "min"
|
||||||
|
AggMax Aggregation = "max"
|
||||||
|
AggFirst Aggregation = "first"
|
||||||
|
AggLast Aggregation = "last"
|
||||||
|
AggStddev Aggregation = "stddev"
|
||||||
|
AggVariance Aggregation = "variance"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TimeRange represents a time range for queries
|
||||||
|
type TimeRange struct {
|
||||||
|
Start interface{} `json:"start"` // int64, time.Time, or string
|
||||||
|
End interface{} `json:"end"` // int64, time.Time, or string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimeSeriesQueryOptions represents options for time series queries
|
||||||
|
type TimeSeriesQueryOptions struct {
|
||||||
|
Tags map[string]string `json:"tags,omitempty"`
|
||||||
|
Aggregation Aggregation `json:"aggregation,omitempty"`
|
||||||
|
Interval string `json:"interval,omitempty"` // e.g., "1m", "5m", "1h"
|
||||||
|
Fill interface{} `json:"fill,omitempty"` // "none", "null", "previous", or number
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimeSeriesResult represents the result of a time series query
|
||||||
|
type TimeSeriesResult struct {
|
||||||
|
Series string `json:"series"`
|
||||||
|
Points []DataPoint `json:"points"`
|
||||||
|
Statistics map[string]float64 `json:"statistics,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeriesInfo represents information about a time series
|
||||||
|
type SeriesInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
RetentionDays int `json:"retention_days"`
|
||||||
|
PointCount int64 `json:"point_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Database Stats ====================
|
||||||
|
|
||||||
|
// DatabaseStats represents overall database statistics
|
||||||
|
type DatabaseStats struct {
|
||||||
|
KVEntries int `json:"kv_entries"`
|
||||||
|
DocumentCount int `json:"document_count"`
|
||||||
|
VectorCount int `json:"vector_count"`
|
||||||
|
TimeseriesPoints int64 `json:"timeseries_points"`
|
||||||
|
StorageUsed int64 `json:"storage_used"`
|
||||||
|
StorageLimit int64 `json:"storage_limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Errors ====================
|
||||||
|
|
||||||
|
// DatabaseError represents a database error
|
||||||
|
type DatabaseError struct {
|
||||||
|
Message string
|
||||||
|
Code string
|
||||||
|
StatusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *DatabaseError) Error() string {
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDatabaseError creates a new DatabaseError
|
||||||
|
func NewDatabaseError(message string, code string, statusCode int) *DatabaseError {
|
||||||
|
return &DatabaseError{
|
||||||
|
Message: message,
|
||||||
|
Code: code,
|
||||||
|
StatusCode: statusCode,
|
||||||
|
}
|
||||||
|
}
|
||||||
470
sdk/go/hosting/client.go
Normal file
470
sdk/go/hosting/client.go
Normal file
|
|
@ -0,0 +1,470 @@
|
||||||
|
package hosting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is the Synor Hosting SDK client
|
||||||
|
type Client struct {
|
||||||
|
config Config
|
||||||
|
httpClient *http.Client
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Hosting client
|
||||||
|
func New(config Config) *Client {
|
||||||
|
if config.Endpoint == "" {
|
||||||
|
config.Endpoint = "https://hosting.synor.io/v1"
|
||||||
|
}
|
||||||
|
if config.Timeout == 0 {
|
||||||
|
config.Timeout = 60 * time.Second
|
||||||
|
}
|
||||||
|
if config.Retries == 0 {
|
||||||
|
config.Retries = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Client{
|
||||||
|
config: config,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: config.Timeout,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Domain Operations ====================
|
||||||
|
|
||||||
|
// CheckAvailability checks domain availability
|
||||||
|
func (c *Client) CheckAvailability(ctx context.Context, name string) (*DomainAvailability, error) {
|
||||||
|
var result DomainAvailability
|
||||||
|
err := c.request(ctx, "GET", "/domains/check/"+url.PathEscape(name), nil, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterDomain registers a new domain
|
||||||
|
func (c *Client) RegisterDomain(ctx context.Context, name string, opts *RegisterDomainOptions) (*Domain, error) {
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"name": name,
|
||||||
|
}
|
||||||
|
if opts != nil {
|
||||||
|
if opts.Years > 0 {
|
||||||
|
body["years"] = opts.Years
|
||||||
|
}
|
||||||
|
body["auto_renew"] = opts.AutoRenew
|
||||||
|
if opts.Records != nil {
|
||||||
|
body["records"] = opts.Records
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var result Domain
|
||||||
|
err := c.request(ctx, "POST", "/domains", body, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDomain gets domain information
|
||||||
|
func (c *Client) GetDomain(ctx context.Context, name string) (*Domain, error) {
|
||||||
|
var result Domain
|
||||||
|
err := c.request(ctx, "GET", "/domains/"+url.PathEscape(name), nil, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDomains lists all domains
|
||||||
|
func (c *Client) ListDomains(ctx context.Context) ([]Domain, error) {
|
||||||
|
var result struct {
|
||||||
|
Domains []Domain `json:"domains"`
|
||||||
|
}
|
||||||
|
err := c.request(ctx, "GET", "/domains", nil, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result.Domains, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateDomainRecord updates domain record
|
||||||
|
func (c *Client) UpdateDomainRecord(ctx context.Context, name string, record *DomainRecord) (*Domain, error) {
|
||||||
|
var result Domain
|
||||||
|
err := c.request(ctx, "PUT", "/domains/"+url.PathEscape(name)+"/record", record, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveDomain resolves a domain
|
||||||
|
func (c *Client) ResolveDomain(ctx context.Context, name string) (*DomainRecord, error) {
|
||||||
|
var result DomainRecord
|
||||||
|
err := c.request(ctx, "GET", "/domains/"+url.PathEscape(name)+"/resolve", nil, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenewDomain renews a domain
|
||||||
|
func (c *Client) RenewDomain(ctx context.Context, name string, years int) (*Domain, error) {
|
||||||
|
var result Domain
|
||||||
|
err := c.request(ctx, "POST", "/domains/"+url.PathEscape(name)+"/renew", map[string]interface{}{"years": years}, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DNS Operations ====================
|
||||||
|
|
||||||
|
// GetDnsZone gets DNS zone for a domain
|
||||||
|
func (c *Client) GetDnsZone(ctx context.Context, domain string) (*DnsZone, error) {
|
||||||
|
var result DnsZone
|
||||||
|
err := c.request(ctx, "GET", "/dns/"+url.PathEscape(domain), nil, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDnsRecords sets DNS records for a domain
|
||||||
|
func (c *Client) SetDnsRecords(ctx context.Context, domain string, records []DnsRecord) (*DnsZone, error) {
|
||||||
|
var result DnsZone
|
||||||
|
err := c.request(ctx, "PUT", "/dns/"+url.PathEscape(domain), map[string]interface{}{"records": records}, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddDnsRecord adds a DNS record
|
||||||
|
func (c *Client) AddDnsRecord(ctx context.Context, domain string, record DnsRecord) (*DnsZone, error) {
|
||||||
|
var result DnsZone
|
||||||
|
err := c.request(ctx, "POST", "/dns/"+url.PathEscape(domain)+"/records", record, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDnsRecord deletes a DNS record
|
||||||
|
func (c *Client) DeleteDnsRecord(ctx context.Context, domain, recordType, name string) (*DnsZone, error) {
|
||||||
|
var result DnsZone
|
||||||
|
path := fmt.Sprintf("/dns/%s/records/%s/%s", url.PathEscape(domain), recordType, url.PathEscape(name))
|
||||||
|
err := c.request(ctx, "DELETE", path, nil, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Deployment Operations ====================
|
||||||
|
|
||||||
|
// Deploy deploys a site from CID
|
||||||
|
func (c *Client) Deploy(ctx context.Context, cid string, opts *DeployOptions) (*Deployment, error) {
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"cid": cid,
|
||||||
|
}
|
||||||
|
if opts != nil {
|
||||||
|
if opts.Domain != "" {
|
||||||
|
body["domain"] = opts.Domain
|
||||||
|
}
|
||||||
|
if opts.Subdomain != "" {
|
||||||
|
body["subdomain"] = opts.Subdomain
|
||||||
|
}
|
||||||
|
if opts.Headers != nil {
|
||||||
|
body["headers"] = opts.Headers
|
||||||
|
}
|
||||||
|
if opts.Redirects != nil {
|
||||||
|
body["redirects"] = opts.Redirects
|
||||||
|
}
|
||||||
|
body["spa"] = opts.SPA
|
||||||
|
body["clean_urls"] = opts.CleanURLs
|
||||||
|
body["trailing_slash"] = opts.TrailingSlash
|
||||||
|
}
|
||||||
|
|
||||||
|
var result Deployment
|
||||||
|
err := c.request(ctx, "POST", "/deployments", body, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeployment gets deployment by ID
|
||||||
|
func (c *Client) GetDeployment(ctx context.Context, id string) (*Deployment, error) {
|
||||||
|
var result Deployment
|
||||||
|
err := c.request(ctx, "GET", "/deployments/"+url.PathEscape(id), nil, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDeployments lists deployments
|
||||||
|
func (c *Client) ListDeployments(ctx context.Context, domain string) ([]Deployment, error) {
|
||||||
|
path := "/deployments"
|
||||||
|
if domain != "" {
|
||||||
|
path += "?domain=" + url.QueryEscape(domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Deployments []Deployment `json:"deployments"`
|
||||||
|
}
|
||||||
|
err := c.request(ctx, "GET", path, nil, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result.Deployments, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rollback rolls back to a previous deployment
|
||||||
|
func (c *Client) Rollback(ctx context.Context, domain, deploymentID string) (*Deployment, error) {
|
||||||
|
var result Deployment
|
||||||
|
err := c.request(ctx, "POST", "/deployments/"+url.PathEscape(deploymentID)+"/rollback", map[string]interface{}{"domain": domain}, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDeployment deletes a deployment
|
||||||
|
func (c *Client) DeleteDeployment(ctx context.Context, id string) error {
|
||||||
|
return c.request(ctx, "DELETE", "/deployments/"+url.PathEscape(id), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeploymentStats gets deployment stats
|
||||||
|
func (c *Client) GetDeploymentStats(ctx context.Context, id, period string) (*DeploymentStats, error) {
|
||||||
|
path := fmt.Sprintf("/deployments/%s/stats?period=%s", url.PathEscape(id), url.QueryEscape(period))
|
||||||
|
var result DeploymentStats
|
||||||
|
err := c.request(ctx, "GET", path, nil, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SSL Operations ====================
|
||||||
|
|
||||||
|
// ProvisionSSL provisions SSL certificate
|
||||||
|
func (c *Client) ProvisionSSL(ctx context.Context, domain string, opts *ProvisionSSLOptions) (*Certificate, error) {
|
||||||
|
body := make(map[string]interface{})
|
||||||
|
if opts != nil {
|
||||||
|
body["include_www"] = opts.IncludeWWW
|
||||||
|
body["auto_renew"] = opts.AutoRenew
|
||||||
|
}
|
||||||
|
|
||||||
|
var result Certificate
|
||||||
|
err := c.request(ctx, "POST", "/ssl/"+url.PathEscape(domain), body, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCertificate gets certificate status
|
||||||
|
func (c *Client) GetCertificate(ctx context.Context, domain string) (*Certificate, error) {
|
||||||
|
var result Certificate
|
||||||
|
err := c.request(ctx, "GET", "/ssl/"+url.PathEscape(domain), nil, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenewCertificate renews SSL certificate
|
||||||
|
func (c *Client) RenewCertificate(ctx context.Context, domain string) (*Certificate, error) {
|
||||||
|
var result Certificate
|
||||||
|
err := c.request(ctx, "POST", "/ssl/"+url.PathEscape(domain)+"/renew", nil, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCertificate deletes/revokes SSL certificate
|
||||||
|
func (c *Client) DeleteCertificate(ctx context.Context, domain string) error {
|
||||||
|
return c.request(ctx, "DELETE", "/ssl/"+url.PathEscape(domain), nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Site Configuration ====================
|
||||||
|
|
||||||
|
// GetSiteConfig gets site configuration
|
||||||
|
func (c *Client) GetSiteConfig(ctx context.Context, domain string) (*SiteConfig, error) {
|
||||||
|
var result SiteConfig
|
||||||
|
err := c.request(ctx, "GET", "/sites/"+url.PathEscape(domain)+"/config", nil, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSiteConfig updates site configuration
|
||||||
|
func (c *Client) UpdateSiteConfig(ctx context.Context, domain string, config map[string]interface{}) (*SiteConfig, error) {
|
||||||
|
var result SiteConfig
|
||||||
|
err := c.request(ctx, "PATCH", "/sites/"+url.PathEscape(domain)+"/config", config, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeCache purges CDN cache
|
||||||
|
func (c *Client) PurgeCache(ctx context.Context, domain string, paths []string) (int, error) {
|
||||||
|
body := make(map[string]interface{})
|
||||||
|
if paths != nil {
|
||||||
|
body["paths"] = paths
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Purged int `json:"purged"`
|
||||||
|
}
|
||||||
|
err := c.request(ctx, "DELETE", "/sites/"+url.PathEscape(domain)+"/cache", body, &result)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.Purged, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Analytics ====================
|
||||||
|
|
||||||
|
// GetAnalytics gets site analytics
|
||||||
|
func (c *Client) GetAnalytics(ctx context.Context, domain string, opts *AnalyticsOptions) (*AnalyticsData, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
if opts != nil {
|
||||||
|
if opts.Period != "" {
|
||||||
|
params.Set("period", opts.Period)
|
||||||
|
}
|
||||||
|
if opts.StartDate != "" {
|
||||||
|
params.Set("start", opts.StartDate)
|
||||||
|
}
|
||||||
|
if opts.EndDate != "" {
|
||||||
|
params.Set("end", opts.EndDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path := "/sites/" + url.PathEscape(domain) + "/analytics"
|
||||||
|
if len(params) > 0 {
|
||||||
|
path += "?" + params.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
var result AnalyticsData
|
||||||
|
err := c.request(ctx, "GET", path, nil, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Lifecycle ====================
|
||||||
|
|
||||||
|
// Close closes the client
|
||||||
|
func (c *Client) Close() {
|
||||||
|
c.closed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsClosed returns whether the client is closed
|
||||||
|
func (c *Client) IsClosed() bool {
|
||||||
|
return c.closed
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthCheck performs a health check
|
||||||
|
func (c *Client) HealthCheck(ctx context.Context) bool {
|
||||||
|
var result struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
err := c.request(ctx, "GET", "/health", nil, &result)
|
||||||
|
return err == nil && result.Status == "healthy"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) request(ctx context.Context, method, path string, body interface{}, result interface{}) error {
|
||||||
|
if c.closed {
|
||||||
|
return NewHostingError("Client has been closed", "CLIENT_CLOSED", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
|
||||||
|
for attempt := 0; attempt < c.config.Retries; attempt++ {
|
||||||
|
err := c.doRequest(ctx, method, path, body, result)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lastErr = err
|
||||||
|
if c.config.Debug {
|
||||||
|
fmt.Printf("Attempt %d failed: %v\n", attempt+1, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if attempt < c.config.Retries-1 {
|
||||||
|
time.Sleep(time.Duration(attempt+1) * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doRequest(ctx context.Context, method, path string, body interface{}, result interface{}) error {
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
jsonBody, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
bodyReader = bytes.NewReader(jsonBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, c.config.Endpoint+path, bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-SDK-Version", "go/0.1.0")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
var errResp struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
json.Unmarshal(respBody, &errResp)
|
||||||
|
|
||||||
|
msg := errResp.Message
|
||||||
|
if msg == "" {
|
||||||
|
msg = errResp.Error
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewHostingError(msg, errResp.Code, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil && len(respBody) > 0 {
|
||||||
|
return json.Unmarshal(respBody, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
276
sdk/go/hosting/types.go
Normal file
276
sdk/go/hosting/types.go
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
// Package hosting provides the Synor Hosting SDK for Go.
|
||||||
|
// Decentralized web hosting with domain management, DNS, and deployments.
|
||||||
|
package hosting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Network represents the network environment
|
||||||
|
type Network string
|
||||||
|
|
||||||
|
const (
|
||||||
|
NetworkMainnet Network = "mainnet"
|
||||||
|
NetworkTestnet Network = "testnet"
|
||||||
|
NetworkDevnet Network = "devnet"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config represents the hosting client configuration
|
||||||
|
type Config struct {
|
||||||
|
APIKey string
|
||||||
|
Endpoint string
|
||||||
|
Network Network
|
||||||
|
Timeout time.Duration
|
||||||
|
Retries int
|
||||||
|
Debug bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns the default configuration
|
||||||
|
func DefaultConfig(apiKey string) Config {
|
||||||
|
return Config{
|
||||||
|
APIKey: apiKey,
|
||||||
|
Endpoint: "https://hosting.synor.io/v1",
|
||||||
|
Network: NetworkMainnet,
|
||||||
|
Timeout: 60 * time.Second,
|
||||||
|
Retries: 3,
|
||||||
|
Debug: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Domain Types ====================
|
||||||
|
|
||||||
|
// DomainStatus represents a domain status
|
||||||
|
type DomainStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DomainStatusPending DomainStatus = "pending"
|
||||||
|
DomainStatusActive DomainStatus = "active"
|
||||||
|
DomainStatusExpired DomainStatus = "expired"
|
||||||
|
DomainStatusSuspended DomainStatus = "suspended"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Domain represents a domain
|
||||||
|
type Domain struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status DomainStatus `json:"status"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
RegisteredAt int64 `json:"registered_at"`
|
||||||
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
|
AutoRenew bool `json:"auto_renew"`
|
||||||
|
Records *DomainRecord `json:"records,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DomainRecord represents a domain record for resolution
|
||||||
|
type DomainRecord struct {
|
||||||
|
CID string `json:"cid,omitempty"`
|
||||||
|
IPv4 []string `json:"ipv4,omitempty"`
|
||||||
|
IPv6 []string `json:"ipv6,omitempty"`
|
||||||
|
CNAME string `json:"cname,omitempty"`
|
||||||
|
TXT []string `json:"txt,omitempty"`
|
||||||
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DomainAvailability represents domain availability check result
|
||||||
|
type DomainAvailability struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Price *float64 `json:"price,omitempty"`
|
||||||
|
Premium bool `json:"premium"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterDomainOptions represents options for registering a domain
|
||||||
|
type RegisterDomainOptions struct {
|
||||||
|
Years int `json:"years,omitempty"`
|
||||||
|
AutoRenew bool `json:"auto_renew,omitempty"`
|
||||||
|
Records *DomainRecord `json:"records,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DNS Types ====================
|
||||||
|
|
||||||
|
// DnsRecordType represents a DNS record type
|
||||||
|
type DnsRecordType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DnsRecordTypeA DnsRecordType = "A"
|
||||||
|
DnsRecordTypeAAAA DnsRecordType = "AAAA"
|
||||||
|
DnsRecordTypeCNAME DnsRecordType = "CNAME"
|
||||||
|
DnsRecordTypeTXT DnsRecordType = "TXT"
|
||||||
|
DnsRecordTypeMX DnsRecordType = "MX"
|
||||||
|
DnsRecordTypeNS DnsRecordType = "NS"
|
||||||
|
DnsRecordTypeSRV DnsRecordType = "SRV"
|
||||||
|
DnsRecordTypeCAA DnsRecordType = "CAA"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DnsRecord represents a DNS record
|
||||||
|
type DnsRecord struct {
|
||||||
|
Type DnsRecordType `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
TTL int `json:"ttl,omitempty"`
|
||||||
|
Priority *int `json:"priority,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DnsZone represents a DNS zone
|
||||||
|
type DnsZone struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Records []DnsRecord `json:"records"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Deployment Types ====================
|
||||||
|
|
||||||
|
// DeploymentStatus represents a deployment status
|
||||||
|
type DeploymentStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DeploymentStatusPending DeploymentStatus = "pending"
|
||||||
|
DeploymentStatusBuilding DeploymentStatus = "building"
|
||||||
|
DeploymentStatusDeploying DeploymentStatus = "deploying"
|
||||||
|
DeploymentStatusActive DeploymentStatus = "active"
|
||||||
|
DeploymentStatusFailed DeploymentStatus = "failed"
|
||||||
|
DeploymentStatusInactive DeploymentStatus = "inactive"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Deployment represents a deployment
|
||||||
|
type Deployment struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
CID string `json:"cid"`
|
||||||
|
Status DeploymentStatus `json:"status"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
BuildLogs string `json:"build_logs,omitempty"`
|
||||||
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedirectRule represents a redirect rule
|
||||||
|
type RedirectRule struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
Destination string `json:"destination"`
|
||||||
|
StatusCode int `json:"status_code,omitempty"`
|
||||||
|
Permanent bool `json:"permanent,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployOptions represents options for deploying a site
|
||||||
|
type DeployOptions struct {
|
||||||
|
Domain string `json:"domain,omitempty"`
|
||||||
|
Subdomain string `json:"subdomain,omitempty"`
|
||||||
|
Headers map[string]string `json:"headers,omitempty"`
|
||||||
|
Redirects []RedirectRule `json:"redirects,omitempty"`
|
||||||
|
SPA bool `json:"spa,omitempty"`
|
||||||
|
CleanURLs bool `json:"clean_urls,omitempty"`
|
||||||
|
TrailingSlash bool `json:"trailing_slash,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeploymentStats represents deployment statistics
|
||||||
|
type DeploymentStats struct {
|
||||||
|
Requests int `json:"requests"`
|
||||||
|
Bandwidth int64 `json:"bandwidth"`
|
||||||
|
UniqueVisitors int `json:"unique_visitors"`
|
||||||
|
Period string `json:"period"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SSL Types ====================
|
||||||
|
|
||||||
|
// CertificateStatus represents a certificate status
|
||||||
|
type CertificateStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CertificateStatusPending CertificateStatus = "pending"
|
||||||
|
CertificateStatusIssued CertificateStatus = "issued"
|
||||||
|
CertificateStatusExpired CertificateStatus = "expired"
|
||||||
|
CertificateStatusRevoked CertificateStatus = "revoked"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Certificate represents an SSL certificate
|
||||||
|
type Certificate struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Status CertificateStatus `json:"status"`
|
||||||
|
AutoRenew bool `json:"auto_renew"`
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
|
IssuedAt *int64 `json:"issued_at,omitempty"`
|
||||||
|
ExpiresAt *int64 `json:"expires_at,omitempty"`
|
||||||
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProvisionSSLOptions represents options for provisioning SSL
|
||||||
|
type ProvisionSSLOptions struct {
|
||||||
|
IncludeWWW bool `json:"include_www,omitempty"`
|
||||||
|
AutoRenew bool `json:"auto_renew,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Site Configuration ====================
|
||||||
|
|
||||||
|
// SiteConfig represents site configuration
|
||||||
|
type SiteConfig struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
CID string `json:"cid,omitempty"`
|
||||||
|
Headers map[string]string `json:"headers,omitempty"`
|
||||||
|
Redirects []RedirectRule `json:"redirects,omitempty"`
|
||||||
|
ErrorPages map[string]string `json:"error_pages,omitempty"`
|
||||||
|
SPA bool `json:"spa"`
|
||||||
|
CleanURLs bool `json:"clean_urls"`
|
||||||
|
TrailingSlash bool `json:"trailing_slash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Analytics ====================
|
||||||
|
|
||||||
|
// PageView represents page view data
|
||||||
|
type PageView struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Views int `json:"views"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Referrer represents referrer data
|
||||||
|
type Referrer struct {
|
||||||
|
Referrer string `json:"referrer"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Country represents country data
|
||||||
|
type Country struct {
|
||||||
|
Country string `json:"country"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnalyticsData represents analytics data
|
||||||
|
type AnalyticsData struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Period string `json:"period"`
|
||||||
|
PageViews int `json:"page_views"`
|
||||||
|
UniqueVisitors int `json:"unique_visitors"`
|
||||||
|
Bandwidth int64 `json:"bandwidth"`
|
||||||
|
TopPages []PageView `json:"top_pages"`
|
||||||
|
TopReferrers []Referrer `json:"top_referrers"`
|
||||||
|
TopCountries []Country `json:"top_countries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnalyticsOptions represents options for analytics query
|
||||||
|
type AnalyticsOptions struct {
|
||||||
|
Period string `json:"period,omitempty"`
|
||||||
|
StartDate string `json:"start,omitempty"`
|
||||||
|
EndDate string `json:"end,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Errors ====================
|
||||||
|
|
||||||
|
// HostingError represents a hosting error
|
||||||
|
type HostingError struct {
|
||||||
|
Message string
|
||||||
|
Code string
|
||||||
|
StatusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostingError) Error() string {
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHostingError creates a new HostingError
|
||||||
|
func NewHostingError(message string, code string, statusCode int) *HostingError {
|
||||||
|
return &HostingError{
|
||||||
|
Message: message,
|
||||||
|
Code: code,
|
||||||
|
StatusCode: statusCode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,13 +5,13 @@
|
||||||
<modelVersion>4.0.0</modelVersion>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
<groupId>io.synor</groupId>
|
<groupId>io.synor</groupId>
|
||||||
<artifactId>synor-compute</artifactId>
|
<artifactId>synor-sdk</artifactId>
|
||||||
<version>0.1.0</version>
|
<version>0.1.0</version>
|
||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
<name>Synor Compute SDK</name>
|
<name>Synor SDK</name>
|
||||||
<description>Java SDK for Synor Compute - Distributed Heterogeneous Computing</description>
|
<description>Java SDK for Synor - Compute, Wallet, RPC, and Storage</description>
|
||||||
<url>https://github.com/synor/synor-compute-java</url>
|
<url>https://synor.cc</url>
|
||||||
|
|
||||||
<licenses>
|
<licenses>
|
||||||
<license>
|
<license>
|
||||||
|
|
@ -48,6 +48,11 @@
|
||||||
<artifactId>jackson-databind</artifactId>
|
<artifactId>jackson-databind</artifactId>
|
||||||
<version>${jackson.version}</version>
|
<version>${jackson.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.code.gson</groupId>
|
||||||
|
<artifactId>gson</artifactId>
|
||||||
|
<version>2.10.1</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Testing -->
|
<!-- Testing -->
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|
|
||||||
371
sdk/java/src/main/java/io/synor/bridge/SynorBridge.java
Normal file
371
sdk/java/src/main/java/io/synor/bridge/SynorBridge.java
Normal file
|
|
@ -0,0 +1,371 @@
|
||||||
|
package io.synor.bridge;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.google.gson.reflect.TypeToken;
|
||||||
|
|
||||||
|
import java.lang.reflect.Type;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synor Bridge SDK for Java.
|
||||||
|
* Cross-chain asset transfers with lock-mint and burn-unlock patterns.
|
||||||
|
*/
|
||||||
|
public class SynorBridge implements AutoCloseable {
|
||||||
|
private static final String DEFAULT_ENDPOINT = "https://bridge.synor.io/v1";
|
||||||
|
private static final Gson gson = new GsonBuilder().create();
|
||||||
|
private static final Set<TransferStatus> FINAL_STATUSES = Set.of(
|
||||||
|
TransferStatus.completed, TransferStatus.failed, TransferStatus.refunded
|
||||||
|
);
|
||||||
|
|
||||||
|
private final BridgeConfig config;
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
private final AtomicBoolean closed = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
public SynorBridge(String apiKey) {
|
||||||
|
this(new BridgeConfig(apiKey, DEFAULT_ENDPOINT, 60, 3, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
public SynorBridge(BridgeConfig config) {
|
||||||
|
this.config = config;
|
||||||
|
this.httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(config.getTimeoutSecs()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Chain Operations ====================
|
||||||
|
|
||||||
|
public CompletableFuture<List<Chain>> getSupportedChains() {
|
||||||
|
return request("GET", "/chains", null)
|
||||||
|
.thenApply(resp -> {
|
||||||
|
Type type = new TypeToken<ChainsResponse>(){}.getType();
|
||||||
|
ChainsResponse result = gson.fromJson(resp, type);
|
||||||
|
return result.chains;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Chain> getChain(ChainId chainId) {
|
||||||
|
return request("GET", "/chains/" + chainId.name().toLowerCase(), null)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, Chain.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Boolean> isChainSupported(ChainId chainId) {
|
||||||
|
return getChain(chainId)
|
||||||
|
.thenApply(Chain::isSupported)
|
||||||
|
.exceptionally(e -> false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Asset Operations ====================
|
||||||
|
|
||||||
|
public CompletableFuture<List<Asset>> getSupportedAssets(ChainId chainId) {
|
||||||
|
return request("GET", "/chains/" + chainId.name().toLowerCase() + "/assets", null)
|
||||||
|
.thenApply(resp -> {
|
||||||
|
Type type = new TypeToken<AssetsResponse>(){}.getType();
|
||||||
|
AssetsResponse result = gson.fromJson(resp, type);
|
||||||
|
return result.assets;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Asset> getAsset(String assetId) {
|
||||||
|
return request("GET", "/assets/" + encode(assetId), null)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, Asset.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<WrappedAsset> getWrappedAsset(String originalAssetId, ChainId targetChain) {
|
||||||
|
String path = "/assets/" + encode(originalAssetId) + "/wrapped/" + targetChain.name().toLowerCase();
|
||||||
|
return request("GET", path, null)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, WrappedAsset.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Fee & Rate Operations ====================
|
||||||
|
|
||||||
|
public CompletableFuture<FeeEstimate> estimateFee(String asset, String amount, ChainId sourceChain, ChainId targetChain) {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("asset", asset);
|
||||||
|
body.put("amount", amount);
|
||||||
|
body.put("sourceChain", sourceChain.name().toLowerCase());
|
||||||
|
body.put("targetChain", targetChain.name().toLowerCase());
|
||||||
|
return request("POST", "/fees/estimate", body)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, FeeEstimate.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<ExchangeRate> getExchangeRate(String fromAsset, String toAsset) {
|
||||||
|
String path = "/rates/" + encode(fromAsset) + "/" + encode(toAsset);
|
||||||
|
return request("GET", path, null)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, ExchangeRate.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Lock-Mint Flow ====================
|
||||||
|
|
||||||
|
public CompletableFuture<LockReceipt> lock(String asset, String amount, ChainId targetChain, LockOptions options) {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("asset", asset);
|
||||||
|
body.put("amount", amount);
|
||||||
|
body.put("targetChain", targetChain.name().toLowerCase());
|
||||||
|
if (options != null) {
|
||||||
|
if (options.getRecipient() != null) body.put("recipient", options.getRecipient());
|
||||||
|
if (options.getDeadline() != null) body.put("deadline", options.getDeadline());
|
||||||
|
if (options.getSlippage() != null) body.put("slippage", options.getSlippage());
|
||||||
|
}
|
||||||
|
return request("POST", "/transfers/lock", body)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, LockReceipt.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<LockProof> getLockProof(String lockReceiptId) {
|
||||||
|
return request("GET", "/transfers/lock/" + encode(lockReceiptId) + "/proof", null)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, LockProof.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<LockProof> waitForLockProof(String lockReceiptId, long pollIntervalMs, long maxWaitMs) {
|
||||||
|
long deadline = System.currentTimeMillis() + maxWaitMs;
|
||||||
|
return waitForLockProofInternal(lockReceiptId, pollIntervalMs, deadline);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompletableFuture<LockProof> waitForLockProofInternal(String id, long pollMs, long deadline) {
|
||||||
|
if (System.currentTimeMillis() >= deadline) {
|
||||||
|
return CompletableFuture.failedFuture(
|
||||||
|
new BridgeException("Timeout waiting for lock proof", "CONFIRMATIONS_PENDING", 0));
|
||||||
|
}
|
||||||
|
return getLockProof(id)
|
||||||
|
.exceptionallyCompose(e -> {
|
||||||
|
if (e.getCause() instanceof BridgeException &&
|
||||||
|
"CONFIRMATIONS_PENDING".equals(((BridgeException) e.getCause()).getCode())) {
|
||||||
|
return CompletableFuture.runAsync(() -> sleep(pollMs))
|
||||||
|
.thenCompose(v -> waitForLockProofInternal(id, pollMs, deadline));
|
||||||
|
}
|
||||||
|
return CompletableFuture.failedFuture(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<SignedTransaction> mint(LockProof proof, String targetAddress, MintOptions options) {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("proof", proof);
|
||||||
|
body.put("targetAddress", targetAddress);
|
||||||
|
if (options != null) {
|
||||||
|
if (options.getGasLimit() != null) body.put("gasLimit", options.getGasLimit());
|
||||||
|
if (options.getMaxFeePerGas() != null) body.put("maxFeePerGas", options.getMaxFeePerGas());
|
||||||
|
}
|
||||||
|
return request("POST", "/transfers/mint", body)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, SignedTransaction.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Burn-Unlock Flow ====================
|
||||||
|
|
||||||
|
public CompletableFuture<BurnReceipt> burn(String wrappedAsset, String amount, BurnOptions options) {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("wrappedAsset", wrappedAsset);
|
||||||
|
body.put("amount", amount);
|
||||||
|
if (options != null) {
|
||||||
|
if (options.getRecipient() != null) body.put("recipient", options.getRecipient());
|
||||||
|
if (options.getDeadline() != null) body.put("deadline", options.getDeadline());
|
||||||
|
}
|
||||||
|
return request("POST", "/transfers/burn", body)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, BurnReceipt.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<BurnProof> getBurnProof(String burnReceiptId) {
|
||||||
|
return request("GET", "/transfers/burn/" + encode(burnReceiptId) + "/proof", null)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, BurnProof.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<BurnProof> waitForBurnProof(String burnReceiptId, long pollIntervalMs, long maxWaitMs) {
|
||||||
|
long deadline = System.currentTimeMillis() + maxWaitMs;
|
||||||
|
return waitForBurnProofInternal(burnReceiptId, pollIntervalMs, deadline);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompletableFuture<BurnProof> waitForBurnProofInternal(String id, long pollMs, long deadline) {
|
||||||
|
if (System.currentTimeMillis() >= deadline) {
|
||||||
|
return CompletableFuture.failedFuture(
|
||||||
|
new BridgeException("Timeout waiting for burn proof", "CONFIRMATIONS_PENDING", 0));
|
||||||
|
}
|
||||||
|
return getBurnProof(id)
|
||||||
|
.exceptionallyCompose(e -> {
|
||||||
|
if (e.getCause() instanceof BridgeException &&
|
||||||
|
"CONFIRMATIONS_PENDING".equals(((BridgeException) e.getCause()).getCode())) {
|
||||||
|
return CompletableFuture.runAsync(() -> sleep(pollMs))
|
||||||
|
.thenCompose(v -> waitForBurnProofInternal(id, pollMs, deadline));
|
||||||
|
}
|
||||||
|
return CompletableFuture.failedFuture(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<SignedTransaction> unlock(BurnProof proof, UnlockOptions options) {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("proof", proof);
|
||||||
|
if (options != null) {
|
||||||
|
if (options.getGasLimit() != null) body.put("gasLimit", options.getGasLimit());
|
||||||
|
if (options.getGasPrice() != null) body.put("gasPrice", options.getGasPrice());
|
||||||
|
}
|
||||||
|
return request("POST", "/transfers/unlock", body)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, SignedTransaction.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Transfer Management ====================
|
||||||
|
|
||||||
|
public CompletableFuture<Transfer> getTransfer(String transferId) {
|
||||||
|
return request("GET", "/transfers/" + encode(transferId), null)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, Transfer.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<TransferStatus> getTransferStatus(String transferId) {
|
||||||
|
return getTransfer(transferId).thenApply(Transfer::getStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<List<Transfer>> listTransfers(TransferFilter filter) {
|
||||||
|
StringBuilder path = new StringBuilder("/transfers");
|
||||||
|
if (filter != null) {
|
||||||
|
StringBuilder params = new StringBuilder();
|
||||||
|
if (filter.getStatus() != null) params.append("status=").append(filter.getStatus().name().toLowerCase());
|
||||||
|
if (filter.getSourceChain() != null) {
|
||||||
|
if (params.length() > 0) params.append("&");
|
||||||
|
params.append("sourceChain=").append(filter.getSourceChain().name().toLowerCase());
|
||||||
|
}
|
||||||
|
if (filter.getTargetChain() != null) {
|
||||||
|
if (params.length() > 0) params.append("&");
|
||||||
|
params.append("targetChain=").append(filter.getTargetChain().name().toLowerCase());
|
||||||
|
}
|
||||||
|
if (filter.getLimit() != null) {
|
||||||
|
if (params.length() > 0) params.append("&");
|
||||||
|
params.append("limit=").append(filter.getLimit());
|
||||||
|
}
|
||||||
|
if (params.length() > 0) path.append("?").append(params);
|
||||||
|
}
|
||||||
|
return request("GET", path.toString(), null)
|
||||||
|
.thenApply(resp -> {
|
||||||
|
Type type = new TypeToken<TransfersResponse>(){}.getType();
|
||||||
|
TransfersResponse result = gson.fromJson(resp, type);
|
||||||
|
return result.transfers;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Transfer> waitForTransfer(String transferId, long pollIntervalMs, long maxWaitMs) {
|
||||||
|
long deadline = System.currentTimeMillis() + maxWaitMs;
|
||||||
|
return waitForTransferInternal(transferId, pollIntervalMs, deadline);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompletableFuture<Transfer> waitForTransferInternal(String id, long pollMs, long deadline) {
|
||||||
|
if (System.currentTimeMillis() >= deadline) {
|
||||||
|
return CompletableFuture.failedFuture(new BridgeException("Timeout waiting for transfer completion"));
|
||||||
|
}
|
||||||
|
return getTransfer(id).thenCompose(transfer -> {
|
||||||
|
if (FINAL_STATUSES.contains(transfer.getStatus())) {
|
||||||
|
return CompletableFuture.completedFuture(transfer);
|
||||||
|
}
|
||||||
|
return CompletableFuture.runAsync(() -> sleep(pollMs))
|
||||||
|
.thenCompose(v -> waitForTransferInternal(id, pollMs, deadline));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Convenience Methods ====================
|
||||||
|
|
||||||
|
public CompletableFuture<Transfer> bridgeTo(String asset, String amount, ChainId targetChain,
|
||||||
|
String targetAddress, LockOptions lockOpts, MintOptions mintOpts) {
|
||||||
|
return lock(asset, amount, targetChain, lockOpts)
|
||||||
|
.thenCompose(receipt -> waitForLockProof(receipt.getId(), 5000, 600000)
|
||||||
|
.thenCompose(proof -> mint(proof, targetAddress, mintOpts))
|
||||||
|
.thenCompose(tx -> waitForTransfer(receipt.getId(), 10000, 1800000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Transfer> bridgeBack(String wrappedAsset, String amount,
|
||||||
|
BurnOptions burnOpts, UnlockOptions unlockOpts) {
|
||||||
|
return burn(wrappedAsset, amount, burnOpts)
|
||||||
|
.thenCompose(receipt -> waitForBurnProof(receipt.getId(), 5000, 600000)
|
||||||
|
.thenCompose(proof -> unlock(proof, unlockOpts))
|
||||||
|
.thenCompose(tx -> waitForTransfer(receipt.getId(), 10000, 1800000)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Lifecycle ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
closed.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isClosed() {
|
||||||
|
return closed.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Boolean> healthCheck() {
|
||||||
|
return request("GET", "/health", null)
|
||||||
|
.thenApply(resp -> {
|
||||||
|
Map<String, Object> map = gson.fromJson(resp, new TypeToken<Map<String, Object>>(){}.getType());
|
||||||
|
return "healthy".equals(map.get("status"));
|
||||||
|
})
|
||||||
|
.exceptionally(e -> false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Private Methods ====================
|
||||||
|
|
||||||
|
private CompletableFuture<String> request(String method, String path, Object body) {
|
||||||
|
if (closed.get()) {
|
||||||
|
return CompletableFuture.failedFuture(new BridgeException("Client has been closed"));
|
||||||
|
}
|
||||||
|
return doRequest(method, path, body, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompletableFuture<String> doRequest(String method, String path, Object body, int attempt) {
|
||||||
|
String url = config.getEndpoint() + path;
|
||||||
|
|
||||||
|
HttpRequest.Builder builder = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(url))
|
||||||
|
.header("Authorization", "Bearer " + config.getApiKey())
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("X-SDK-Version", "java/0.1.0")
|
||||||
|
.timeout(Duration.ofSeconds(config.getTimeoutSecs()));
|
||||||
|
|
||||||
|
HttpRequest.BodyPublisher bodyPub = body != null
|
||||||
|
? HttpRequest.BodyPublishers.ofString(gson.toJson(body))
|
||||||
|
: HttpRequest.BodyPublishers.noBody();
|
||||||
|
|
||||||
|
switch (method) {
|
||||||
|
case "GET": builder.GET(); break;
|
||||||
|
case "POST": builder.POST(bodyPub); break;
|
||||||
|
case "PUT": builder.PUT(bodyPub); break;
|
||||||
|
case "DELETE": builder.method("DELETE", bodyPub); break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return httpClient.sendAsync(builder.build(), HttpResponse.BodyHandlers.ofString())
|
||||||
|
.thenCompose(response -> {
|
||||||
|
if (response.statusCode() >= 400) {
|
||||||
|
Map<String, Object> errorBody = gson.fromJson(response.body(),
|
||||||
|
new TypeToken<Map<String, Object>>(){}.getType());
|
||||||
|
String message = errorBody != null && errorBody.get("message") != null
|
||||||
|
? (String) errorBody.get("message") : "HTTP " + response.statusCode();
|
||||||
|
String code = errorBody != null ? (String) errorBody.get("code") : null;
|
||||||
|
return CompletableFuture.failedFuture(
|
||||||
|
new BridgeException(message, code, response.statusCode()));
|
||||||
|
}
|
||||||
|
return CompletableFuture.completedFuture(response.body());
|
||||||
|
})
|
||||||
|
.exceptionally(e -> {
|
||||||
|
if (attempt < config.getRetries() - 1) {
|
||||||
|
sleep((long) Math.pow(2, attempt) * 1000);
|
||||||
|
return doRequest(method, path, body, attempt + 1).join();
|
||||||
|
}
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String encode(String value) {
|
||||||
|
return java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void sleep(long ms) {
|
||||||
|
try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper response types
|
||||||
|
private static class ChainsResponse { List<Chain> chains; }
|
||||||
|
private static class AssetsResponse { List<Asset> assets; }
|
||||||
|
private static class TransfersResponse { List<Transfer> transfers; }
|
||||||
|
}
|
||||||
404
sdk/java/src/main/java/io/synor/bridge/Types.java
Normal file
404
sdk/java/src/main/java/io/synor/bridge/Types.java
Normal file
|
|
@ -0,0 +1,404 @@
|
||||||
|
package io.synor.bridge;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridge SDK Types for Java.
|
||||||
|
*/
|
||||||
|
public class Types {
|
||||||
|
// Organizational class
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Enums ====================
|
||||||
|
|
||||||
|
enum ChainId { synor, ethereum, polygon, arbitrum, optimism, bsc, avalanche, solana, cosmos }
|
||||||
|
enum AssetType { native, erc20, erc721, erc1155 }
|
||||||
|
enum TransferStatus { pending, locked, confirming, minting, completed, failed, refunded }
|
||||||
|
enum TransferDirection { lock_mint, burn_unlock }
|
||||||
|
|
||||||
|
// ==================== Config ====================
|
||||||
|
|
||||||
|
class BridgeConfig {
|
||||||
|
private final String apiKey;
|
||||||
|
private final String endpoint;
|
||||||
|
private final int timeoutSecs;
|
||||||
|
private final int retries;
|
||||||
|
private final boolean debug;
|
||||||
|
|
||||||
|
public BridgeConfig(String apiKey) {
|
||||||
|
this(apiKey, "https://bridge.synor.io/v1", 60, 3, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public BridgeConfig(String apiKey, String endpoint, int timeoutSecs, int retries, boolean debug) {
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
this.endpoint = endpoint;
|
||||||
|
this.timeoutSecs = timeoutSecs;
|
||||||
|
this.retries = retries;
|
||||||
|
this.debug = debug;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getApiKey() { return apiKey; }
|
||||||
|
public String getEndpoint() { return endpoint; }
|
||||||
|
public int getTimeoutSecs() { return timeoutSecs; }
|
||||||
|
public int getRetries() { return retries; }
|
||||||
|
public boolean isDebug() { return debug; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Chain & Asset Types ====================
|
||||||
|
|
||||||
|
class NativeCurrency {
|
||||||
|
private String name;
|
||||||
|
private String symbol;
|
||||||
|
private int decimals;
|
||||||
|
|
||||||
|
public String getName() { return name; }
|
||||||
|
public String getSymbol() { return symbol; }
|
||||||
|
public int getDecimals() { return decimals; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class Chain {
|
||||||
|
private ChainId id;
|
||||||
|
private String name;
|
||||||
|
private long chainId;
|
||||||
|
private String rpcUrl;
|
||||||
|
private String explorerUrl;
|
||||||
|
private NativeCurrency nativeCurrency;
|
||||||
|
private int confirmations;
|
||||||
|
private int estimatedBlockTime;
|
||||||
|
private boolean supported;
|
||||||
|
|
||||||
|
public ChainId getId() { return id; }
|
||||||
|
public String getName() { return name; }
|
||||||
|
public long getChainId() { return chainId; }
|
||||||
|
public String getRpcUrl() { return rpcUrl; }
|
||||||
|
public String getExplorerUrl() { return explorerUrl; }
|
||||||
|
public NativeCurrency getNativeCurrency() { return nativeCurrency; }
|
||||||
|
public int getConfirmations() { return confirmations; }
|
||||||
|
public int getEstimatedBlockTime() { return estimatedBlockTime; }
|
||||||
|
public boolean isSupported() { return supported; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class Asset {
|
||||||
|
private String id;
|
||||||
|
private String symbol;
|
||||||
|
private String name;
|
||||||
|
private AssetType type;
|
||||||
|
private ChainId chain;
|
||||||
|
private String contractAddress;
|
||||||
|
private int decimals;
|
||||||
|
private String logoUrl;
|
||||||
|
private boolean verified;
|
||||||
|
|
||||||
|
public String getId() { return id; }
|
||||||
|
public String getSymbol() { return symbol; }
|
||||||
|
public String getName() { return name; }
|
||||||
|
public AssetType getType() { return type; }
|
||||||
|
public ChainId getChain() { return chain; }
|
||||||
|
public String getContractAddress() { return contractAddress; }
|
||||||
|
public int getDecimals() { return decimals; }
|
||||||
|
public String getLogoUrl() { return logoUrl; }
|
||||||
|
public boolean isVerified() { return verified; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class WrappedAsset {
|
||||||
|
private Asset originalAsset;
|
||||||
|
private Asset wrappedAsset;
|
||||||
|
private ChainId chain;
|
||||||
|
private String bridgeContract;
|
||||||
|
|
||||||
|
public Asset getOriginalAsset() { return originalAsset; }
|
||||||
|
public Asset getWrappedAsset() { return wrappedAsset; }
|
||||||
|
public ChainId getChain() { return chain; }
|
||||||
|
public String getBridgeContract() { return bridgeContract; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Transfer Types ====================
|
||||||
|
|
||||||
|
class ValidatorSignature {
|
||||||
|
private String validator;
|
||||||
|
private String signature;
|
||||||
|
private long timestamp;
|
||||||
|
|
||||||
|
public String getValidator() { return validator; }
|
||||||
|
public String getSignature() { return signature; }
|
||||||
|
public long getTimestamp() { return timestamp; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class LockReceipt {
|
||||||
|
private String id;
|
||||||
|
private String txHash;
|
||||||
|
private ChainId sourceChain;
|
||||||
|
private ChainId targetChain;
|
||||||
|
private Asset asset;
|
||||||
|
private String amount;
|
||||||
|
private String sender;
|
||||||
|
private String recipient;
|
||||||
|
private long lockTimestamp;
|
||||||
|
private int confirmations;
|
||||||
|
private int requiredConfirmations;
|
||||||
|
|
||||||
|
public String getId() { return id; }
|
||||||
|
public String getTxHash() { return txHash; }
|
||||||
|
public ChainId getSourceChain() { return sourceChain; }
|
||||||
|
public ChainId getTargetChain() { return targetChain; }
|
||||||
|
public Asset getAsset() { return asset; }
|
||||||
|
public String getAmount() { return amount; }
|
||||||
|
public String getSender() { return sender; }
|
||||||
|
public String getRecipient() { return recipient; }
|
||||||
|
public long getLockTimestamp() { return lockTimestamp; }
|
||||||
|
public int getConfirmations() { return confirmations; }
|
||||||
|
public int getRequiredConfirmations() { return requiredConfirmations; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class LockProof {
|
||||||
|
private LockReceipt lockReceipt;
|
||||||
|
private List<String> merkleProof;
|
||||||
|
private String blockHeader;
|
||||||
|
private List<ValidatorSignature> signatures;
|
||||||
|
|
||||||
|
public LockReceipt getLockReceipt() { return lockReceipt; }
|
||||||
|
public List<String> getMerkleProof() { return merkleProof; }
|
||||||
|
public String getBlockHeader() { return blockHeader; }
|
||||||
|
public List<ValidatorSignature> getSignatures() { return signatures; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class BurnReceipt {
|
||||||
|
private String id;
|
||||||
|
private String txHash;
|
||||||
|
private ChainId sourceChain;
|
||||||
|
private ChainId targetChain;
|
||||||
|
private Asset wrappedAsset;
|
||||||
|
private Asset originalAsset;
|
||||||
|
private String amount;
|
||||||
|
private String sender;
|
||||||
|
private String recipient;
|
||||||
|
private long burnTimestamp;
|
||||||
|
private int confirmations;
|
||||||
|
private int requiredConfirmations;
|
||||||
|
|
||||||
|
public String getId() { return id; }
|
||||||
|
public String getTxHash() { return txHash; }
|
||||||
|
public ChainId getSourceChain() { return sourceChain; }
|
||||||
|
public ChainId getTargetChain() { return targetChain; }
|
||||||
|
public Asset getWrappedAsset() { return wrappedAsset; }
|
||||||
|
public Asset getOriginalAsset() { return originalAsset; }
|
||||||
|
public String getAmount() { return amount; }
|
||||||
|
public String getSender() { return sender; }
|
||||||
|
public String getRecipient() { return recipient; }
|
||||||
|
public long getBurnTimestamp() { return burnTimestamp; }
|
||||||
|
public int getConfirmations() { return confirmations; }
|
||||||
|
public int getRequiredConfirmations() { return requiredConfirmations; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class BurnProof {
|
||||||
|
private BurnReceipt burnReceipt;
|
||||||
|
private List<String> merkleProof;
|
||||||
|
private String blockHeader;
|
||||||
|
private List<ValidatorSignature> signatures;
|
||||||
|
|
||||||
|
public BurnReceipt getBurnReceipt() { return burnReceipt; }
|
||||||
|
public List<String> getMerkleProof() { return merkleProof; }
|
||||||
|
public String getBlockHeader() { return blockHeader; }
|
||||||
|
public List<ValidatorSignature> getSignatures() { return signatures; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class Transfer {
|
||||||
|
private String id;
|
||||||
|
private TransferDirection direction;
|
||||||
|
private TransferStatus status;
|
||||||
|
private ChainId sourceChain;
|
||||||
|
private ChainId targetChain;
|
||||||
|
private Asset asset;
|
||||||
|
private String amount;
|
||||||
|
private String sender;
|
||||||
|
private String recipient;
|
||||||
|
private String sourceTxHash;
|
||||||
|
private String targetTxHash;
|
||||||
|
private String fee;
|
||||||
|
private Asset feeAsset;
|
||||||
|
private long createdAt;
|
||||||
|
private long updatedAt;
|
||||||
|
private Long completedAt;
|
||||||
|
private String errorMessage;
|
||||||
|
|
||||||
|
public String getId() { return id; }
|
||||||
|
public TransferDirection getDirection() { return direction; }
|
||||||
|
public TransferStatus getStatus() { return status; }
|
||||||
|
public ChainId getSourceChain() { return sourceChain; }
|
||||||
|
public ChainId getTargetChain() { return targetChain; }
|
||||||
|
public Asset getAsset() { return asset; }
|
||||||
|
public String getAmount() { return amount; }
|
||||||
|
public String getSender() { return sender; }
|
||||||
|
public String getRecipient() { return recipient; }
|
||||||
|
public String getSourceTxHash() { return sourceTxHash; }
|
||||||
|
public String getTargetTxHash() { return targetTxHash; }
|
||||||
|
public String getFee() { return fee; }
|
||||||
|
public Asset getFeeAsset() { return feeAsset; }
|
||||||
|
public long getCreatedAt() { return createdAt; }
|
||||||
|
public long getUpdatedAt() { return updatedAt; }
|
||||||
|
public Long getCompletedAt() { return completedAt; }
|
||||||
|
public String getErrorMessage() { return errorMessage; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Fee Types ====================
|
||||||
|
|
||||||
|
class FeeEstimate {
|
||||||
|
private String bridgeFee;
|
||||||
|
private String gasFeeSource;
|
||||||
|
private String gasFeeTarget;
|
||||||
|
private String totalFee;
|
||||||
|
private Asset feeAsset;
|
||||||
|
private int estimatedTime;
|
||||||
|
private String exchangeRate;
|
||||||
|
|
||||||
|
public String getBridgeFee() { return bridgeFee; }
|
||||||
|
public String getGasFeeSource() { return gasFeeSource; }
|
||||||
|
public String getGasFeeTarget() { return gasFeeTarget; }
|
||||||
|
public String getTotalFee() { return totalFee; }
|
||||||
|
public Asset getFeeAsset() { return feeAsset; }
|
||||||
|
public int getEstimatedTime() { return estimatedTime; }
|
||||||
|
public String getExchangeRate() { return exchangeRate; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExchangeRate {
|
||||||
|
private Asset fromAsset;
|
||||||
|
private Asset toAsset;
|
||||||
|
private String rate;
|
||||||
|
private String inverseRate;
|
||||||
|
private long lastUpdated;
|
||||||
|
private String source;
|
||||||
|
|
||||||
|
public Asset getFromAsset() { return fromAsset; }
|
||||||
|
public Asset getToAsset() { return toAsset; }
|
||||||
|
public String getRate() { return rate; }
|
||||||
|
public String getInverseRate() { return inverseRate; }
|
||||||
|
public long getLastUpdated() { return lastUpdated; }
|
||||||
|
public String getSource() { return source; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class SignedTransaction {
|
||||||
|
private String txHash;
|
||||||
|
private ChainId chain;
|
||||||
|
private String from;
|
||||||
|
private String to;
|
||||||
|
private String value;
|
||||||
|
private String data;
|
||||||
|
private String gasLimit;
|
||||||
|
private String gasPrice;
|
||||||
|
private String maxFeePerGas;
|
||||||
|
private String maxPriorityFeePerGas;
|
||||||
|
private int nonce;
|
||||||
|
private String signature;
|
||||||
|
|
||||||
|
public String getTxHash() { return txHash; }
|
||||||
|
public ChainId getChain() { return chain; }
|
||||||
|
public String getFrom() { return from; }
|
||||||
|
public String getTo() { return to; }
|
||||||
|
public String getValue() { return value; }
|
||||||
|
public String getData() { return data; }
|
||||||
|
public String getGasLimit() { return gasLimit; }
|
||||||
|
public String getGasPrice() { return gasPrice; }
|
||||||
|
public String getMaxFeePerGas() { return maxFeePerGas; }
|
||||||
|
public String getMaxPriorityFeePerGas() { return maxPriorityFeePerGas; }
|
||||||
|
public int getNonce() { return nonce; }
|
||||||
|
public String getSignature() { return signature; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Options & Filter ====================
|
||||||
|
|
||||||
|
class LockOptions {
|
||||||
|
private String recipient;
|
||||||
|
private Long deadline;
|
||||||
|
private Double slippage;
|
||||||
|
|
||||||
|
public String getRecipient() { return recipient; }
|
||||||
|
public Long getDeadline() { return deadline; }
|
||||||
|
public Double getSlippage() { return slippage; }
|
||||||
|
public void setRecipient(String recipient) { this.recipient = recipient; }
|
||||||
|
public void setDeadline(Long deadline) { this.deadline = deadline; }
|
||||||
|
public void setSlippage(Double slippage) { this.slippage = slippage; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class MintOptions {
|
||||||
|
private String gasLimit;
|
||||||
|
private String maxFeePerGas;
|
||||||
|
private String maxPriorityFeePerGas;
|
||||||
|
|
||||||
|
public String getGasLimit() { return gasLimit; }
|
||||||
|
public String getMaxFeePerGas() { return maxFeePerGas; }
|
||||||
|
public String getMaxPriorityFeePerGas() { return maxPriorityFeePerGas; }
|
||||||
|
public void setGasLimit(String gasLimit) { this.gasLimit = gasLimit; }
|
||||||
|
public void setMaxFeePerGas(String maxFeePerGas) { this.maxFeePerGas = maxFeePerGas; }
|
||||||
|
public void setMaxPriorityFeePerGas(String v) { this.maxPriorityFeePerGas = v; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class BurnOptions {
|
||||||
|
private String recipient;
|
||||||
|
private Long deadline;
|
||||||
|
|
||||||
|
public String getRecipient() { return recipient; }
|
||||||
|
public Long getDeadline() { return deadline; }
|
||||||
|
public void setRecipient(String recipient) { this.recipient = recipient; }
|
||||||
|
public void setDeadline(Long deadline) { this.deadline = deadline; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class UnlockOptions {
|
||||||
|
private String gasLimit;
|
||||||
|
private String gasPrice;
|
||||||
|
|
||||||
|
public String getGasLimit() { return gasLimit; }
|
||||||
|
public String getGasPrice() { return gasPrice; }
|
||||||
|
public void setGasLimit(String gasLimit) { this.gasLimit = gasLimit; }
|
||||||
|
public void setGasPrice(String gasPrice) { this.gasPrice = gasPrice; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class TransferFilter {
|
||||||
|
private TransferStatus status;
|
||||||
|
private ChainId sourceChain;
|
||||||
|
private ChainId targetChain;
|
||||||
|
private String asset;
|
||||||
|
private String sender;
|
||||||
|
private String recipient;
|
||||||
|
private Integer limit;
|
||||||
|
private Integer offset;
|
||||||
|
|
||||||
|
public TransferStatus getStatus() { return status; }
|
||||||
|
public ChainId getSourceChain() { return sourceChain; }
|
||||||
|
public ChainId getTargetChain() { return targetChain; }
|
||||||
|
public String getAsset() { return asset; }
|
||||||
|
public String getSender() { return sender; }
|
||||||
|
public String getRecipient() { return recipient; }
|
||||||
|
public Integer getLimit() { return limit; }
|
||||||
|
public Integer getOffset() { return offset; }
|
||||||
|
|
||||||
|
public void setStatus(TransferStatus status) { this.status = status; }
|
||||||
|
public void setSourceChain(ChainId sourceChain) { this.sourceChain = sourceChain; }
|
||||||
|
public void setTargetChain(ChainId targetChain) { this.targetChain = targetChain; }
|
||||||
|
public void setAsset(String asset) { this.asset = asset; }
|
||||||
|
public void setSender(String sender) { this.sender = sender; }
|
||||||
|
public void setRecipient(String recipient) { this.recipient = recipient; }
|
||||||
|
public void setLimit(Integer limit) { this.limit = limit; }
|
||||||
|
public void setOffset(Integer offset) { this.offset = offset; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Error ====================
|
||||||
|
|
||||||
|
class BridgeException extends RuntimeException {
|
||||||
|
private final String code;
|
||||||
|
private final int statusCode;
|
||||||
|
|
||||||
|
public BridgeException(String message) {
|
||||||
|
super(message);
|
||||||
|
this.code = null;
|
||||||
|
this.statusCode = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BridgeException(String message, String code, int statusCode) {
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCode() { return code; }
|
||||||
|
public int getStatusCode() { return statusCode; }
|
||||||
|
}
|
||||||
309
sdk/java/src/main/java/io/synor/database/SynorDatabase.java
Normal file
309
sdk/java/src/main/java/io/synor/database/SynorDatabase.java
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
package io.synor.database;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.google.gson.reflect.TypeToken;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.lang.reflect.Type;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synor Database SDK for Java.
|
||||||
|
* Multi-model database with Key-Value, Document, Vector, and Time Series stores.
|
||||||
|
*/
|
||||||
|
public class SynorDatabase implements AutoCloseable {
|
||||||
|
private static final String DEFAULT_ENDPOINT = "https://db.synor.io/v1";
|
||||||
|
private static final Gson gson = new GsonBuilder().create();
|
||||||
|
|
||||||
|
private final DatabaseConfig config;
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
private final AtomicBoolean closed = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
// Sub-stores
|
||||||
|
public final KeyValueStore kv;
|
||||||
|
public final DocumentStore documents;
|
||||||
|
public final VectorStore vectors;
|
||||||
|
public final TimeSeriesStore timeseries;
|
||||||
|
|
||||||
|
public SynorDatabase(String apiKey) {
|
||||||
|
this(new DatabaseConfig(apiKey, DEFAULT_ENDPOINT, 60, 3, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
public SynorDatabase(DatabaseConfig config) {
|
||||||
|
this.config = config;
|
||||||
|
this.httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(config.getTimeoutSecs()))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
this.kv = new KeyValueStore(this);
|
||||||
|
this.documents = new DocumentStore(this);
|
||||||
|
this.vectors = new VectorStore(this);
|
||||||
|
this.timeseries = new TimeSeriesStore(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Key-Value Store ====================
|
||||||
|
|
||||||
|
public static class KeyValueStore {
|
||||||
|
private final SynorDatabase db;
|
||||||
|
|
||||||
|
KeyValueStore(SynorDatabase db) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Object> get(String key) {
|
||||||
|
return db.request("GET", "/kv/" + encode(key), null)
|
||||||
|
.thenApply(resp -> {
|
||||||
|
Map<String, Object> map = gson.fromJson(resp, new TypeToken<Map<String, Object>>(){}.getType());
|
||||||
|
return map.get("value");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Void> set(String key, Object value, Integer ttl) {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("key", key);
|
||||||
|
body.put("value", value);
|
||||||
|
if (ttl != null) body.put("ttl", ttl);
|
||||||
|
|
||||||
|
return db.request("PUT", "/kv/" + encode(key), body)
|
||||||
|
.thenApply(resp -> null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Void> delete(String key) {
|
||||||
|
return db.request("DELETE", "/kv/" + encode(key), null)
|
||||||
|
.thenApply(resp -> null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<List<KeyValue>> list(String prefix) {
|
||||||
|
String path = "/kv?prefix=" + encode(prefix);
|
||||||
|
return db.request("GET", path, null)
|
||||||
|
.thenApply(resp -> {
|
||||||
|
Type type = new TypeToken<ListResponse<KeyValue>>(){}.getType();
|
||||||
|
ListResponse<KeyValue> result = gson.fromJson(resp, type);
|
||||||
|
return result.items;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Document Store ====================
|
||||||
|
|
||||||
|
public static class DocumentStore {
|
||||||
|
private final SynorDatabase db;
|
||||||
|
|
||||||
|
DocumentStore(SynorDatabase db) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<String> create(String collection, Map<String, Object> document) {
|
||||||
|
return db.request("POST", "/collections/" + encode(collection) + "/documents", document)
|
||||||
|
.thenApply(resp -> {
|
||||||
|
Map<String, Object> map = gson.fromJson(resp, new TypeToken<Map<String, Object>>(){}.getType());
|
||||||
|
return (String) map.get("id");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Document> get(String collection, String id) {
|
||||||
|
return db.request("GET", "/collections/" + encode(collection) + "/documents/" + encode(id), null)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, Document.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Void> update(String collection, String id, Map<String, Object> update) {
|
||||||
|
return db.request("PATCH", "/collections/" + encode(collection) + "/documents/" + encode(id), update)
|
||||||
|
.thenApply(resp -> null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Void> delete(String collection, String id) {
|
||||||
|
return db.request("DELETE", "/collections/" + encode(collection) + "/documents/" + encode(id), null)
|
||||||
|
.thenApply(resp -> null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<List<Document>> query(String collection, Query query) {
|
||||||
|
return db.request("POST", "/collections/" + encode(collection) + "/query", query)
|
||||||
|
.thenApply(resp -> {
|
||||||
|
Type type = new TypeToken<ListResponse<Document>>(){}.getType();
|
||||||
|
ListResponse<Document> result = gson.fromJson(resp, type);
|
||||||
|
return result.documents;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Vector Store ====================
|
||||||
|
|
||||||
|
public static class VectorStore {
|
||||||
|
private final SynorDatabase db;
|
||||||
|
|
||||||
|
VectorStore(SynorDatabase db) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Void> upsert(String collection, List<VectorEntry> vectors) {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("vectors", vectors);
|
||||||
|
|
||||||
|
return db.request("POST", "/vectors/" + encode(collection) + "/upsert", body)
|
||||||
|
.thenApply(resp -> null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<List<SearchResult>> search(String collection, double[] vector, int k) {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("vector", vector);
|
||||||
|
body.put("k", k);
|
||||||
|
|
||||||
|
return db.request("POST", "/vectors/" + encode(collection) + "/search", body)
|
||||||
|
.thenApply(resp -> {
|
||||||
|
Type type = new TypeToken<ListResponse<SearchResult>>(){}.getType();
|
||||||
|
ListResponse<SearchResult> result = gson.fromJson(resp, type);
|
||||||
|
return result.results;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Void> delete(String collection, List<String> ids) {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("ids", ids);
|
||||||
|
|
||||||
|
return db.request("DELETE", "/vectors/" + encode(collection), body)
|
||||||
|
.thenApply(resp -> null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Time Series Store ====================
|
||||||
|
|
||||||
|
public static class TimeSeriesStore {
|
||||||
|
private final SynorDatabase db;
|
||||||
|
|
||||||
|
TimeSeriesStore(SynorDatabase db) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Void> write(String series, List<DataPoint> points) {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("points", points);
|
||||||
|
|
||||||
|
return db.request("POST", "/timeseries/" + encode(series) + "/write", body)
|
||||||
|
.thenApply(resp -> null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<List<DataPoint>> query(String series, TimeRange range, Aggregation aggregation) {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("range", range);
|
||||||
|
if (aggregation != null) body.put("aggregation", aggregation);
|
||||||
|
|
||||||
|
return db.request("POST", "/timeseries/" + encode(series) + "/query", body)
|
||||||
|
.thenApply(resp -> {
|
||||||
|
Type type = new TypeToken<ListResponse<DataPoint>>(){}.getType();
|
||||||
|
ListResponse<DataPoint> result = gson.fromJson(resp, type);
|
||||||
|
return result.points;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Lifecycle ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
closed.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isClosed() {
|
||||||
|
return closed.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Boolean> healthCheck() {
|
||||||
|
return request("GET", "/health", null)
|
||||||
|
.thenApply(resp -> {
|
||||||
|
Map<String, Object> map = gson.fromJson(resp, new TypeToken<Map<String, Object>>(){}.getType());
|
||||||
|
return "healthy".equals(map.get("status"));
|
||||||
|
})
|
||||||
|
.exceptionally(e -> false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Private Methods ====================
|
||||||
|
|
||||||
|
private CompletableFuture<String> request(String method, String path, Object body) {
|
||||||
|
if (closed.get()) {
|
||||||
|
return CompletableFuture.failedFuture(new DatabaseException("Client has been closed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return doRequest(method, path, body, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompletableFuture<String> doRequest(String method, String path, Object body, int attempt) {
|
||||||
|
String url = config.getEndpoint() + path;
|
||||||
|
|
||||||
|
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(url))
|
||||||
|
.header("Authorization", "Bearer " + config.getApiKey())
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("X-SDK-Version", "java/0.1.0")
|
||||||
|
.timeout(Duration.ofSeconds(config.getTimeoutSecs()));
|
||||||
|
|
||||||
|
HttpRequest.BodyPublisher bodyPublisher = body != null
|
||||||
|
? HttpRequest.BodyPublishers.ofString(gson.toJson(body))
|
||||||
|
: HttpRequest.BodyPublishers.noBody();
|
||||||
|
|
||||||
|
switch (method) {
|
||||||
|
case "GET":
|
||||||
|
requestBuilder.GET();
|
||||||
|
break;
|
||||||
|
case "POST":
|
||||||
|
requestBuilder.POST(bodyPublisher);
|
||||||
|
break;
|
||||||
|
case "PUT":
|
||||||
|
requestBuilder.PUT(bodyPublisher);
|
||||||
|
break;
|
||||||
|
case "PATCH":
|
||||||
|
requestBuilder.method("PATCH", bodyPublisher);
|
||||||
|
break;
|
||||||
|
case "DELETE":
|
||||||
|
requestBuilder.method("DELETE", bodyPublisher);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return httpClient.sendAsync(requestBuilder.build(), HttpResponse.BodyHandlers.ofString())
|
||||||
|
.thenCompose(response -> {
|
||||||
|
if (response.statusCode() >= 400) {
|
||||||
|
Map<String, Object> errorBody = gson.fromJson(response.body(),
|
||||||
|
new TypeToken<Map<String, Object>>(){}.getType());
|
||||||
|
String message = errorBody != null && errorBody.get("message") != null
|
||||||
|
? (String) errorBody.get("message")
|
||||||
|
: "HTTP " + response.statusCode();
|
||||||
|
return CompletableFuture.failedFuture(
|
||||||
|
new DatabaseException(message, (String) errorBody.get("code"), response.statusCode()));
|
||||||
|
}
|
||||||
|
return CompletableFuture.completedFuture(response.body());
|
||||||
|
})
|
||||||
|
.exceptionally(e -> {
|
||||||
|
if (attempt < config.getRetries() - 1) {
|
||||||
|
try {
|
||||||
|
Thread.sleep((long) Math.pow(2, attempt) * 1000);
|
||||||
|
} catch (InterruptedException ie) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
return doRequest(method, path, body, attempt + 1).join();
|
||||||
|
}
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String encode(String value) {
|
||||||
|
return java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Helper Types ====================
|
||||||
|
|
||||||
|
private static class ListResponse<T> {
|
||||||
|
List<T> items;
|
||||||
|
List<T> documents;
|
||||||
|
List<T> results;
|
||||||
|
List<T> points;
|
||||||
|
}
|
||||||
|
}
|
||||||
210
sdk/java/src/main/java/io/synor/database/Types.java
Normal file
210
sdk/java/src/main/java/io/synor/database/Types.java
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
package io.synor.database;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database SDK Types for Java.
|
||||||
|
*/
|
||||||
|
public class Types {
|
||||||
|
// This class exists for organizational purposes.
|
||||||
|
// Individual types are defined below.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Config ====================
|
||||||
|
|
||||||
|
class DatabaseConfig {
|
||||||
|
private final String apiKey;
|
||||||
|
private final String endpoint;
|
||||||
|
private final int timeoutSecs;
|
||||||
|
private final int retries;
|
||||||
|
private final boolean debug;
|
||||||
|
|
||||||
|
public DatabaseConfig(String apiKey) {
|
||||||
|
this(apiKey, "https://db.synor.io/v1", 60, 3, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DatabaseConfig(String apiKey, String endpoint, int timeoutSecs, int retries, boolean debug) {
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
this.endpoint = endpoint;
|
||||||
|
this.timeoutSecs = timeoutSecs;
|
||||||
|
this.retries = retries;
|
||||||
|
this.debug = debug;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getApiKey() { return apiKey; }
|
||||||
|
public String getEndpoint() { return endpoint; }
|
||||||
|
public int getTimeoutSecs() { return timeoutSecs; }
|
||||||
|
public int getRetries() { return retries; }
|
||||||
|
public boolean isDebug() { return debug; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Key-Value Types ====================
|
||||||
|
|
||||||
|
class KeyValue {
|
||||||
|
private String key;
|
||||||
|
private Object value;
|
||||||
|
private Long ttl;
|
||||||
|
private Long createdAt;
|
||||||
|
private Long updatedAt;
|
||||||
|
|
||||||
|
public String getKey() { return key; }
|
||||||
|
public Object getValue() { return value; }
|
||||||
|
public Long getTtl() { return ttl; }
|
||||||
|
public Long getCreatedAt() { return createdAt; }
|
||||||
|
public Long getUpdatedAt() { return updatedAt; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Document Types ====================
|
||||||
|
|
||||||
|
class Document {
|
||||||
|
private String id;
|
||||||
|
private String collection;
|
||||||
|
private Map<String, Object> data;
|
||||||
|
private Long createdAt;
|
||||||
|
private Long updatedAt;
|
||||||
|
|
||||||
|
public String getId() { return id; }
|
||||||
|
public String getCollection() { return collection; }
|
||||||
|
public Map<String, Object> getData() { return data; }
|
||||||
|
public Long getCreatedAt() { return createdAt; }
|
||||||
|
public Long getUpdatedAt() { return updatedAt; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class Query {
|
||||||
|
private Map<String, Object> filter;
|
||||||
|
private Map<String, Integer> sort;
|
||||||
|
private Integer limit;
|
||||||
|
private Integer offset;
|
||||||
|
private String[] projection;
|
||||||
|
|
||||||
|
public Query filter(Map<String, Object> filter) {
|
||||||
|
this.filter = filter;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Query sort(Map<String, Integer> sort) {
|
||||||
|
this.sort = sort;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Query limit(int limit) {
|
||||||
|
this.limit = limit;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Query offset(int offset) {
|
||||||
|
this.offset = offset;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Query projection(String... fields) {
|
||||||
|
this.projection = fields;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Vector Types ====================
|
||||||
|
|
||||||
|
class VectorEntry {
|
||||||
|
private String id;
|
||||||
|
private double[] vector;
|
||||||
|
private Map<String, Object> metadata;
|
||||||
|
|
||||||
|
public VectorEntry(String id, double[] vector) {
|
||||||
|
this.id = id;
|
||||||
|
this.vector = vector;
|
||||||
|
}
|
||||||
|
|
||||||
|
public VectorEntry(String id, double[] vector, Map<String, Object> metadata) {
|
||||||
|
this.id = id;
|
||||||
|
this.vector = vector;
|
||||||
|
this.metadata = metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getId() { return id; }
|
||||||
|
public double[] getVector() { return vector; }
|
||||||
|
public Map<String, Object> getMetadata() { return metadata; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class SearchResult {
|
||||||
|
private String id;
|
||||||
|
private double score;
|
||||||
|
private double[] vector;
|
||||||
|
private Map<String, Object> metadata;
|
||||||
|
|
||||||
|
public String getId() { return id; }
|
||||||
|
public double getScore() { return score; }
|
||||||
|
public double[] getVector() { return vector; }
|
||||||
|
public Map<String, Object> getMetadata() { return metadata; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Time Series Types ====================
|
||||||
|
|
||||||
|
class DataPoint {
|
||||||
|
private long timestamp;
|
||||||
|
private double value;
|
||||||
|
private Map<String, String> tags;
|
||||||
|
|
||||||
|
public DataPoint(long timestamp, double value) {
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DataPoint(long timestamp, double value, Map<String, String> tags) {
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
this.value = value;
|
||||||
|
this.tags = tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTimestamp() { return timestamp; }
|
||||||
|
public double getValue() { return value; }
|
||||||
|
public Map<String, String> getTags() { return tags; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class TimeRange {
|
||||||
|
private long start;
|
||||||
|
private long end;
|
||||||
|
|
||||||
|
public TimeRange(long start, long end) {
|
||||||
|
this.start = start;
|
||||||
|
this.end = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getStart() { return start; }
|
||||||
|
public long getEnd() { return end; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class Aggregation {
|
||||||
|
private String function; // sum, avg, min, max, count
|
||||||
|
private String interval; // 1m, 5m, 1h, 1d
|
||||||
|
|
||||||
|
public Aggregation(String function, String interval) {
|
||||||
|
this.function = function;
|
||||||
|
this.interval = interval;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFunction() { return function; }
|
||||||
|
public String getInterval() { return interval; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Error Types ====================
|
||||||
|
|
||||||
|
class DatabaseException extends RuntimeException {
|
||||||
|
private final String code;
|
||||||
|
private final int statusCode;
|
||||||
|
|
||||||
|
public DatabaseException(String message) {
|
||||||
|
super(message);
|
||||||
|
this.code = null;
|
||||||
|
this.statusCode = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DatabaseException(String message, String code, int statusCode) {
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCode() { return code; }
|
||||||
|
public int getStatusCode() { return statusCode; }
|
||||||
|
}
|
||||||
304
sdk/java/src/main/java/io/synor/hosting/SynorHosting.java
Normal file
304
sdk/java/src/main/java/io/synor/hosting/SynorHosting.java
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
package io.synor.hosting;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.google.gson.reflect.TypeToken;
|
||||||
|
|
||||||
|
import java.lang.reflect.Type;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synor Hosting SDK for Java.
|
||||||
|
* Decentralized web hosting with domain management, DNS, and deployments.
|
||||||
|
*/
|
||||||
|
public class SynorHosting implements AutoCloseable {
|
||||||
|
private static final String DEFAULT_ENDPOINT = "https://hosting.synor.io/v1";
|
||||||
|
private static final Gson gson = new GsonBuilder().create();
|
||||||
|
|
||||||
|
private final HostingConfig config;
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
private final AtomicBoolean closed = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
public SynorHosting(String apiKey) {
|
||||||
|
this(new HostingConfig(apiKey, DEFAULT_ENDPOINT, 60, 3, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
public SynorHosting(HostingConfig config) {
|
||||||
|
this.config = config;
|
||||||
|
this.httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(config.getTimeoutSecs()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Domain Operations ====================
|
||||||
|
|
||||||
|
public CompletableFuture<DomainAvailability> checkAvailability(String name) {
|
||||||
|
return request("GET", "/domains/check/" + encode(name), null)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, DomainAvailability.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Domain> registerDomain(String name, RegisterDomainOptions options) {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("name", name);
|
||||||
|
if (options != null) {
|
||||||
|
if (options.getYears() != null) body.put("years", options.getYears());
|
||||||
|
if (options.getAutoRenew() != null) body.put("autoRenew", options.getAutoRenew());
|
||||||
|
}
|
||||||
|
return request("POST", "/domains", body)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, Domain.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Domain> getDomain(String name) {
|
||||||
|
return request("GET", "/domains/" + encode(name), null)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, Domain.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<List<Domain>> listDomains() {
|
||||||
|
return request("GET", "/domains", null)
|
||||||
|
.thenApply(resp -> {
|
||||||
|
Type type = new TypeToken<DomainsResponse>(){}.getType();
|
||||||
|
DomainsResponse result = gson.fromJson(resp, type);
|
||||||
|
return result.domains;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Domain> updateDomainRecord(String name, DomainRecord record) {
|
||||||
|
return request("PUT", "/domains/" + encode(name) + "/record", record)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, Domain.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<DomainRecord> resolveDomain(String name) {
|
||||||
|
return request("GET", "/domains/" + encode(name) + "/resolve", null)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, DomainRecord.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Domain> renewDomain(String name, int years) {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("years", years);
|
||||||
|
return request("POST", "/domains/" + encode(name) + "/renew", body)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, Domain.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DNS Operations ====================
|
||||||
|
|
||||||
|
public CompletableFuture<DnsZone> getDnsZone(String domain) {
|
||||||
|
return request("GET", "/dns/" + encode(domain), null)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, DnsZone.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<DnsZone> setDnsRecords(String domain, List<DnsRecord> records) {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("records", records);
|
||||||
|
return request("PUT", "/dns/" + encode(domain), body)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, DnsZone.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<DnsZone> addDnsRecord(String domain, DnsRecord record) {
|
||||||
|
return request("POST", "/dns/" + encode(domain) + "/records", record)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, DnsZone.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<DnsZone> deleteDnsRecord(String domain, String recordType, String name) {
|
||||||
|
String path = "/dns/" + encode(domain) + "/records/" + recordType + "/" + encode(name);
|
||||||
|
return request("DELETE", path, null)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, DnsZone.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Deployment Operations ====================
|
||||||
|
|
||||||
|
public CompletableFuture<Deployment> deploy(String cid, DeployOptions options) {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("cid", cid);
|
||||||
|
if (options != null) {
|
||||||
|
if (options.getDomain() != null) body.put("domain", options.getDomain());
|
||||||
|
if (options.getSubdomain() != null) body.put("subdomain", options.getSubdomain());
|
||||||
|
if (options.getSpa() != null) body.put("spa", options.getSpa());
|
||||||
|
if (options.getCleanUrls() != null) body.put("cleanUrls", options.getCleanUrls());
|
||||||
|
}
|
||||||
|
return request("POST", "/deployments", body)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, Deployment.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Deployment> getDeployment(String id) {
|
||||||
|
return request("GET", "/deployments/" + encode(id), null)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, Deployment.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<List<Deployment>> listDeployments(String domain) {
|
||||||
|
String path = domain != null ? "/deployments?domain=" + encode(domain) : "/deployments";
|
||||||
|
return request("GET", path, null)
|
||||||
|
.thenApply(resp -> {
|
||||||
|
Type type = new TypeToken<DeploymentsResponse>(){}.getType();
|
||||||
|
DeploymentsResponse result = gson.fromJson(resp, type);
|
||||||
|
return result.deployments;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Deployment> rollback(String domain, String deploymentId) {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("domain", domain);
|
||||||
|
return request("POST", "/deployments/" + encode(deploymentId) + "/rollback", body)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, Deployment.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Void> deleteDeployment(String id) {
|
||||||
|
return request("DELETE", "/deployments/" + encode(id), null)
|
||||||
|
.thenApply(resp -> null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SSL Operations ====================
|
||||||
|
|
||||||
|
public CompletableFuture<Certificate> provisionSsl(String domain, ProvisionSslOptions options) {
|
||||||
|
return request("POST", "/ssl/" + encode(domain), options)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, Certificate.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Certificate> getCertificate(String domain) {
|
||||||
|
return request("GET", "/ssl/" + encode(domain), null)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, Certificate.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Certificate> renewCertificate(String domain) {
|
||||||
|
return request("POST", "/ssl/" + encode(domain) + "/renew", null)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, Certificate.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Void> deleteCertificate(String domain) {
|
||||||
|
return request("DELETE", "/ssl/" + encode(domain), null)
|
||||||
|
.thenApply(resp -> null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Site Configuration ====================
|
||||||
|
|
||||||
|
public CompletableFuture<SiteConfig> getSiteConfig(String domain) {
|
||||||
|
return request("GET", "/sites/" + encode(domain) + "/config", null)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, SiteConfig.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<SiteConfig> updateSiteConfig(String domain, Map<String, Object> config) {
|
||||||
|
return request("PATCH", "/sites/" + encode(domain) + "/config", config)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, SiteConfig.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Long> purgeCache(String domain, List<String> paths) {
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
if (paths != null) body.put("paths", paths);
|
||||||
|
return request("DELETE", "/sites/" + encode(domain) + "/cache", body)
|
||||||
|
.thenApply(resp -> {
|
||||||
|
Map<String, Object> map = gson.fromJson(resp, new TypeToken<Map<String, Object>>(){}.getType());
|
||||||
|
return ((Number) map.get("purged")).longValue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Analytics ====================
|
||||||
|
|
||||||
|
public CompletableFuture<AnalyticsData> getAnalytics(String domain, AnalyticsOptions options) {
|
||||||
|
StringBuilder path = new StringBuilder("/sites/" + encode(domain) + "/analytics");
|
||||||
|
if (options != null) {
|
||||||
|
StringBuilder params = new StringBuilder();
|
||||||
|
if (options.getPeriod() != null) params.append("period=").append(encode(options.getPeriod()));
|
||||||
|
if (options.getStart() != null) {
|
||||||
|
if (params.length() > 0) params.append("&");
|
||||||
|
params.append("start=").append(encode(options.getStart()));
|
||||||
|
}
|
||||||
|
if (options.getEnd() != null) {
|
||||||
|
if (params.length() > 0) params.append("&");
|
||||||
|
params.append("end=").append(encode(options.getEnd()));
|
||||||
|
}
|
||||||
|
if (params.length() > 0) path.append("?").append(params);
|
||||||
|
}
|
||||||
|
return request("GET", path.toString(), null)
|
||||||
|
.thenApply(resp -> gson.fromJson(resp, AnalyticsData.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Lifecycle ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
closed.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isClosed() {
|
||||||
|
return closed.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public CompletableFuture<Boolean> healthCheck() {
|
||||||
|
return request("GET", "/health", null)
|
||||||
|
.thenApply(resp -> {
|
||||||
|
Map<String, Object> map = gson.fromJson(resp, new TypeToken<Map<String, Object>>(){}.getType());
|
||||||
|
return "healthy".equals(map.get("status"));
|
||||||
|
})
|
||||||
|
.exceptionally(e -> false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Private Methods ====================
|
||||||
|
|
||||||
|
private CompletableFuture<String> request(String method, String path, Object body) {
|
||||||
|
if (closed.get()) {
|
||||||
|
return CompletableFuture.failedFuture(new HostingException("Client has been closed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return doRequest(method, path, body, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompletableFuture<String> doRequest(String method, String path, Object body, int attempt) {
|
||||||
|
String url = config.getEndpoint() + path;
|
||||||
|
|
||||||
|
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(url))
|
||||||
|
.header("Authorization", "Bearer " + config.getApiKey())
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("X-SDK-Version", "java/0.1.0")
|
||||||
|
.timeout(Duration.ofSeconds(config.getTimeoutSecs()));
|
||||||
|
|
||||||
|
HttpRequest.BodyPublisher bodyPublisher = body != null
|
||||||
|
? HttpRequest.BodyPublishers.ofString(gson.toJson(body))
|
||||||
|
: HttpRequest.BodyPublishers.noBody();
|
||||||
|
|
||||||
|
switch (method) {
|
||||||
|
case "GET": requestBuilder.GET(); break;
|
||||||
|
case "POST": requestBuilder.POST(bodyPublisher); break;
|
||||||
|
case "PUT": requestBuilder.PUT(bodyPublisher); break;
|
||||||
|
case "PATCH": requestBuilder.method("PATCH", bodyPublisher); break;
|
||||||
|
case "DELETE": requestBuilder.method("DELETE", bodyPublisher); break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return httpClient.sendAsync(requestBuilder.build(), HttpResponse.BodyHandlers.ofString())
|
||||||
|
.thenCompose(response -> {
|
||||||
|
if (response.statusCode() >= 400) {
|
||||||
|
Map<String, Object> errorBody = gson.fromJson(response.body(),
|
||||||
|
new TypeToken<Map<String, Object>>(){}.getType());
|
||||||
|
String message = errorBody != null && errorBody.get("message") != null
|
||||||
|
? (String) errorBody.get("message") : "HTTP " + response.statusCode();
|
||||||
|
return CompletableFuture.failedFuture(
|
||||||
|
new HostingException(message, (String) errorBody.get("code"), response.statusCode()));
|
||||||
|
}
|
||||||
|
return CompletableFuture.completedFuture(response.body());
|
||||||
|
})
|
||||||
|
.exceptionally(e -> {
|
||||||
|
if (attempt < config.getRetries() - 1) {
|
||||||
|
try { Thread.sleep((long) Math.pow(2, attempt) * 1000); }
|
||||||
|
catch (InterruptedException ie) { Thread.currentThread().interrupt(); }
|
||||||
|
return doRequest(method, path, body, attempt + 1).join();
|
||||||
|
}
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String encode(String value) {
|
||||||
|
return java.net.URLEncoder.encode(value, java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper response types
|
||||||
|
private static class DomainsResponse { List<Domain> domains; }
|
||||||
|
private static class DeploymentsResponse { List<Deployment> deployments; }
|
||||||
|
}
|
||||||
330
sdk/java/src/main/java/io/synor/hosting/Types.java
Normal file
330
sdk/java/src/main/java/io/synor/hosting/Types.java
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
package io.synor.hosting;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hosting SDK Types for Java.
|
||||||
|
*/
|
||||||
|
public class Types {
|
||||||
|
// Organizational class
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Config ====================
|
||||||
|
|
||||||
|
class HostingConfig {
|
||||||
|
private final String apiKey;
|
||||||
|
private final String endpoint;
|
||||||
|
private final int timeoutSecs;
|
||||||
|
private final int retries;
|
||||||
|
private final boolean debug;
|
||||||
|
|
||||||
|
public HostingConfig(String apiKey) {
|
||||||
|
this(apiKey, "https://hosting.synor.io/v1", 60, 3, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public HostingConfig(String apiKey, String endpoint, int timeoutSecs, int retries, boolean debug) {
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
this.endpoint = endpoint;
|
||||||
|
this.timeoutSecs = timeoutSecs;
|
||||||
|
this.retries = retries;
|
||||||
|
this.debug = debug;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getApiKey() { return apiKey; }
|
||||||
|
public String getEndpoint() { return endpoint; }
|
||||||
|
public int getTimeoutSecs() { return timeoutSecs; }
|
||||||
|
public int getRetries() { return retries; }
|
||||||
|
public boolean isDebug() { return debug; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Domain Types ====================
|
||||||
|
|
||||||
|
enum DomainStatus { pending, active, expired, suspended }
|
||||||
|
|
||||||
|
class Domain {
|
||||||
|
private String name;
|
||||||
|
private DomainStatus status;
|
||||||
|
private String owner;
|
||||||
|
private long registeredAt;
|
||||||
|
private long expiresAt;
|
||||||
|
private boolean autoRenew;
|
||||||
|
private DomainRecord records;
|
||||||
|
|
||||||
|
public String getName() { return name; }
|
||||||
|
public DomainStatus getStatus() { return status; }
|
||||||
|
public String getOwner() { return owner; }
|
||||||
|
public long getRegisteredAt() { return registeredAt; }
|
||||||
|
public long getExpiresAt() { return expiresAt; }
|
||||||
|
public boolean isAutoRenew() { return autoRenew; }
|
||||||
|
public DomainRecord getRecords() { return records; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class DomainRecord {
|
||||||
|
private String cid;
|
||||||
|
private List<String> ipv4;
|
||||||
|
private List<String> ipv6;
|
||||||
|
private String cname;
|
||||||
|
private List<String> txt;
|
||||||
|
private Map<String, String> metadata;
|
||||||
|
|
||||||
|
public String getCid() { return cid; }
|
||||||
|
public List<String> getIpv4() { return ipv4; }
|
||||||
|
public List<String> getIpv6() { return ipv6; }
|
||||||
|
public String getCname() { return cname; }
|
||||||
|
public List<String> getTxt() { return txt; }
|
||||||
|
public Map<String, String> getMetadata() { return metadata; }
|
||||||
|
|
||||||
|
public void setCid(String cid) { this.cid = cid; }
|
||||||
|
public void setIpv4(List<String> ipv4) { this.ipv4 = ipv4; }
|
||||||
|
public void setIpv6(List<String> ipv6) { this.ipv6 = ipv6; }
|
||||||
|
public void setCname(String cname) { this.cname = cname; }
|
||||||
|
public void setTxt(List<String> txt) { this.txt = txt; }
|
||||||
|
public void setMetadata(Map<String, String> metadata) { this.metadata = metadata; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class DomainAvailability {
|
||||||
|
private String name;
|
||||||
|
private boolean available;
|
||||||
|
private Double price;
|
||||||
|
private boolean premium;
|
||||||
|
|
||||||
|
public String getName() { return name; }
|
||||||
|
public boolean isAvailable() { return available; }
|
||||||
|
public Double getPrice() { return price; }
|
||||||
|
public boolean isPremium() { return premium; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class RegisterDomainOptions {
|
||||||
|
private Integer years;
|
||||||
|
private Boolean autoRenew;
|
||||||
|
|
||||||
|
public Integer getYears() { return years; }
|
||||||
|
public Boolean getAutoRenew() { return autoRenew; }
|
||||||
|
public void setYears(Integer years) { this.years = years; }
|
||||||
|
public void setAutoRenew(Boolean autoRenew) { this.autoRenew = autoRenew; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DNS Types ====================
|
||||||
|
|
||||||
|
enum DnsRecordType { A, AAAA, CNAME, TXT, MX, NS, SRV, CAA }
|
||||||
|
|
||||||
|
class DnsRecord {
|
||||||
|
private DnsRecordType type;
|
||||||
|
private String name;
|
||||||
|
private String value;
|
||||||
|
private int ttl = 3600;
|
||||||
|
private Integer priority;
|
||||||
|
|
||||||
|
public DnsRecordType getType() { return type; }
|
||||||
|
public String getName() { return name; }
|
||||||
|
public String getValue() { return value; }
|
||||||
|
public int getTtl() { return ttl; }
|
||||||
|
public Integer getPriority() { return priority; }
|
||||||
|
|
||||||
|
public void setType(DnsRecordType type) { this.type = type; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
public void setValue(String value) { this.value = value; }
|
||||||
|
public void setTtl(int ttl) { this.ttl = ttl; }
|
||||||
|
public void setPriority(Integer priority) { this.priority = priority; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class DnsZone {
|
||||||
|
private String domain;
|
||||||
|
private List<DnsRecord> records;
|
||||||
|
private long updatedAt;
|
||||||
|
|
||||||
|
public String getDomain() { return domain; }
|
||||||
|
public List<DnsRecord> getRecords() { return records; }
|
||||||
|
public long getUpdatedAt() { return updatedAt; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Deployment Types ====================
|
||||||
|
|
||||||
|
enum DeploymentStatus { pending, building, deploying, active, failed, inactive }
|
||||||
|
|
||||||
|
class Deployment {
|
||||||
|
private String id;
|
||||||
|
private String domain;
|
||||||
|
private String cid;
|
||||||
|
private DeploymentStatus status;
|
||||||
|
private String url;
|
||||||
|
private long createdAt;
|
||||||
|
private long updatedAt;
|
||||||
|
private String buildLogs;
|
||||||
|
private String errorMessage;
|
||||||
|
|
||||||
|
public String getId() { return id; }
|
||||||
|
public String getDomain() { return domain; }
|
||||||
|
public String getCid() { return cid; }
|
||||||
|
public DeploymentStatus getStatus() { return status; }
|
||||||
|
public String getUrl() { return url; }
|
||||||
|
public long getCreatedAt() { return createdAt; }
|
||||||
|
public long getUpdatedAt() { return updatedAt; }
|
||||||
|
public String getBuildLogs() { return buildLogs; }
|
||||||
|
public String getErrorMessage() { return errorMessage; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeployOptions {
|
||||||
|
private String domain;
|
||||||
|
private String subdomain;
|
||||||
|
private Boolean spa;
|
||||||
|
private Boolean cleanUrls;
|
||||||
|
private Boolean trailingSlash;
|
||||||
|
|
||||||
|
public String getDomain() { return domain; }
|
||||||
|
public String getSubdomain() { return subdomain; }
|
||||||
|
public Boolean getSpa() { return spa; }
|
||||||
|
public Boolean getCleanUrls() { return cleanUrls; }
|
||||||
|
public Boolean getTrailingSlash() { return trailingSlash; }
|
||||||
|
|
||||||
|
public void setDomain(String domain) { this.domain = domain; }
|
||||||
|
public void setSubdomain(String subdomain) { this.subdomain = subdomain; }
|
||||||
|
public void setSpa(Boolean spa) { this.spa = spa; }
|
||||||
|
public void setCleanUrls(Boolean cleanUrls) { this.cleanUrls = cleanUrls; }
|
||||||
|
public void setTrailingSlash(Boolean trailingSlash) { this.trailingSlash = trailingSlash; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SSL Types ====================
|
||||||
|
|
||||||
|
enum CertificateStatus { pending, issued, expired, revoked }
|
||||||
|
|
||||||
|
class Certificate {
|
||||||
|
private String domain;
|
||||||
|
private CertificateStatus status;
|
||||||
|
private boolean autoRenew;
|
||||||
|
private String issuer;
|
||||||
|
private Long issuedAt;
|
||||||
|
private Long expiresAt;
|
||||||
|
private String fingerprint;
|
||||||
|
|
||||||
|
public String getDomain() { return domain; }
|
||||||
|
public CertificateStatus getStatus() { return status; }
|
||||||
|
public boolean isAutoRenew() { return autoRenew; }
|
||||||
|
public String getIssuer() { return issuer; }
|
||||||
|
public Long getIssuedAt() { return issuedAt; }
|
||||||
|
public Long getExpiresAt() { return expiresAt; }
|
||||||
|
public String getFingerprint() { return fingerprint; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProvisionSslOptions {
|
||||||
|
private Boolean includeWww;
|
||||||
|
private Boolean autoRenew;
|
||||||
|
|
||||||
|
public Boolean getIncludeWww() { return includeWww; }
|
||||||
|
public Boolean getAutoRenew() { return autoRenew; }
|
||||||
|
public void setIncludeWww(Boolean includeWww) { this.includeWww = includeWww; }
|
||||||
|
public void setAutoRenew(Boolean autoRenew) { this.autoRenew = autoRenew; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Site Config ====================
|
||||||
|
|
||||||
|
class SiteConfig {
|
||||||
|
private String domain;
|
||||||
|
private String cid;
|
||||||
|
private Map<String, String> headers;
|
||||||
|
private List<RedirectRule> redirects;
|
||||||
|
private Map<String, String> errorPages;
|
||||||
|
private boolean spa;
|
||||||
|
private boolean cleanUrls;
|
||||||
|
private boolean trailingSlash;
|
||||||
|
|
||||||
|
public String getDomain() { return domain; }
|
||||||
|
public String getCid() { return cid; }
|
||||||
|
public Map<String, String> getHeaders() { return headers; }
|
||||||
|
public List<RedirectRule> getRedirects() { return redirects; }
|
||||||
|
public Map<String, String> getErrorPages() { return errorPages; }
|
||||||
|
public boolean isSpa() { return spa; }
|
||||||
|
public boolean isCleanUrls() { return cleanUrls; }
|
||||||
|
public boolean isTrailingSlash() { return trailingSlash; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class RedirectRule {
|
||||||
|
private String source;
|
||||||
|
private String destination;
|
||||||
|
private int statusCode = 301;
|
||||||
|
private boolean permanent = true;
|
||||||
|
|
||||||
|
public String getSource() { return source; }
|
||||||
|
public String getDestination() { return destination; }
|
||||||
|
public int getStatusCode() { return statusCode; }
|
||||||
|
public boolean isPermanent() { return permanent; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Analytics ====================
|
||||||
|
|
||||||
|
class AnalyticsData {
|
||||||
|
private String domain;
|
||||||
|
private String period;
|
||||||
|
private long pageViews;
|
||||||
|
private long uniqueVisitors;
|
||||||
|
private long bandwidth;
|
||||||
|
private List<PageView> topPages;
|
||||||
|
private List<Referrer> topReferrers;
|
||||||
|
private List<Country> topCountries;
|
||||||
|
|
||||||
|
public String getDomain() { return domain; }
|
||||||
|
public String getPeriod() { return period; }
|
||||||
|
public long getPageViews() { return pageViews; }
|
||||||
|
public long getUniqueVisitors() { return uniqueVisitors; }
|
||||||
|
public long getBandwidth() { return bandwidth; }
|
||||||
|
public List<PageView> getTopPages() { return topPages; }
|
||||||
|
public List<Referrer> getTopReferrers() { return topReferrers; }
|
||||||
|
public List<Country> getTopCountries() { return topCountries; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class PageView {
|
||||||
|
private String path;
|
||||||
|
private long views;
|
||||||
|
public String getPath() { return path; }
|
||||||
|
public long getViews() { return views; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class Referrer {
|
||||||
|
private String referrer;
|
||||||
|
private long count;
|
||||||
|
public String getReferrer() { return referrer; }
|
||||||
|
public long getCount() { return count; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class Country {
|
||||||
|
private String country;
|
||||||
|
private long count;
|
||||||
|
public String getCountry() { return country; }
|
||||||
|
public long getCount() { return count; }
|
||||||
|
}
|
||||||
|
|
||||||
|
class AnalyticsOptions {
|
||||||
|
private String period;
|
||||||
|
private String start;
|
||||||
|
private String end;
|
||||||
|
|
||||||
|
public String getPeriod() { return period; }
|
||||||
|
public String getStart() { return start; }
|
||||||
|
public String getEnd() { return end; }
|
||||||
|
public void setPeriod(String period) { this.period = period; }
|
||||||
|
public void setStart(String start) { this.start = start; }
|
||||||
|
public void setEnd(String end) { this.end = end; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Error ====================
|
||||||
|
|
||||||
|
class HostingException extends RuntimeException {
|
||||||
|
private final String code;
|
||||||
|
private final int statusCode;
|
||||||
|
|
||||||
|
public HostingException(String message) {
|
||||||
|
super(message);
|
||||||
|
this.code = null;
|
||||||
|
this.statusCode = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HostingException(String message, String code, int statusCode) {
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCode() { return code; }
|
||||||
|
public int getStatusCode() { return statusCode; }
|
||||||
|
}
|
||||||
226
sdk/java/src/main/java/io/synor/rpc/SynorRpc.java
Normal file
226
sdk/java/src/main/java/io/synor/rpc/SynorRpc.java
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
package io.synor.rpc;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.net.http.WebSocket;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synor RPC SDK for Java.
|
||||||
|
*
|
||||||
|
* Query blocks, transactions, and chain state with WebSocket subscription support.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* <pre>{@code
|
||||||
|
* SynorRpc rpc = new SynorRpc("your-api-key");
|
||||||
|
* Block block = rpc.getLatestBlock().join();
|
||||||
|
* System.out.println("Height: " + block.getHeight());
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
public class SynorRpc implements AutoCloseable {
|
||||||
|
private static final String DEFAULT_ENDPOINT = "https://rpc.synor.cc/api/v1";
|
||||||
|
private static final String DEFAULT_WS_ENDPOINT = "wss://rpc.synor.cc/ws";
|
||||||
|
|
||||||
|
private final RpcConfig config;
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
private final Gson gson;
|
||||||
|
private WebSocket webSocket;
|
||||||
|
private final Map<String, Consumer<String>> subscriptions = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public SynorRpc(String apiKey) {
|
||||||
|
this(RpcConfig.builder().apiKey(apiKey).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
public SynorRpc(RpcConfig config) {
|
||||||
|
this.config = config;
|
||||||
|
this.httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(config.getTimeoutSeconds()))
|
||||||
|
.build();
|
||||||
|
this.gson = new GsonBuilder()
|
||||||
|
.setFieldNamingPolicy(com.google.gson.FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
|
||||||
|
.create();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a block by hash. */
|
||||||
|
public CompletableFuture<Block> getBlockByHash(String hash) {
|
||||||
|
return get("/blocks/" + hash, Block.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a block by height. */
|
||||||
|
public CompletableFuture<Block> getBlockByHeight(long height) {
|
||||||
|
return get("/blocks/height/" + height, Block.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the latest block. */
|
||||||
|
public CompletableFuture<Block> getLatestBlock() {
|
||||||
|
return get("/blocks/latest", Block.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a block header by hash. */
|
||||||
|
public CompletableFuture<BlockHeader> getBlockHeaderByHash(String hash) {
|
||||||
|
return get("/blocks/" + hash + "/header", BlockHeaderResponse.class)
|
||||||
|
.thenApply(BlockHeaderResponse::getHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a transaction by ID. */
|
||||||
|
public CompletableFuture<Transaction> getTransaction(String txid) {
|
||||||
|
return get("/transactions/" + txid, Transaction.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a raw transaction. */
|
||||||
|
public CompletableFuture<String> sendRawTransaction(String rawTx) {
|
||||||
|
var body = Map.of("raw", rawTx);
|
||||||
|
return post("/transactions", body, SendTxResponse.class)
|
||||||
|
.thenApply(SendTxResponse::getTxid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Estimate transaction fee. */
|
||||||
|
public CompletableFuture<FeeEstimate> estimateFee(Priority priority) {
|
||||||
|
return get("/fees/estimate?priority=" + priority.getValue(), FeeEstimate.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get chain information. */
|
||||||
|
public CompletableFuture<ChainInfo> getChainInfo() {
|
||||||
|
return get("/chain", ChainInfo.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get mempool information. */
|
||||||
|
public CompletableFuture<MempoolInfo> getMempoolInfo() {
|
||||||
|
return get("/mempool", MempoolInfo.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get UTXOs for an address. */
|
||||||
|
public CompletableFuture<UTXO[]> getUTXOs(String address) {
|
||||||
|
return get("/addresses/" + address + "/utxos", UTXOsResponse.class)
|
||||||
|
.thenApply(UTXOsResponse::getUtxos);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get balance for an address. */
|
||||||
|
public CompletableFuture<Balance> getBalance(String address) {
|
||||||
|
return get("/addresses/" + address + "/balance", Balance.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to new blocks. */
|
||||||
|
public CompletableFuture<Subscription> subscribeBlocks(Consumer<Block> callback) {
|
||||||
|
return subscribe("blocks", null, data -> {
|
||||||
|
Block block = gson.fromJson(data, BlockNotification.class).getBlock();
|
||||||
|
callback.accept(block);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to address transactions. */
|
||||||
|
public CompletableFuture<Subscription> subscribeAddress(String address, Consumer<Transaction> callback) {
|
||||||
|
return subscribe("address", Map.of("address", address), data -> {
|
||||||
|
Transaction tx = gson.fromJson(data, TransactionNotification.class).getTransaction();
|
||||||
|
callback.accept(tx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompletableFuture<Subscription> subscribe(String type, Map<String, String> params, Consumer<String> handler) {
|
||||||
|
return ensureWebSocket().thenApply(ws -> {
|
||||||
|
String subId = type + "_" + System.currentTimeMillis();
|
||||||
|
subscriptions.put(subId, handler);
|
||||||
|
|
||||||
|
var msg = new java.util.HashMap<String, Object>();
|
||||||
|
msg.put("type", "subscribe");
|
||||||
|
msg.put("id", subId);
|
||||||
|
msg.put("subscription", type);
|
||||||
|
if (params != null) msg.putAll(params);
|
||||||
|
|
||||||
|
ws.sendText(gson.toJson(msg), true);
|
||||||
|
|
||||||
|
return new Subscription(subId, type, System.currentTimeMillis(), () -> {
|
||||||
|
subscriptions.remove(subId);
|
||||||
|
ws.sendText(gson.toJson(Map.of("type", "unsubscribe", "id", subId)), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompletableFuture<WebSocket> ensureWebSocket() {
|
||||||
|
if (webSocket != null) {
|
||||||
|
return CompletableFuture.completedFuture(webSocket);
|
||||||
|
}
|
||||||
|
|
||||||
|
String wsUrl = config.getWsEndpoint() + "?apiKey=" + config.getApiKey() + "&network=" + config.getNetwork().getValue();
|
||||||
|
|
||||||
|
return httpClient.newWebSocketBuilder()
|
||||||
|
.buildAsync(URI.create(wsUrl), new WebSocket.Listener() {
|
||||||
|
@Override
|
||||||
|
public CompletionStage<?> onText(WebSocket ws, CharSequence data, boolean last) {
|
||||||
|
var msg = gson.fromJson(data.toString(), WsMessage.class);
|
||||||
|
if (msg.subscriptionId != null && subscriptions.containsKey(msg.subscriptionId)) {
|
||||||
|
subscriptions.get(msg.subscriptionId).accept(data.toString());
|
||||||
|
}
|
||||||
|
return WebSocket.Listener.super.onText(ws, data, last);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.thenApply(ws -> {
|
||||||
|
this.webSocket = ws;
|
||||||
|
return ws;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> CompletableFuture<T> get(String path, Class<T> responseType) {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(config.getEndpoint() + path))
|
||||||
|
.header("Authorization", "Bearer " + config.getApiKey())
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("X-Network", config.getNetwork().getValue())
|
||||||
|
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||||
|
.thenApply(response -> handleResponse(response, responseType));
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> CompletableFuture<T> post(String path, Object body, Class<T> responseType) {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(config.getEndpoint() + path))
|
||||||
|
.header("Authorization", "Bearer " + config.getApiKey())
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("X-Network", config.getNetwork().getValue())
|
||||||
|
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(gson.toJson(body)))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||||
|
.thenApply(response -> handleResponse(response, responseType));
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> T handleResponse(HttpResponse<String> response, Class<T> responseType) {
|
||||||
|
if (response.statusCode() >= 400) {
|
||||||
|
ErrorResponse error = gson.fromJson(response.body(), ErrorResponse.class);
|
||||||
|
throw new RpcException(
|
||||||
|
error != null ? error.getMessage() : "Unknown error",
|
||||||
|
response.statusCode(),
|
||||||
|
error != null ? error.getCode() : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return gson.fromJson(response.body(), responseType);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
if (webSocket != null) {
|
||||||
|
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "closing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal types
|
||||||
|
private static class WsMessage { String subscriptionId; String data; }
|
||||||
|
private record BlockHeaderResponse(BlockHeader header) { BlockHeader getHeader() { return header; } }
|
||||||
|
private record SendTxResponse(String txid) { String getTxid() { return txid; } }
|
||||||
|
private record UTXOsResponse(UTXO[] utxos) { UTXO[] getUtxos() { return utxos; } }
|
||||||
|
private record BlockNotification(Block block) { Block getBlock() { return block; } }
|
||||||
|
private record TransactionNotification(Transaction transaction) { Transaction getTransaction() { return transaction; } }
|
||||||
|
private record ErrorResponse(String message, String code) { String getMessage() { return message; } String getCode() { return code; } }
|
||||||
|
}
|
||||||
240
sdk/java/src/main/java/io/synor/rpc/Types.java
Normal file
240
sdk/java/src/main/java/io/synor/rpc/Types.java
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
package io.synor.rpc;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CompletionStage;
|
||||||
|
|
||||||
|
/** Network type. */
|
||||||
|
public enum Network {
|
||||||
|
MAINNET("mainnet"), TESTNET("testnet");
|
||||||
|
private final String value;
|
||||||
|
Network(String value) { this.value = value; }
|
||||||
|
public String getValue() { return value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transaction priority. */
|
||||||
|
public enum Priority {
|
||||||
|
LOW("low"), MEDIUM("medium"), HIGH("high"), URGENT("urgent");
|
||||||
|
private final String value;
|
||||||
|
Priority(String value) { this.value = value; }
|
||||||
|
public String getValue() { return value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transaction status. */
|
||||||
|
public enum TransactionStatus {
|
||||||
|
PENDING("pending"), CONFIRMED("confirmed"), FAILED("failed"), REPLACED("replaced");
|
||||||
|
private final String value;
|
||||||
|
TransactionStatus(String value) { this.value = value; }
|
||||||
|
public String getValue() { return value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RPC configuration. */
|
||||||
|
class RpcConfig {
|
||||||
|
private final String apiKey;
|
||||||
|
private final String endpoint;
|
||||||
|
private final String wsEndpoint;
|
||||||
|
private final Network network;
|
||||||
|
private final long timeoutSeconds;
|
||||||
|
private final boolean debug;
|
||||||
|
|
||||||
|
private RpcConfig(Builder builder) {
|
||||||
|
this.apiKey = builder.apiKey;
|
||||||
|
this.endpoint = builder.endpoint;
|
||||||
|
this.wsEndpoint = builder.wsEndpoint;
|
||||||
|
this.network = builder.network;
|
||||||
|
this.timeoutSeconds = builder.timeoutSeconds;
|
||||||
|
this.debug = builder.debug;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getApiKey() { return apiKey; }
|
||||||
|
public String getEndpoint() { return endpoint; }
|
||||||
|
public String getWsEndpoint() { return wsEndpoint; }
|
||||||
|
public Network getNetwork() { return network; }
|
||||||
|
public long getTimeoutSeconds() { return timeoutSeconds; }
|
||||||
|
public boolean isDebug() { return debug; }
|
||||||
|
|
||||||
|
public static Builder builder() { return new Builder(); }
|
||||||
|
|
||||||
|
public static class Builder {
|
||||||
|
private String apiKey;
|
||||||
|
private String endpoint = "https://rpc.synor.cc/api/v1";
|
||||||
|
private String wsEndpoint = "wss://rpc.synor.cc/ws";
|
||||||
|
private Network network = Network.MAINNET;
|
||||||
|
private long timeoutSeconds = 30;
|
||||||
|
private boolean debug = false;
|
||||||
|
|
||||||
|
public Builder apiKey(String apiKey) { this.apiKey = apiKey; return this; }
|
||||||
|
public Builder endpoint(String endpoint) { this.endpoint = endpoint; return this; }
|
||||||
|
public Builder wsEndpoint(String wsEndpoint) { this.wsEndpoint = wsEndpoint; return this; }
|
||||||
|
public Builder network(Network network) { this.network = network; return this; }
|
||||||
|
public Builder timeoutSeconds(long seconds) { this.timeoutSeconds = seconds; return this; }
|
||||||
|
public Builder debug(boolean debug) { this.debug = debug; return this; }
|
||||||
|
public RpcConfig build() { return new RpcConfig(this); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Block header. */
|
||||||
|
class BlockHeader {
|
||||||
|
private String hash;
|
||||||
|
private long height;
|
||||||
|
private int version;
|
||||||
|
private String previousHash;
|
||||||
|
private String merkleRoot;
|
||||||
|
private long timestamp;
|
||||||
|
private String difficulty;
|
||||||
|
private long nonce;
|
||||||
|
|
||||||
|
public String getHash() { return hash; }
|
||||||
|
public long getHeight() { return height; }
|
||||||
|
public int getVersion() { return version; }
|
||||||
|
public String getPreviousHash() { return previousHash; }
|
||||||
|
public String getMerkleRoot() { return merkleRoot; }
|
||||||
|
public long getTimestamp() { return timestamp; }
|
||||||
|
public String getDifficulty() { return difficulty; }
|
||||||
|
public long getNonce() { return nonce; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full block. */
|
||||||
|
class Block extends BlockHeader {
|
||||||
|
private List<String> transactions;
|
||||||
|
private int size;
|
||||||
|
private int weight;
|
||||||
|
private int txCount;
|
||||||
|
|
||||||
|
public List<String> getTransactions() { return transactions; }
|
||||||
|
public int getSize() { return size; }
|
||||||
|
public int getWeight() { return weight; }
|
||||||
|
public int getTxCount() { return txCount; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transaction. */
|
||||||
|
class Transaction {
|
||||||
|
private String txid;
|
||||||
|
private String blockHash;
|
||||||
|
private long blockHeight;
|
||||||
|
private int confirmations;
|
||||||
|
private long timestamp;
|
||||||
|
private TransactionStatus status;
|
||||||
|
private String raw;
|
||||||
|
private int size;
|
||||||
|
private String fee;
|
||||||
|
|
||||||
|
public String getTxid() { return txid; }
|
||||||
|
public String getBlockHash() { return blockHash; }
|
||||||
|
public long getBlockHeight() { return blockHeight; }
|
||||||
|
public int getConfirmations() { return confirmations; }
|
||||||
|
public long getTimestamp() { return timestamp; }
|
||||||
|
public TransactionStatus getStatus() { return status; }
|
||||||
|
public String getRaw() { return raw; }
|
||||||
|
public int getSize() { return size; }
|
||||||
|
public String getFee() { return fee; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fee estimate. */
|
||||||
|
class FeeEstimate {
|
||||||
|
private Priority priority;
|
||||||
|
private String feeRate;
|
||||||
|
private int estimatedBlocks;
|
||||||
|
|
||||||
|
public Priority getPriority() { return priority; }
|
||||||
|
public String getFeeRate() { return feeRate; }
|
||||||
|
public int getEstimatedBlocks() { return estimatedBlocks; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chain information. */
|
||||||
|
class ChainInfo {
|
||||||
|
private String chain;
|
||||||
|
private String network;
|
||||||
|
private long height;
|
||||||
|
private String bestBlockHash;
|
||||||
|
private String difficulty;
|
||||||
|
private long medianTime;
|
||||||
|
private String chainWork;
|
||||||
|
private boolean syncing;
|
||||||
|
private double syncProgress;
|
||||||
|
|
||||||
|
public String getChain() { return chain; }
|
||||||
|
public String getNetwork() { return network; }
|
||||||
|
public long getHeight() { return height; }
|
||||||
|
public String getBestBlockHash() { return bestBlockHash; }
|
||||||
|
public String getDifficulty() { return difficulty; }
|
||||||
|
public long getMedianTime() { return medianTime; }
|
||||||
|
public String getChainWork() { return chainWork; }
|
||||||
|
public boolean isSyncing() { return syncing; }
|
||||||
|
public double getSyncProgress() { return syncProgress; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mempool information. */
|
||||||
|
class MempoolInfo {
|
||||||
|
private int size;
|
||||||
|
private long bytes;
|
||||||
|
private long usage;
|
||||||
|
private long maxMempool;
|
||||||
|
private String minFee;
|
||||||
|
|
||||||
|
public int getSize() { return size; }
|
||||||
|
public long getBytes() { return bytes; }
|
||||||
|
public long getUsage() { return usage; }
|
||||||
|
public long getMaxMempool() { return maxMempool; }
|
||||||
|
public String getMinFee() { return minFee; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UTXO. */
|
||||||
|
class UTXO {
|
||||||
|
private String txid;
|
||||||
|
private int vout;
|
||||||
|
private String amount;
|
||||||
|
private String address;
|
||||||
|
private int confirmations;
|
||||||
|
|
||||||
|
public String getTxid() { return txid; }
|
||||||
|
public int getVout() { return vout; }
|
||||||
|
public String getAmount() { return amount; }
|
||||||
|
public String getAddress() { return address; }
|
||||||
|
public int getConfirmations() { return confirmations; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Balance. */
|
||||||
|
class Balance {
|
||||||
|
private String confirmed;
|
||||||
|
private String unconfirmed;
|
||||||
|
private String total;
|
||||||
|
|
||||||
|
public String getConfirmed() { return confirmed; }
|
||||||
|
public String getUnconfirmed() { return unconfirmed; }
|
||||||
|
public String getTotal() { return total; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscription. */
|
||||||
|
class Subscription {
|
||||||
|
private final String id;
|
||||||
|
private final String type;
|
||||||
|
private final long createdAt;
|
||||||
|
private final Runnable unsubscribe;
|
||||||
|
|
||||||
|
public Subscription(String id, String type, long createdAt, Runnable unsubscribe) {
|
||||||
|
this.id = id;
|
||||||
|
this.type = type;
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
this.unsubscribe = unsubscribe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getId() { return id; }
|
||||||
|
public String getType() { return type; }
|
||||||
|
public long getCreatedAt() { return createdAt; }
|
||||||
|
public void unsubscribe() { unsubscribe.run(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RPC exception. */
|
||||||
|
class RpcException extends RuntimeException {
|
||||||
|
private final int statusCode;
|
||||||
|
private final String code;
|
||||||
|
|
||||||
|
public RpcException(String message, int statusCode, String code) {
|
||||||
|
super(message);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getStatusCode() { return statusCode; }
|
||||||
|
public String getCode() { return code; }
|
||||||
|
}
|
||||||
237
sdk/java/src/main/java/io/synor/storage/SynorStorage.java
Normal file
237
sdk/java/src/main/java/io/synor/storage/SynorStorage.java
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
package io.synor.storage;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synor Storage SDK for Java.
|
||||||
|
*
|
||||||
|
* Decentralized storage, pinning, and content retrieval.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* <pre>{@code
|
||||||
|
* SynorStorage storage = new SynorStorage("your-api-key");
|
||||||
|
* UploadResponse result = storage.upload("Hello, World!".getBytes()).join();
|
||||||
|
* System.out.println("CID: " + result.getCid());
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
public class SynorStorage {
|
||||||
|
private static final String DEFAULT_ENDPOINT = "https://storage.synor.cc/api/v1";
|
||||||
|
private static final String DEFAULT_GATEWAY = "https://gateway.synor.cc";
|
||||||
|
|
||||||
|
private final StorageConfig config;
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
private final Gson gson;
|
||||||
|
|
||||||
|
public SynorStorage(String apiKey) {
|
||||||
|
this(StorageConfig.builder().apiKey(apiKey).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
public SynorStorage(StorageConfig config) {
|
||||||
|
this.config = config;
|
||||||
|
this.httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(config.getTimeoutSeconds()))
|
||||||
|
.build();
|
||||||
|
this.gson = new GsonBuilder()
|
||||||
|
.setFieldNamingPolicy(com.google.gson.FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
|
||||||
|
.create();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload content. */
|
||||||
|
public CompletableFuture<UploadResponse> upload(byte[] data) {
|
||||||
|
return upload(data, UploadOptions.defaults());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload content with options. */
|
||||||
|
public CompletableFuture<UploadResponse> upload(byte[] data, UploadOptions options) {
|
||||||
|
String boundary = "----SynorBoundary" + System.currentTimeMillis();
|
||||||
|
String body = "--" + boundary + "\r\n"
|
||||||
|
+ "Content-Disposition: form-data; name=\"file\"; filename=\"file\"\r\n"
|
||||||
|
+ "Content-Type: application/octet-stream\r\n\r\n"
|
||||||
|
+ new String(data) + "\r\n"
|
||||||
|
+ "--" + boundary + "--\r\n";
|
||||||
|
|
||||||
|
StringBuilder path = new StringBuilder("/upload?");
|
||||||
|
path.append("pin=").append(options.isPin());
|
||||||
|
if (options.isWrapWithDirectory()) path.append("&wrapWithDirectory=true");
|
||||||
|
path.append("&cidVersion=").append(options.getCidVersion());
|
||||||
|
path.append("&hashAlgorithm=").append(options.getHashAlgorithm().getValue());
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(config.getEndpoint() + path))
|
||||||
|
.header("Authorization", "Bearer " + config.getApiKey())
|
||||||
|
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
|
||||||
|
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||||
|
.thenApply(response -> handleResponse(response, UploadResponse.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download content by CID. */
|
||||||
|
public CompletableFuture<byte[]> download(String cid) {
|
||||||
|
return download(cid, DownloadOptions.defaults());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download content with options. */
|
||||||
|
public CompletableFuture<byte[]> download(String cid, DownloadOptions options) {
|
||||||
|
StringBuilder path = new StringBuilder("/content/" + cid);
|
||||||
|
if (options.getOffset() != null || options.getLength() != null) {
|
||||||
|
path.append("?");
|
||||||
|
if (options.getOffset() != null) path.append("offset=").append(options.getOffset()).append("&");
|
||||||
|
if (options.getLength() != null) path.append("length=").append(options.getLength());
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(config.getEndpoint() + path))
|
||||||
|
.header("Authorization", "Bearer " + config.getApiKey())
|
||||||
|
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofByteArray())
|
||||||
|
.thenApply(response -> {
|
||||||
|
if (response.statusCode() >= 400) {
|
||||||
|
throw new StorageException("Download failed", response.statusCode(), null);
|
||||||
|
}
|
||||||
|
return response.body();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pin content. */
|
||||||
|
public CompletableFuture<Pin> pin(PinRequest pinRequest) {
|
||||||
|
return post("/pins", pinRequest, Pin.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unpin content. */
|
||||||
|
public CompletableFuture<Void> unpin(String cid) {
|
||||||
|
return delete("/pins/" + cid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get pin status. */
|
||||||
|
public CompletableFuture<Pin> getPinStatus(String cid) {
|
||||||
|
return get("/pins/" + cid, Pin.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List pins. */
|
||||||
|
public CompletableFuture<ListPinsResponse> listPins(ListPinsOptions options) {
|
||||||
|
StringBuilder path = new StringBuilder("/pins?");
|
||||||
|
if (options.getLimit() != null) path.append("limit=").append(options.getLimit()).append("&");
|
||||||
|
if (options.getOffset() != null) path.append("offset=").append(options.getOffset());
|
||||||
|
return get(path.toString(), ListPinsResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get gateway URL. */
|
||||||
|
public GatewayUrl getGatewayUrl(String cid, String path) {
|
||||||
|
String fullPath = path != null ? "/" + cid + "/" + path : "/" + cid;
|
||||||
|
return new GatewayUrl(config.getGateway() + "/ipfs" + fullPath, cid, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create CAR file. */
|
||||||
|
public CompletableFuture<CarFile> createCar(List<FileEntry> files) {
|
||||||
|
return post("/car/create", Map.of("files", files), CarFile.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import CAR file. */
|
||||||
|
public CompletableFuture<ImportCarResponse> importCar(byte[] carData, boolean pin) {
|
||||||
|
String encoded = Base64.getEncoder().encodeToString(carData);
|
||||||
|
return post("/car/import", Map.of("car", encoded, "pin", pin), ImportCarResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create directory. */
|
||||||
|
public CompletableFuture<UploadResponse> createDirectory(List<FileEntry> files) {
|
||||||
|
return post("/directory", Map.of("files", files), UploadResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List directory. */
|
||||||
|
public CompletableFuture<List<DirectoryEntry>> listDirectory(String cid, String path) {
|
||||||
|
String apiPath = "/directory/" + cid + (path != null ? "?path=" + path : "");
|
||||||
|
return get(apiPath, ListDirectoryResponse.class)
|
||||||
|
.thenApply(ListDirectoryResponse::getEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get storage stats. */
|
||||||
|
public CompletableFuture<StorageStats> getStats() {
|
||||||
|
return get("/stats", StorageStats.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if content exists. */
|
||||||
|
public CompletableFuture<Boolean> exists(String cid) {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(config.getEndpoint() + "/content/" + cid))
|
||||||
|
.header("Authorization", "Bearer " + config.getApiKey())
|
||||||
|
.method("HEAD", HttpRequest.BodyPublishers.noBody())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.discarding())
|
||||||
|
.thenApply(response -> response.statusCode() == 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> CompletableFuture<T> get(String path, Class<T> responseType) {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(config.getEndpoint() + path))
|
||||||
|
.header("Authorization", "Bearer " + config.getApiKey())
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||||
|
.thenApply(response -> handleResponse(response, responseType));
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> CompletableFuture<T> post(String path, Object body, Class<T> responseType) {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(config.getEndpoint() + path))
|
||||||
|
.header("Authorization", "Bearer " + config.getApiKey())
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(gson.toJson(body)))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||||
|
.thenApply(response -> handleResponse(response, responseType));
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompletableFuture<Void> delete(String path) {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(config.getEndpoint() + path))
|
||||||
|
.header("Authorization", "Bearer " + config.getApiKey())
|
||||||
|
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
|
||||||
|
.DELETE()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.discarding())
|
||||||
|
.thenApply(response -> {
|
||||||
|
if (response.statusCode() >= 400) {
|
||||||
|
throw new StorageException("Delete failed", response.statusCode(), null);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> T handleResponse(HttpResponse<String> response, Class<T> responseType) {
|
||||||
|
if (response.statusCode() >= 400) {
|
||||||
|
ErrorResponse error = gson.fromJson(response.body(), ErrorResponse.class);
|
||||||
|
throw new StorageException(
|
||||||
|
error != null ? error.getMessage() : "Unknown error",
|
||||||
|
response.statusCode(),
|
||||||
|
error != null ? error.getCode() : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return gson.fromJson(response.body(), responseType);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ListDirectoryResponse(List<DirectoryEntry> entries) { List<DirectoryEntry> getEntries() { return entries; } }
|
||||||
|
private record ErrorResponse(String message, String code) { String getMessage() { return message; } String getCode() { return code; } }
|
||||||
|
}
|
||||||
285
sdk/java/src/main/java/io/synor/storage/Types.java
Normal file
285
sdk/java/src/main/java/io/synor/storage/Types.java
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
package io.synor.storage;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Pin status. */
|
||||||
|
public enum PinStatus {
|
||||||
|
QUEUED("queued"), PINNING("pinning"), PINNED("pinned"), FAILED("failed"), UNPINNED("unpinned");
|
||||||
|
private final String value;
|
||||||
|
PinStatus(String value) { this.value = value; }
|
||||||
|
public String getValue() { return value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hash algorithm. */
|
||||||
|
public enum HashAlgorithm {
|
||||||
|
SHA2_256("sha2-256"), BLAKE3("blake3");
|
||||||
|
private final String value;
|
||||||
|
HashAlgorithm(String value) { this.value = value; }
|
||||||
|
public String getValue() { return value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entry type. */
|
||||||
|
public enum EntryType {
|
||||||
|
FILE("file"), DIRECTORY("directory");
|
||||||
|
private final String value;
|
||||||
|
EntryType(String value) { this.value = value; }
|
||||||
|
public String getValue() { return value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Storage configuration. */
|
||||||
|
class StorageConfig {
|
||||||
|
private final String apiKey;
|
||||||
|
private final String endpoint;
|
||||||
|
private final String gateway;
|
||||||
|
private final long timeoutSeconds;
|
||||||
|
private final boolean debug;
|
||||||
|
|
||||||
|
private StorageConfig(Builder builder) {
|
||||||
|
this.apiKey = builder.apiKey;
|
||||||
|
this.endpoint = builder.endpoint;
|
||||||
|
this.gateway = builder.gateway;
|
||||||
|
this.timeoutSeconds = builder.timeoutSeconds;
|
||||||
|
this.debug = builder.debug;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getApiKey() { return apiKey; }
|
||||||
|
public String getEndpoint() { return endpoint; }
|
||||||
|
public String getGateway() { return gateway; }
|
||||||
|
public long getTimeoutSeconds() { return timeoutSeconds; }
|
||||||
|
public boolean isDebug() { return debug; }
|
||||||
|
|
||||||
|
public static Builder builder() { return new Builder(); }
|
||||||
|
|
||||||
|
public static class Builder {
|
||||||
|
private String apiKey;
|
||||||
|
private String endpoint = "https://storage.synor.cc/api/v1";
|
||||||
|
private String gateway = "https://gateway.synor.cc";
|
||||||
|
private long timeoutSeconds = 30;
|
||||||
|
private boolean debug = false;
|
||||||
|
|
||||||
|
public Builder apiKey(String apiKey) { this.apiKey = apiKey; return this; }
|
||||||
|
public Builder endpoint(String endpoint) { this.endpoint = endpoint; return this; }
|
||||||
|
public Builder gateway(String gateway) { this.gateway = gateway; return this; }
|
||||||
|
public Builder timeoutSeconds(long seconds) { this.timeoutSeconds = seconds; return this; }
|
||||||
|
public Builder debug(boolean debug) { this.debug = debug; return this; }
|
||||||
|
public StorageConfig build() { return new StorageConfig(this); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload options. */
|
||||||
|
class UploadOptions {
|
||||||
|
private boolean pin = true;
|
||||||
|
private boolean wrapWithDirectory = false;
|
||||||
|
private int cidVersion = 1;
|
||||||
|
private HashAlgorithm hashAlgorithm = HashAlgorithm.SHA2_256;
|
||||||
|
|
||||||
|
public static UploadOptions defaults() { return new UploadOptions(); }
|
||||||
|
|
||||||
|
public boolean isPin() { return pin; }
|
||||||
|
public boolean isWrapWithDirectory() { return wrapWithDirectory; }
|
||||||
|
public int getCidVersion() { return cidVersion; }
|
||||||
|
public HashAlgorithm getHashAlgorithm() { return hashAlgorithm; }
|
||||||
|
|
||||||
|
public UploadOptions pin(boolean pin) { this.pin = pin; return this; }
|
||||||
|
public UploadOptions wrapWithDirectory(boolean wrap) { this.wrapWithDirectory = wrap; return this; }
|
||||||
|
public UploadOptions cidVersion(int version) { this.cidVersion = version; return this; }
|
||||||
|
public UploadOptions hashAlgorithm(HashAlgorithm algo) { this.hashAlgorithm = algo; return this; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download options. */
|
||||||
|
class DownloadOptions {
|
||||||
|
private Long offset;
|
||||||
|
private Long length;
|
||||||
|
|
||||||
|
public static DownloadOptions defaults() { return new DownloadOptions(); }
|
||||||
|
|
||||||
|
public Long getOffset() { return offset; }
|
||||||
|
public Long getLength() { return length; }
|
||||||
|
|
||||||
|
public DownloadOptions offset(long offset) { this.offset = offset; return this; }
|
||||||
|
public DownloadOptions length(long length) { this.length = length; return this; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload response. */
|
||||||
|
class UploadResponse {
|
||||||
|
private String cid;
|
||||||
|
private long size;
|
||||||
|
private String name;
|
||||||
|
private String hash;
|
||||||
|
|
||||||
|
public String getCid() { return cid; }
|
||||||
|
public long getSize() { return size; }
|
||||||
|
public String getName() { return name; }
|
||||||
|
public String getHash() { return hash; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pin. */
|
||||||
|
class Pin {
|
||||||
|
private String cid;
|
||||||
|
private PinStatus status;
|
||||||
|
private String name;
|
||||||
|
private Long size;
|
||||||
|
private Long createdAt;
|
||||||
|
private Long expiresAt;
|
||||||
|
private List<String> delegates;
|
||||||
|
|
||||||
|
public String getCid() { return cid; }
|
||||||
|
public PinStatus getStatus() { return status; }
|
||||||
|
public String getName() { return name; }
|
||||||
|
public Long getSize() { return size; }
|
||||||
|
public Long getCreatedAt() { return createdAt; }
|
||||||
|
public Long getExpiresAt() { return expiresAt; }
|
||||||
|
public List<String> getDelegates() { return delegates; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pin request. */
|
||||||
|
class PinRequest {
|
||||||
|
private String cid;
|
||||||
|
private String name;
|
||||||
|
private Long duration;
|
||||||
|
private List<String> origins;
|
||||||
|
|
||||||
|
public PinRequest(String cid) { this.cid = cid; }
|
||||||
|
|
||||||
|
public PinRequest name(String name) { this.name = name; return this; }
|
||||||
|
public PinRequest duration(long duration) { this.duration = duration; return this; }
|
||||||
|
public PinRequest origins(List<String> origins) { this.origins = origins; return this; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List pins options. */
|
||||||
|
class ListPinsOptions {
|
||||||
|
private Integer limit;
|
||||||
|
private Integer offset;
|
||||||
|
private List<PinStatus> status;
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
public Integer getLimit() { return limit; }
|
||||||
|
public Integer getOffset() { return offset; }
|
||||||
|
|
||||||
|
public ListPinsOptions limit(int limit) { this.limit = limit; return this; }
|
||||||
|
public ListPinsOptions offset(int offset) { this.offset = offset; return this; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List pins response. */
|
||||||
|
class ListPinsResponse {
|
||||||
|
private List<Pin> pins;
|
||||||
|
private int total;
|
||||||
|
private boolean hasMore;
|
||||||
|
|
||||||
|
public List<Pin> getPins() { return pins; }
|
||||||
|
public int getTotal() { return total; }
|
||||||
|
public boolean isHasMore() { return hasMore; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gateway URL. */
|
||||||
|
class GatewayUrl {
|
||||||
|
private final String url;
|
||||||
|
private final String cid;
|
||||||
|
private final String path;
|
||||||
|
|
||||||
|
public GatewayUrl(String url, String cid, String path) {
|
||||||
|
this.url = url;
|
||||||
|
this.cid = cid;
|
||||||
|
this.path = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUrl() { return url; }
|
||||||
|
public String getCid() { return cid; }
|
||||||
|
public String getPath() { return path; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** File entry. */
|
||||||
|
class FileEntry {
|
||||||
|
private String name;
|
||||||
|
private String content;
|
||||||
|
private String cid;
|
||||||
|
|
||||||
|
public FileEntry(String name) { this.name = name; }
|
||||||
|
|
||||||
|
public FileEntry content(byte[] content) {
|
||||||
|
this.content = java.util.Base64.getEncoder().encodeToString(content);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
public FileEntry cid(String cid) { this.cid = cid; return this; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Directory entry. */
|
||||||
|
class DirectoryEntry {
|
||||||
|
private String name;
|
||||||
|
private String cid;
|
||||||
|
private Long size;
|
||||||
|
private EntryType type;
|
||||||
|
|
||||||
|
public String getName() { return name; }
|
||||||
|
public String getCid() { return cid; }
|
||||||
|
public Long getSize() { return size; }
|
||||||
|
public EntryType getType() { return type; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CAR block. */
|
||||||
|
class CarBlock {
|
||||||
|
private String cid;
|
||||||
|
private String data;
|
||||||
|
private Long size;
|
||||||
|
|
||||||
|
public String getCid() { return cid; }
|
||||||
|
public String getData() { return data; }
|
||||||
|
public Long getSize() { return size; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CAR file. */
|
||||||
|
class CarFile {
|
||||||
|
private int version;
|
||||||
|
private List<String> roots;
|
||||||
|
private List<CarBlock> blocks;
|
||||||
|
private Long size;
|
||||||
|
|
||||||
|
public int getVersion() { return version; }
|
||||||
|
public List<String> getRoots() { return roots; }
|
||||||
|
public List<CarBlock> getBlocks() { return blocks; }
|
||||||
|
public Long getSize() { return size; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import CAR response. */
|
||||||
|
class ImportCarResponse {
|
||||||
|
private List<String> roots;
|
||||||
|
private int blocksImported;
|
||||||
|
|
||||||
|
public List<String> getRoots() { return roots; }
|
||||||
|
public int getBlocksImported() { return blocksImported; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Storage stats. */
|
||||||
|
class StorageStats {
|
||||||
|
private long totalSize;
|
||||||
|
private int pinCount;
|
||||||
|
private BandwidthStats bandwidth;
|
||||||
|
|
||||||
|
public long getTotalSize() { return totalSize; }
|
||||||
|
public int getPinCount() { return pinCount; }
|
||||||
|
public BandwidthStats getBandwidth() { return bandwidth; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bandwidth stats. */
|
||||||
|
class BandwidthStats {
|
||||||
|
private long upload;
|
||||||
|
private long download;
|
||||||
|
|
||||||
|
public long getUpload() { return upload; }
|
||||||
|
public long getDownload() { return download; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Storage exception. */
|
||||||
|
class StorageException extends RuntimeException {
|
||||||
|
private final int statusCode;
|
||||||
|
private final String code;
|
||||||
|
|
||||||
|
public StorageException(String message, int statusCode, String code) {
|
||||||
|
super(message);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getStatusCode() { return statusCode; }
|
||||||
|
public String getCode() { return code; }
|
||||||
|
}
|
||||||
163
sdk/java/src/main/java/io/synor/wallet/SynorWallet.java
Normal file
163
sdk/java/src/main/java/io/synor/wallet/SynorWallet.java
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
package io.synor.wallet;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synor Wallet SDK for Java.
|
||||||
|
*
|
||||||
|
* Manage wallets, sign transactions, and query balances on the Synor blockchain.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* <pre>{@code
|
||||||
|
* SynorWallet wallet = new SynorWallet("your-api-key");
|
||||||
|
* CreateWalletResult result = wallet.createWallet(WalletType.STANDARD).join();
|
||||||
|
* System.out.println("Address: " + result.getWallet().getAddress());
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
public class SynorWallet {
|
||||||
|
private static final String DEFAULT_ENDPOINT = "https://wallet.synor.cc/api/v1";
|
||||||
|
private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(30);
|
||||||
|
|
||||||
|
private final WalletConfig config;
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
private final Gson gson;
|
||||||
|
|
||||||
|
public SynorWallet(String apiKey) {
|
||||||
|
this(WalletConfig.builder().apiKey(apiKey).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
public SynorWallet(WalletConfig config) {
|
||||||
|
this.config = config;
|
||||||
|
this.httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(config.getTimeoutSeconds()))
|
||||||
|
.build();
|
||||||
|
this.gson = new GsonBuilder()
|
||||||
|
.setFieldNamingPolicy(com.google.gson.FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
|
||||||
|
.create();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new wallet.
|
||||||
|
*/
|
||||||
|
public CompletableFuture<CreateWalletResult> createWallet(WalletType type) {
|
||||||
|
var body = new CreateWalletRequest(type, config.getNetwork());
|
||||||
|
return post("/wallets", body, CreateWalletResult.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a wallet from mnemonic.
|
||||||
|
*/
|
||||||
|
public CompletableFuture<Wallet> importWallet(String mnemonic, String passphrase, WalletType type) {
|
||||||
|
var body = new ImportWalletRequest(mnemonic, passphrase, type, config.getNetwork());
|
||||||
|
return post("/wallets/import", body, WalletResponse.class)
|
||||||
|
.thenApply(WalletResponse::getWallet);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a wallet by ID.
|
||||||
|
*/
|
||||||
|
public CompletableFuture<Wallet> getWallet(String walletId) {
|
||||||
|
return get("/wallets/" + walletId, WalletResponse.class)
|
||||||
|
.thenApply(WalletResponse::getWallet);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a transaction.
|
||||||
|
*/
|
||||||
|
public CompletableFuture<SignedTransaction> signTransaction(String walletId, Transaction tx) {
|
||||||
|
var body = new SignTransactionRequest(tx);
|
||||||
|
return post("/wallets/" + walletId + "/sign", body, SignedTransactionResponse.class)
|
||||||
|
.thenApply(SignedTransactionResponse::getSignedTransaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a message.
|
||||||
|
*/
|
||||||
|
public CompletableFuture<SignedMessage> signMessage(String walletId, String message, String format) {
|
||||||
|
var body = new SignMessageRequest(message, format != null ? format : "text");
|
||||||
|
return post("/wallets/" + walletId + "/sign-message", body, SignedMessage.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get balance for an address.
|
||||||
|
*/
|
||||||
|
public CompletableFuture<BalanceResponse> getBalance(String address, boolean includeTokens) {
|
||||||
|
String path = "/balances/" + address + "?includeTokens=" + includeTokens;
|
||||||
|
return get(path, BalanceResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get UTXOs for an address.
|
||||||
|
*/
|
||||||
|
public CompletableFuture<UTXO[]> getUTXOs(String address) {
|
||||||
|
return get("/utxos/" + address, UTXOsResponse.class)
|
||||||
|
.thenApply(UTXOsResponse::getUtxos);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate transaction fee.
|
||||||
|
*/
|
||||||
|
public CompletableFuture<FeeEstimate> estimateFee(Priority priority) {
|
||||||
|
String path = "/fees/estimate?priority=" + priority.getValue();
|
||||||
|
return get(path, FeeEstimate.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> CompletableFuture<T> get(String path, Class<T> responseType) {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(config.getEndpoint() + path))
|
||||||
|
.header("Authorization", "Bearer " + config.getApiKey())
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("X-Network", config.getNetwork().getValue())
|
||||||
|
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||||
|
.thenApply(response -> handleResponse(response, responseType));
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> CompletableFuture<T> post(String path, Object body, Class<T> responseType) {
|
||||||
|
String jsonBody = gson.toJson(body);
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(config.getEndpoint() + path))
|
||||||
|
.header("Authorization", "Bearer " + config.getApiKey())
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("X-Network", config.getNetwork().getValue())
|
||||||
|
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||||
|
.thenApply(response -> handleResponse(response, responseType));
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> T handleResponse(HttpResponse<String> response, Class<T> responseType) {
|
||||||
|
if (response.statusCode() >= 400) {
|
||||||
|
ErrorResponse error = gson.fromJson(response.body(), ErrorResponse.class);
|
||||||
|
throw new WalletException(
|
||||||
|
error != null ? error.getMessage() : "Unknown error",
|
||||||
|
response.statusCode(),
|
||||||
|
error != null ? error.getCode() : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return gson.fromJson(response.body(), responseType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request/Response DTOs
|
||||||
|
private record CreateWalletRequest(WalletType type, Network network) {}
|
||||||
|
private record ImportWalletRequest(String mnemonic, String passphrase, WalletType type, Network network) {}
|
||||||
|
private record SignTransactionRequest(Transaction transaction) {}
|
||||||
|
private record SignMessageRequest(String message, String format) {}
|
||||||
|
private record WalletResponse(Wallet wallet) { Wallet getWallet() { return wallet; } }
|
||||||
|
private record SignedTransactionResponse(SignedTransaction signedTransaction) { SignedTransaction getSignedTransaction() { return signedTransaction; } }
|
||||||
|
private record UTXOsResponse(UTXO[] utxos) { UTXO[] getUtxos() { return utxos; } }
|
||||||
|
private record ErrorResponse(String message, String code) { String getMessage() { return message; } String getCode() { return code; } }
|
||||||
|
}
|
||||||
240
sdk/java/src/main/java/io/synor/wallet/Types.java
Normal file
240
sdk/java/src/main/java/io/synor/wallet/Types.java
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
package io.synor.wallet;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Network type. */
|
||||||
|
public enum Network {
|
||||||
|
MAINNET("mainnet"),
|
||||||
|
TESTNET("testnet");
|
||||||
|
|
||||||
|
private final String value;
|
||||||
|
Network(String value) { this.value = value; }
|
||||||
|
public String getValue() { return value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wallet type. */
|
||||||
|
public enum WalletType {
|
||||||
|
STANDARD("standard"),
|
||||||
|
MULTISIG("multisig"),
|
||||||
|
STEALTH("stealth"),
|
||||||
|
HARDWARE("hardware");
|
||||||
|
|
||||||
|
private final String value;
|
||||||
|
WalletType(String value) { this.value = value; }
|
||||||
|
public String getValue() { return value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transaction priority. */
|
||||||
|
public enum Priority {
|
||||||
|
LOW("low"),
|
||||||
|
MEDIUM("medium"),
|
||||||
|
HIGH("high"),
|
||||||
|
URGENT("urgent");
|
||||||
|
|
||||||
|
private final String value;
|
||||||
|
Priority(String value) { this.value = value; }
|
||||||
|
public String getValue() { return value; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wallet configuration. */
|
||||||
|
class WalletConfig {
|
||||||
|
private final String apiKey;
|
||||||
|
private final String endpoint;
|
||||||
|
private final Network network;
|
||||||
|
private final long timeoutSeconds;
|
||||||
|
private final boolean debug;
|
||||||
|
|
||||||
|
private WalletConfig(Builder builder) {
|
||||||
|
this.apiKey = builder.apiKey;
|
||||||
|
this.endpoint = builder.endpoint;
|
||||||
|
this.network = builder.network;
|
||||||
|
this.timeoutSeconds = builder.timeoutSeconds;
|
||||||
|
this.debug = builder.debug;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getApiKey() { return apiKey; }
|
||||||
|
public String getEndpoint() { return endpoint; }
|
||||||
|
public Network getNetwork() { return network; }
|
||||||
|
public long getTimeoutSeconds() { return timeoutSeconds; }
|
||||||
|
public boolean isDebug() { return debug; }
|
||||||
|
|
||||||
|
public static Builder builder() { return new Builder(); }
|
||||||
|
|
||||||
|
public static class Builder {
|
||||||
|
private String apiKey;
|
||||||
|
private String endpoint = "https://wallet.synor.cc/api/v1";
|
||||||
|
private Network network = Network.MAINNET;
|
||||||
|
private long timeoutSeconds = 30;
|
||||||
|
private boolean debug = false;
|
||||||
|
|
||||||
|
public Builder apiKey(String apiKey) { this.apiKey = apiKey; return this; }
|
||||||
|
public Builder endpoint(String endpoint) { this.endpoint = endpoint; return this; }
|
||||||
|
public Builder network(Network network) { this.network = network; return this; }
|
||||||
|
public Builder timeoutSeconds(long seconds) { this.timeoutSeconds = seconds; return this; }
|
||||||
|
public Builder debug(boolean debug) { this.debug = debug; return this; }
|
||||||
|
public WalletConfig build() { return new WalletConfig(this); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wallet instance. */
|
||||||
|
class Wallet {
|
||||||
|
private String id;
|
||||||
|
private String address;
|
||||||
|
private String publicKey;
|
||||||
|
private WalletType type;
|
||||||
|
private long createdAt;
|
||||||
|
|
||||||
|
public String getId() { return id; }
|
||||||
|
public String getAddress() { return address; }
|
||||||
|
public String getPublicKey() { return publicKey; }
|
||||||
|
public WalletType getType() { return type; }
|
||||||
|
public long getCreatedAt() { return createdAt; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create wallet result. */
|
||||||
|
class CreateWalletResult {
|
||||||
|
private Wallet wallet;
|
||||||
|
private String mnemonic;
|
||||||
|
|
||||||
|
public Wallet getWallet() { return wallet; }
|
||||||
|
public String getMnemonic() { return mnemonic; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transaction input. */
|
||||||
|
class TransactionInput {
|
||||||
|
private String txid;
|
||||||
|
private int vout;
|
||||||
|
private String amount;
|
||||||
|
private String scriptSig;
|
||||||
|
|
||||||
|
public String getTxid() { return txid; }
|
||||||
|
public int getVout() { return vout; }
|
||||||
|
public String getAmount() { return amount; }
|
||||||
|
public String getScriptSig() { return scriptSig; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transaction output. */
|
||||||
|
class TransactionOutput {
|
||||||
|
private String address;
|
||||||
|
private String amount;
|
||||||
|
private String scriptPubKey;
|
||||||
|
|
||||||
|
public String getAddress() { return address; }
|
||||||
|
public String getAmount() { return amount; }
|
||||||
|
public String getScriptPubKey() { return scriptPubKey; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transaction. */
|
||||||
|
class Transaction {
|
||||||
|
private int version;
|
||||||
|
private List<TransactionInput> inputs;
|
||||||
|
private List<TransactionOutput> outputs;
|
||||||
|
private int lockTime;
|
||||||
|
private String fee;
|
||||||
|
|
||||||
|
public int getVersion() { return version; }
|
||||||
|
public List<TransactionInput> getInputs() { return inputs; }
|
||||||
|
public List<TransactionOutput> getOutputs() { return outputs; }
|
||||||
|
public int getLockTime() { return lockTime; }
|
||||||
|
public String getFee() { return fee; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signed transaction. */
|
||||||
|
class SignedTransaction {
|
||||||
|
private String raw;
|
||||||
|
private String txid;
|
||||||
|
private int size;
|
||||||
|
private int weight;
|
||||||
|
|
||||||
|
public String getRaw() { return raw; }
|
||||||
|
public String getTxid() { return txid; }
|
||||||
|
public int getSize() { return size; }
|
||||||
|
public int getWeight() { return weight; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signed message. */
|
||||||
|
class SignedMessage {
|
||||||
|
private String signature;
|
||||||
|
private String publicKey;
|
||||||
|
private String address;
|
||||||
|
|
||||||
|
public String getSignature() { return signature; }
|
||||||
|
public String getPublicKey() { return publicKey; }
|
||||||
|
public String getAddress() { return address; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UTXO. */
|
||||||
|
class UTXO {
|
||||||
|
private String txid;
|
||||||
|
private int vout;
|
||||||
|
private String amount;
|
||||||
|
private String address;
|
||||||
|
private int confirmations;
|
||||||
|
private String scriptPubKey;
|
||||||
|
|
||||||
|
public String getTxid() { return txid; }
|
||||||
|
public int getVout() { return vout; }
|
||||||
|
public String getAmount() { return amount; }
|
||||||
|
public String getAddress() { return address; }
|
||||||
|
public int getConfirmations() { return confirmations; }
|
||||||
|
public String getScriptPubKey() { return scriptPubKey; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Balance. */
|
||||||
|
class Balance {
|
||||||
|
private String confirmed;
|
||||||
|
private String unconfirmed;
|
||||||
|
private String total;
|
||||||
|
|
||||||
|
public String getConfirmed() { return confirmed; }
|
||||||
|
public String getUnconfirmed() { return unconfirmed; }
|
||||||
|
public String getTotal() { return total; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Token balance. */
|
||||||
|
class TokenBalance {
|
||||||
|
private String token;
|
||||||
|
private String symbol;
|
||||||
|
private int decimals;
|
||||||
|
private String balance;
|
||||||
|
|
||||||
|
public String getToken() { return token; }
|
||||||
|
public String getSymbol() { return symbol; }
|
||||||
|
public int getDecimals() { return decimals; }
|
||||||
|
public String getBalance() { return balance; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Balance response. */
|
||||||
|
class BalanceResponse {
|
||||||
|
private Balance nativeBalance;
|
||||||
|
private List<TokenBalance> tokens;
|
||||||
|
|
||||||
|
public Balance getNativeBalance() { return nativeBalance; }
|
||||||
|
public List<TokenBalance> getTokens() { return tokens; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fee estimate. */
|
||||||
|
class FeeEstimate {
|
||||||
|
private Priority priority;
|
||||||
|
private String feeRate;
|
||||||
|
private int estimatedBlocks;
|
||||||
|
|
||||||
|
public Priority getPriority() { return priority; }
|
||||||
|
public String getFeeRate() { return feeRate; }
|
||||||
|
public int getEstimatedBlocks() { return estimatedBlocks; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wallet exception. */
|
||||||
|
class WalletException extends RuntimeException {
|
||||||
|
private final int statusCode;
|
||||||
|
private final String code;
|
||||||
|
|
||||||
|
public WalletException(String message, int statusCode, String code) {
|
||||||
|
super(message);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getStatusCode() { return statusCode; }
|
||||||
|
public String getCode() { return code; }
|
||||||
|
}
|
||||||
501
sdk/js/src/bridge/client.ts
Normal file
501
sdk/js/src/bridge/client.ts
Normal file
|
|
@ -0,0 +1,501 @@
|
||||||
|
/**
|
||||||
|
* Synor Bridge SDK Client
|
||||||
|
*
|
||||||
|
* Cross-chain asset transfers with lock-mint and burn-unlock patterns.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
BridgeConfig,
|
||||||
|
BridgeError,
|
||||||
|
BridgeErrorCode,
|
||||||
|
Chain,
|
||||||
|
ChainId,
|
||||||
|
Asset,
|
||||||
|
WrappedAsset,
|
||||||
|
Transfer,
|
||||||
|
TransferFilter,
|
||||||
|
TransferStatus,
|
||||||
|
LockReceipt,
|
||||||
|
LockProof,
|
||||||
|
LockOptions,
|
||||||
|
BurnReceipt,
|
||||||
|
BurnProof,
|
||||||
|
BurnOptions,
|
||||||
|
MintOptions,
|
||||||
|
UnlockOptions,
|
||||||
|
FeeEstimate,
|
||||||
|
ExchangeRate,
|
||||||
|
SignedTransaction,
|
||||||
|
DEFAULT_BRIDGE_ENDPOINT,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
/** Synor Bridge Client */
|
||||||
|
export class SynorBridge {
|
||||||
|
private readonly config: Required<BridgeConfig>;
|
||||||
|
private closed = false;
|
||||||
|
|
||||||
|
constructor(config: BridgeConfig) {
|
||||||
|
this.config = {
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
endpoint: config.endpoint || DEFAULT_BRIDGE_ENDPOINT,
|
||||||
|
timeout: config.timeout || 60000,
|
||||||
|
retries: config.retries || 3,
|
||||||
|
debug: config.debug || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Chain Operations ====================
|
||||||
|
|
||||||
|
/** Get all supported chains */
|
||||||
|
async getSupportedChains(): Promise<Chain[]> {
|
||||||
|
const response = await this.request<{ chains: Chain[] }>('GET', '/chains');
|
||||||
|
return response.chains;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get chain by ID */
|
||||||
|
async getChain(chainId: ChainId): Promise<Chain> {
|
||||||
|
return this.request<Chain>('GET', `/chains/${chainId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if chain is supported */
|
||||||
|
async isChainSupported(chainId: ChainId): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const chain = await this.getChain(chainId);
|
||||||
|
return chain.supported;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Asset Operations ====================
|
||||||
|
|
||||||
|
/** Get supported assets for a chain */
|
||||||
|
async getSupportedAssets(chainId: ChainId): Promise<Asset[]> {
|
||||||
|
const response = await this.request<{ assets: Asset[] }>(
|
||||||
|
'GET',
|
||||||
|
`/chains/${chainId}/assets`
|
||||||
|
);
|
||||||
|
return response.assets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get asset by ID */
|
||||||
|
async getAsset(assetId: string): Promise<Asset> {
|
||||||
|
return this.request<Asset>('GET', `/assets/${assetId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get wrapped asset mapping */
|
||||||
|
async getWrappedAsset(
|
||||||
|
originalAssetId: string,
|
||||||
|
targetChain: ChainId
|
||||||
|
): Promise<WrappedAsset> {
|
||||||
|
return this.request<WrappedAsset>(
|
||||||
|
'GET',
|
||||||
|
`/assets/${originalAssetId}/wrapped/${targetChain}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all wrapped assets for a chain */
|
||||||
|
async getWrappedAssets(chainId: ChainId): Promise<WrappedAsset[]> {
|
||||||
|
const response = await this.request<{ assets: WrappedAsset[] }>(
|
||||||
|
'GET',
|
||||||
|
`/chains/${chainId}/wrapped`
|
||||||
|
);
|
||||||
|
return response.assets;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Fee & Rate Operations ====================
|
||||||
|
|
||||||
|
/** Estimate bridge fee */
|
||||||
|
async estimateFee(
|
||||||
|
asset: string,
|
||||||
|
amount: string,
|
||||||
|
sourceChain: ChainId,
|
||||||
|
targetChain: ChainId
|
||||||
|
): Promise<FeeEstimate> {
|
||||||
|
return this.request<FeeEstimate>('POST', '/fees/estimate', {
|
||||||
|
asset,
|
||||||
|
amount,
|
||||||
|
sourceChain,
|
||||||
|
targetChain,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get exchange rate between assets */
|
||||||
|
async getExchangeRate(fromAsset: string, toAsset: string): Promise<ExchangeRate> {
|
||||||
|
return this.request<ExchangeRate>(
|
||||||
|
'GET',
|
||||||
|
`/rates/${encodeURIComponent(fromAsset)}/${encodeURIComponent(toAsset)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Lock-Mint Flow ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lock assets on source chain for cross-chain transfer
|
||||||
|
* Step 1 of lock-mint flow
|
||||||
|
*/
|
||||||
|
async lock(
|
||||||
|
asset: string,
|
||||||
|
amount: string,
|
||||||
|
targetChain: ChainId,
|
||||||
|
options?: LockOptions
|
||||||
|
): Promise<LockReceipt> {
|
||||||
|
return this.request<LockReceipt>('POST', '/transfers/lock', {
|
||||||
|
asset,
|
||||||
|
amount,
|
||||||
|
targetChain,
|
||||||
|
recipient: options?.recipient,
|
||||||
|
deadline: options?.deadline,
|
||||||
|
slippage: options?.slippage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get lock proof for minting
|
||||||
|
* Step 2 of lock-mint flow (wait for confirmations)
|
||||||
|
*/
|
||||||
|
async getLockProof(lockReceiptId: string): Promise<LockProof> {
|
||||||
|
return this.request<LockProof>('GET', `/transfers/lock/${lockReceiptId}/proof`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for lock proof to be ready
|
||||||
|
* Polls until confirmations are sufficient
|
||||||
|
*/
|
||||||
|
async waitForLockProof(
|
||||||
|
lockReceiptId: string,
|
||||||
|
pollInterval = 5000,
|
||||||
|
maxWait = 600000
|
||||||
|
): Promise<LockProof> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < maxWait) {
|
||||||
|
try {
|
||||||
|
const proof = await this.getLockProof(lockReceiptId);
|
||||||
|
return proof;
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof BridgeError &&
|
||||||
|
error.code === 'CONFIRMATIONS_PENDING'
|
||||||
|
) {
|
||||||
|
await this.sleep(pollInterval);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BridgeError(
|
||||||
|
'Timeout waiting for lock proof',
|
||||||
|
'CONFIRMATIONS_PENDING'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint wrapped tokens on target chain
|
||||||
|
* Step 3 of lock-mint flow
|
||||||
|
*/
|
||||||
|
async mint(
|
||||||
|
proof: LockProof,
|
||||||
|
targetAddress: string,
|
||||||
|
options?: MintOptions
|
||||||
|
): Promise<SignedTransaction> {
|
||||||
|
return this.request<SignedTransaction>('POST', '/transfers/mint', {
|
||||||
|
proof,
|
||||||
|
targetAddress,
|
||||||
|
gasLimit: options?.gasLimit,
|
||||||
|
maxFeePerGas: options?.maxFeePerGas,
|
||||||
|
maxPriorityFeePerGas: options?.maxPriorityFeePerGas,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Burn-Unlock Flow ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Burn wrapped tokens on current chain
|
||||||
|
* Step 1 of burn-unlock flow
|
||||||
|
*/
|
||||||
|
async burn(
|
||||||
|
wrappedAsset: string,
|
||||||
|
amount: string,
|
||||||
|
options?: BurnOptions
|
||||||
|
): Promise<BurnReceipt> {
|
||||||
|
return this.request<BurnReceipt>('POST', '/transfers/burn', {
|
||||||
|
wrappedAsset,
|
||||||
|
amount,
|
||||||
|
recipient: options?.recipient,
|
||||||
|
deadline: options?.deadline,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get burn proof for unlocking
|
||||||
|
* Step 2 of burn-unlock flow (wait for confirmations)
|
||||||
|
*/
|
||||||
|
async getBurnProof(burnReceiptId: string): Promise<BurnProof> {
|
||||||
|
return this.request<BurnProof>('GET', `/transfers/burn/${burnReceiptId}/proof`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for burn proof to be ready
|
||||||
|
* Polls until confirmations are sufficient
|
||||||
|
*/
|
||||||
|
async waitForBurnProof(
|
||||||
|
burnReceiptId: string,
|
||||||
|
pollInterval = 5000,
|
||||||
|
maxWait = 600000
|
||||||
|
): Promise<BurnProof> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < maxWait) {
|
||||||
|
try {
|
||||||
|
const proof = await this.getBurnProof(burnReceiptId);
|
||||||
|
return proof;
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof BridgeError &&
|
||||||
|
error.code === 'CONFIRMATIONS_PENDING'
|
||||||
|
) {
|
||||||
|
await this.sleep(pollInterval);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BridgeError(
|
||||||
|
'Timeout waiting for burn proof',
|
||||||
|
'CONFIRMATIONS_PENDING'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlock original tokens on source chain
|
||||||
|
* Step 3 of burn-unlock flow
|
||||||
|
*/
|
||||||
|
async unlock(proof: BurnProof, options?: UnlockOptions): Promise<SignedTransaction> {
|
||||||
|
return this.request<SignedTransaction>('POST', '/transfers/unlock', {
|
||||||
|
proof,
|
||||||
|
gasLimit: options?.gasLimit,
|
||||||
|
gasPrice: options?.gasPrice,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Transfer Management ====================
|
||||||
|
|
||||||
|
/** Get transfer by ID */
|
||||||
|
async getTransfer(transferId: string): Promise<Transfer> {
|
||||||
|
return this.request<Transfer>('GET', `/transfers/${transferId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get transfer status */
|
||||||
|
async getTransferStatus(transferId: string): Promise<TransferStatus> {
|
||||||
|
const transfer = await this.getTransfer(transferId);
|
||||||
|
return transfer.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List transfers with optional filters */
|
||||||
|
async listTransfers(filter?: TransferFilter): Promise<Transfer[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filter) {
|
||||||
|
if (filter.status) params.set('status', filter.status);
|
||||||
|
if (filter.sourceChain) params.set('sourceChain', filter.sourceChain);
|
||||||
|
if (filter.targetChain) params.set('targetChain', filter.targetChain);
|
||||||
|
if (filter.asset) params.set('asset', filter.asset);
|
||||||
|
if (filter.sender) params.set('sender', filter.sender);
|
||||||
|
if (filter.recipient) params.set('recipient', filter.recipient);
|
||||||
|
if (filter.fromDate) params.set('fromDate', filter.fromDate.toString());
|
||||||
|
if (filter.toDate) params.set('toDate', filter.toDate.toString());
|
||||||
|
if (filter.limit) params.set('limit', filter.limit.toString());
|
||||||
|
if (filter.offset) params.set('offset', filter.offset.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = params.toString();
|
||||||
|
const path = query ? `/transfers?${query}` : '/transfers';
|
||||||
|
|
||||||
|
const response = await this.request<{ transfers: Transfer[] }>('GET', path);
|
||||||
|
return response.transfers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait for transfer to complete */
|
||||||
|
async waitForTransfer(
|
||||||
|
transferId: string,
|
||||||
|
pollInterval = 10000,
|
||||||
|
maxWait = 1800000
|
||||||
|
): Promise<Transfer> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const finalStatuses: TransferStatus[] = ['completed', 'failed', 'refunded'];
|
||||||
|
|
||||||
|
while (Date.now() - startTime < maxWait) {
|
||||||
|
const transfer = await this.getTransfer(transferId);
|
||||||
|
|
||||||
|
if (finalStatuses.includes(transfer.status)) {
|
||||||
|
return transfer;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.sleep(pollInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BridgeError('Timeout waiting for transfer completion');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Convenience Methods ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute complete lock-mint transfer
|
||||||
|
* Combines lock, wait for proof, and mint into single operation
|
||||||
|
*/
|
||||||
|
async bridgeTo(
|
||||||
|
asset: string,
|
||||||
|
amount: string,
|
||||||
|
targetChain: ChainId,
|
||||||
|
targetAddress: string,
|
||||||
|
options?: LockOptions & MintOptions
|
||||||
|
): Promise<Transfer> {
|
||||||
|
// Lock on source chain
|
||||||
|
const lockReceipt = await this.lock(asset, amount, targetChain, options);
|
||||||
|
|
||||||
|
if (this.config.debug) {
|
||||||
|
console.log(`Locked: ${lockReceipt.id}, waiting for confirmations...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for proof
|
||||||
|
const proof = await this.waitForLockProof(lockReceipt.id);
|
||||||
|
|
||||||
|
if (this.config.debug) {
|
||||||
|
console.log(`Proof ready, minting on ${targetChain}...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint on target chain
|
||||||
|
await this.mint(proof, targetAddress, options);
|
||||||
|
|
||||||
|
// Return final transfer status
|
||||||
|
return this.waitForTransfer(lockReceipt.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute complete burn-unlock transfer
|
||||||
|
* Combines burn, wait for proof, and unlock into single operation
|
||||||
|
*/
|
||||||
|
async bridgeBack(
|
||||||
|
wrappedAsset: string,
|
||||||
|
amount: string,
|
||||||
|
options?: BurnOptions & UnlockOptions
|
||||||
|
): Promise<Transfer> {
|
||||||
|
// Burn wrapped tokens
|
||||||
|
const burnReceipt = await this.burn(wrappedAsset, amount, options);
|
||||||
|
|
||||||
|
if (this.config.debug) {
|
||||||
|
console.log(`Burned: ${burnReceipt.id}, waiting for confirmations...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for proof
|
||||||
|
const proof = await this.waitForBurnProof(burnReceipt.id);
|
||||||
|
|
||||||
|
if (this.config.debug) {
|
||||||
|
console.log(`Proof ready, unlocking on ${burnReceipt.targetChain}...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock on original chain
|
||||||
|
await this.unlock(proof, options);
|
||||||
|
|
||||||
|
// Return final transfer status
|
||||||
|
return this.waitForTransfer(burnReceipt.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Lifecycle ====================
|
||||||
|
|
||||||
|
/** Close the client */
|
||||||
|
close(): void {
|
||||||
|
this.closed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if client is closed */
|
||||||
|
isClosed(): boolean {
|
||||||
|
return this.closed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Health check */
|
||||||
|
async healthCheck(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await this.request<{ status: string }>('GET', '/health');
|
||||||
|
return response.status === 'healthy';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Private Methods ====================
|
||||||
|
|
||||||
|
private async request<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: Record<string, unknown>
|
||||||
|
): Promise<T> {
|
||||||
|
if (this.closed) {
|
||||||
|
throw new BridgeError('Client has been closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError: Error | undefined;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < this.config.retries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await this.doRequest<T>(method, path, body);
|
||||||
|
} catch (error) {
|
||||||
|
if (this.config.debug) {
|
||||||
|
console.error(`Attempt ${attempt + 1} failed:`, error);
|
||||||
|
}
|
||||||
|
lastError = error as Error;
|
||||||
|
|
||||||
|
if (attempt < this.config.retries - 1) {
|
||||||
|
await this.sleep(Math.pow(2, attempt) * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new BridgeError('Unknown error after retries');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doRequest<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: Record<string, unknown>
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${this.config.endpoint}${path}`;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Authorization: `Bearer ${this.config.apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-SDK-Version': 'js/0.1.0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorBody = await response.json().catch(() => ({}));
|
||||||
|
const message =
|
||||||
|
errorBody.message || errorBody.error || `HTTP ${response.status}`;
|
||||||
|
const code = errorBody.code as BridgeErrorCode | undefined;
|
||||||
|
|
||||||
|
throw new BridgeError(message, code, response.status, errorBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
25
sdk/js/src/bridge/index.ts
Normal file
25
sdk/js/src/bridge/index.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
/**
|
||||||
|
* Synor Bridge SDK
|
||||||
|
*
|
||||||
|
* Cross-chain asset transfers with lock-mint and burn-unlock patterns.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { SynorBridge } from '@synor/bridge';
|
||||||
|
*
|
||||||
|
* const bridge = new SynorBridge({ apiKey: 'your-api-key' });
|
||||||
|
*
|
||||||
|
* // Bridge tokens from Synor to Ethereum
|
||||||
|
* const transfer = await bridge.bridgeTo(
|
||||||
|
* 'SYNR',
|
||||||
|
* '1000000000000000000', // 1 SYNR in wei
|
||||||
|
* 'ethereum',
|
||||||
|
* '0x...'
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* console.log(`Transfer ${transfer.id}: ${transfer.status}`);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './types';
|
||||||
|
export * from './client';
|
||||||
279
sdk/js/src/bridge/types.ts
Normal file
279
sdk/js/src/bridge/types.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
/**
|
||||||
|
* Synor Bridge SDK Types
|
||||||
|
*
|
||||||
|
* Cross-chain asset transfers with lock-mint and burn-unlock patterns.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ==================== Chain Types ====================
|
||||||
|
|
||||||
|
/** Supported blockchain networks */
|
||||||
|
export type ChainId =
|
||||||
|
| 'synor'
|
||||||
|
| 'ethereum'
|
||||||
|
| 'polygon'
|
||||||
|
| 'arbitrum'
|
||||||
|
| 'optimism'
|
||||||
|
| 'bsc'
|
||||||
|
| 'avalanche'
|
||||||
|
| 'solana'
|
||||||
|
| 'cosmos';
|
||||||
|
|
||||||
|
/** Chain information */
|
||||||
|
export interface Chain {
|
||||||
|
id: ChainId;
|
||||||
|
name: string;
|
||||||
|
chainId: number;
|
||||||
|
rpcUrl: string;
|
||||||
|
explorerUrl: string;
|
||||||
|
nativeCurrency: {
|
||||||
|
name: string;
|
||||||
|
symbol: string;
|
||||||
|
decimals: number;
|
||||||
|
};
|
||||||
|
confirmations: number;
|
||||||
|
estimatedBlockTime: number; // seconds
|
||||||
|
supported: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Asset Types ====================
|
||||||
|
|
||||||
|
/** Asset type */
|
||||||
|
export type AssetType = 'native' | 'erc20' | 'erc721' | 'erc1155';
|
||||||
|
|
||||||
|
/** Asset information */
|
||||||
|
export interface Asset {
|
||||||
|
id: string;
|
||||||
|
symbol: string;
|
||||||
|
name: string;
|
||||||
|
type: AssetType;
|
||||||
|
chain: ChainId;
|
||||||
|
contractAddress?: string;
|
||||||
|
decimals: number;
|
||||||
|
logoUrl?: string;
|
||||||
|
verified: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrapped asset mapping */
|
||||||
|
export interface WrappedAsset {
|
||||||
|
originalAsset: Asset;
|
||||||
|
wrappedAsset: Asset;
|
||||||
|
chain: ChainId;
|
||||||
|
bridgeContract: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Transfer Types ====================
|
||||||
|
|
||||||
|
/** Transfer status */
|
||||||
|
export type TransferStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'locked'
|
||||||
|
| 'confirming'
|
||||||
|
| 'minting'
|
||||||
|
| 'completed'
|
||||||
|
| 'failed'
|
||||||
|
| 'refunded';
|
||||||
|
|
||||||
|
/** Transfer direction */
|
||||||
|
export type TransferDirection = 'lock_mint' | 'burn_unlock';
|
||||||
|
|
||||||
|
/** Lock receipt from source chain */
|
||||||
|
export interface LockReceipt {
|
||||||
|
id: string;
|
||||||
|
txHash: string;
|
||||||
|
sourceChain: ChainId;
|
||||||
|
targetChain: ChainId;
|
||||||
|
asset: Asset;
|
||||||
|
amount: string;
|
||||||
|
sender: string;
|
||||||
|
recipient: string;
|
||||||
|
lockTimestamp: number;
|
||||||
|
confirmations: number;
|
||||||
|
requiredConfirmations: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Proof for minting on target chain */
|
||||||
|
export interface LockProof {
|
||||||
|
lockReceipt: LockReceipt;
|
||||||
|
merkleProof: string[];
|
||||||
|
blockHeader: string;
|
||||||
|
signatures: ValidatorSignature[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validator signature for proof */
|
||||||
|
export interface ValidatorSignature {
|
||||||
|
validator: string;
|
||||||
|
signature: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Burn receipt for unlocking */
|
||||||
|
export interface BurnReceipt {
|
||||||
|
id: string;
|
||||||
|
txHash: string;
|
||||||
|
sourceChain: ChainId;
|
||||||
|
targetChain: ChainId;
|
||||||
|
wrappedAsset: Asset;
|
||||||
|
originalAsset: Asset;
|
||||||
|
amount: string;
|
||||||
|
sender: string;
|
||||||
|
recipient: string;
|
||||||
|
burnTimestamp: number;
|
||||||
|
confirmations: number;
|
||||||
|
requiredConfirmations: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Proof for unlocking on original chain */
|
||||||
|
export interface BurnProof {
|
||||||
|
burnReceipt: BurnReceipt;
|
||||||
|
merkleProof: string[];
|
||||||
|
blockHeader: string;
|
||||||
|
signatures: ValidatorSignature[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Complete transfer record */
|
||||||
|
export interface Transfer {
|
||||||
|
id: string;
|
||||||
|
direction: TransferDirection;
|
||||||
|
status: TransferStatus;
|
||||||
|
sourceChain: ChainId;
|
||||||
|
targetChain: ChainId;
|
||||||
|
asset: Asset;
|
||||||
|
amount: string;
|
||||||
|
sender: string;
|
||||||
|
recipient: string;
|
||||||
|
sourceTxHash?: string;
|
||||||
|
targetTxHash?: string;
|
||||||
|
fee: string;
|
||||||
|
feeAsset: Asset;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
completedAt?: number;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Fee Types ====================
|
||||||
|
|
||||||
|
/** Fee estimate for bridge transfer */
|
||||||
|
export interface FeeEstimate {
|
||||||
|
bridgeFee: string;
|
||||||
|
gasFeeSource: string;
|
||||||
|
gasFeeTarget: string;
|
||||||
|
totalFee: string;
|
||||||
|
feeAsset: Asset;
|
||||||
|
estimatedTime: number; // seconds
|
||||||
|
exchangeRate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exchange rate between assets */
|
||||||
|
export interface ExchangeRate {
|
||||||
|
fromAsset: Asset;
|
||||||
|
toAsset: Asset;
|
||||||
|
rate: string;
|
||||||
|
inverseRate: string;
|
||||||
|
lastUpdated: number;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Transaction Types ====================
|
||||||
|
|
||||||
|
/** Signed transaction result */
|
||||||
|
export interface SignedTransaction {
|
||||||
|
txHash: string;
|
||||||
|
chain: ChainId;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
value: string;
|
||||||
|
data: string;
|
||||||
|
gasLimit: string;
|
||||||
|
gasPrice?: string;
|
||||||
|
maxFeePerGas?: string;
|
||||||
|
maxPriorityFeePerGas?: string;
|
||||||
|
nonce: number;
|
||||||
|
signature: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Filter Types ====================
|
||||||
|
|
||||||
|
/** Filter for listing transfers */
|
||||||
|
export interface TransferFilter {
|
||||||
|
status?: TransferStatus;
|
||||||
|
sourceChain?: ChainId;
|
||||||
|
targetChain?: ChainId;
|
||||||
|
asset?: string;
|
||||||
|
sender?: string;
|
||||||
|
recipient?: string;
|
||||||
|
fromDate?: number;
|
||||||
|
toDate?: number;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Config Types ====================
|
||||||
|
|
||||||
|
/** Bridge configuration */
|
||||||
|
export interface BridgeConfig {
|
||||||
|
apiKey: string;
|
||||||
|
endpoint?: string;
|
||||||
|
timeout?: number;
|
||||||
|
retries?: number;
|
||||||
|
debug?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default bridge endpoint */
|
||||||
|
export const DEFAULT_BRIDGE_ENDPOINT = 'https://bridge.synor.io/v1';
|
||||||
|
|
||||||
|
// ==================== Options Types ====================
|
||||||
|
|
||||||
|
/** Options for lock operation */
|
||||||
|
export interface LockOptions {
|
||||||
|
recipient?: string; // Defaults to sender
|
||||||
|
deadline?: number; // Unix timestamp
|
||||||
|
slippage?: number; // Percentage (0-100)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for mint operation */
|
||||||
|
export interface MintOptions {
|
||||||
|
gasLimit?: string;
|
||||||
|
maxFeePerGas?: string;
|
||||||
|
maxPriorityFeePerGas?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for burn operation */
|
||||||
|
export interface BurnOptions {
|
||||||
|
recipient?: string;
|
||||||
|
deadline?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for unlock operation */
|
||||||
|
export interface UnlockOptions {
|
||||||
|
gasLimit?: string;
|
||||||
|
gasPrice?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Error Types ====================
|
||||||
|
|
||||||
|
/** Bridge-specific error codes */
|
||||||
|
export type BridgeErrorCode =
|
||||||
|
| 'INSUFFICIENT_BALANCE'
|
||||||
|
| 'UNSUPPORTED_CHAIN'
|
||||||
|
| 'UNSUPPORTED_ASSET'
|
||||||
|
| 'TRANSFER_NOT_FOUND'
|
||||||
|
| 'PROOF_INVALID'
|
||||||
|
| 'PROOF_EXPIRED'
|
||||||
|
| 'CONFIRMATIONS_PENDING'
|
||||||
|
| 'BRIDGE_PAUSED'
|
||||||
|
| 'RATE_LIMITED'
|
||||||
|
| 'SLIPPAGE_EXCEEDED';
|
||||||
|
|
||||||
|
/** Bridge error */
|
||||||
|
export class BridgeError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public code?: BridgeErrorCode,
|
||||||
|
public statusCode?: number,
|
||||||
|
public details?: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'BridgeError';
|
||||||
|
}
|
||||||
|
}
|
||||||
659
sdk/js/src/database/client.ts
Normal file
659
sdk/js/src/database/client.ts
Normal file
|
|
@ -0,0 +1,659 @@
|
||||||
|
/**
|
||||||
|
* Synor Database SDK Client
|
||||||
|
* Multi-model database: Key-Value, Document, Vector, and Time Series
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
DatabaseConfig,
|
||||||
|
KeyValueEntry,
|
||||||
|
SetOptions,
|
||||||
|
ListOptions,
|
||||||
|
ListResult,
|
||||||
|
Document,
|
||||||
|
CreateDocumentOptions,
|
||||||
|
UpdateDocumentOptions,
|
||||||
|
QueryOptions,
|
||||||
|
QueryResult,
|
||||||
|
AggregateStage,
|
||||||
|
VectorEntry,
|
||||||
|
UpsertVectorOptions,
|
||||||
|
SearchOptions,
|
||||||
|
SearchResult,
|
||||||
|
VectorCollectionConfig,
|
||||||
|
VectorCollectionStats,
|
||||||
|
DataPoint,
|
||||||
|
WritePointsOptions,
|
||||||
|
TimeRange,
|
||||||
|
TimeSeriesQueryOptions,
|
||||||
|
TimeSeriesResult,
|
||||||
|
SeriesInfo,
|
||||||
|
DatabaseStats,
|
||||||
|
} from './types';
|
||||||
|
import { DatabaseError } from './types';
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
endpoint: 'https://database.synor.io/v1',
|
||||||
|
network: 'mainnet' as const,
|
||||||
|
timeout: 30000,
|
||||||
|
retries: 3,
|
||||||
|
debug: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Key-Value store interface
|
||||||
|
*/
|
||||||
|
class KeyValueStore {
|
||||||
|
constructor(private client: SynorDatabase) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a value by key
|
||||||
|
*/
|
||||||
|
async get<T = unknown>(key: string): Promise<T | null> {
|
||||||
|
const response = await this.client.request<{ value: T | null }>(`/kv/${encodeURIComponent(key)}`);
|
||||||
|
return response.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a full entry with metadata
|
||||||
|
*/
|
||||||
|
async getEntry<T = unknown>(key: string): Promise<KeyValueEntry<T> | null> {
|
||||||
|
return this.client.request<KeyValueEntry<T> | null>(`/kv/${encodeURIComponent(key)}/entry`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a value
|
||||||
|
*/
|
||||||
|
async set<T = unknown>(key: string, value: T, options?: SetOptions): Promise<void> {
|
||||||
|
await this.client.request(`/kv/${encodeURIComponent(key)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { value, ...options },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a key
|
||||||
|
*/
|
||||||
|
async delete(key: string): Promise<boolean> {
|
||||||
|
const response = await this.client.request<{ deleted: boolean }>(`/kv/${encodeURIComponent(key)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
return response.deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a key exists
|
||||||
|
*/
|
||||||
|
async exists(key: string): Promise<boolean> {
|
||||||
|
const response = await this.client.request<{ exists: boolean }>(`/kv/${encodeURIComponent(key)}/exists`);
|
||||||
|
return response.exists;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List keys with optional prefix filtering
|
||||||
|
*/
|
||||||
|
async list<T = unknown>(options?: ListOptions): Promise<ListResult<T>> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options?.prefix) params.set('prefix', options.prefix);
|
||||||
|
if (options?.cursor) params.set('cursor', options.cursor);
|
||||||
|
if (options?.limit) params.set('limit', options.limit.toString());
|
||||||
|
|
||||||
|
return this.client.request<ListResult<T>>(`/kv?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get multiple values at once
|
||||||
|
*/
|
||||||
|
async mget<T = unknown>(keys: string[]): Promise<Map<string, T | null>> {
|
||||||
|
const response = await this.client.request<{ entries: { key: string; value: T | null }[] }>('/kv/mget', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { keys },
|
||||||
|
});
|
||||||
|
return new Map(response.entries.map(e => [e.key, e.value]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set multiple values at once
|
||||||
|
*/
|
||||||
|
async mset<T = unknown>(entries: { key: string; value: T }[]): Promise<void> {
|
||||||
|
await this.client.request('/kv/mset', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { entries },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment a numeric value
|
||||||
|
*/
|
||||||
|
async incr(key: string, by: number = 1): Promise<number> {
|
||||||
|
const response = await this.client.request<{ value: number }>(`/kv/${encodeURIComponent(key)}/incr`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { by },
|
||||||
|
});
|
||||||
|
return response.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Document store interface
|
||||||
|
*/
|
||||||
|
class DocumentStore {
|
||||||
|
constructor(private client: SynorDatabase) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new document
|
||||||
|
*/
|
||||||
|
async create<T extends Record<string, unknown>>(
|
||||||
|
collection: string,
|
||||||
|
data: T,
|
||||||
|
options?: CreateDocumentOptions
|
||||||
|
): Promise<Document<T>> {
|
||||||
|
return this.client.request<Document<T>>(`/documents/${encodeURIComponent(collection)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { data, ...options },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a document by ID
|
||||||
|
*/
|
||||||
|
async get<T extends Record<string, unknown>>(
|
||||||
|
collection: string,
|
||||||
|
id: string
|
||||||
|
): Promise<Document<T> | null> {
|
||||||
|
return this.client.request<Document<T> | null>(
|
||||||
|
`/documents/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a document
|
||||||
|
*/
|
||||||
|
async update<T extends Record<string, unknown>>(
|
||||||
|
collection: string,
|
||||||
|
id: string,
|
||||||
|
update: Partial<T>,
|
||||||
|
options?: UpdateDocumentOptions
|
||||||
|
): Promise<Document<T>> {
|
||||||
|
return this.client.request<Document<T>>(
|
||||||
|
`/documents/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { update, ...options },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace a document
|
||||||
|
*/
|
||||||
|
async replace<T extends Record<string, unknown>>(
|
||||||
|
collection: string,
|
||||||
|
id: string,
|
||||||
|
data: T
|
||||||
|
): Promise<Document<T>> {
|
||||||
|
return this.client.request<Document<T>>(
|
||||||
|
`/documents/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
body: { data },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a document
|
||||||
|
*/
|
||||||
|
async delete(collection: string, id: string): Promise<boolean> {
|
||||||
|
const response = await this.client.request<{ deleted: boolean }>(
|
||||||
|
`/documents/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
);
|
||||||
|
return response.deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query documents
|
||||||
|
*/
|
||||||
|
async query<T extends Record<string, unknown>>(
|
||||||
|
collection: string,
|
||||||
|
options?: QueryOptions
|
||||||
|
): Promise<QueryResult<T>> {
|
||||||
|
return this.client.request<QueryResult<T>>(
|
||||||
|
`/documents/${encodeURIComponent(collection)}/query`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: options || {},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count documents matching a filter
|
||||||
|
*/
|
||||||
|
async count(collection: string, filter?: QueryOptions['filter']): Promise<number> {
|
||||||
|
const response = await this.client.request<{ count: number }>(
|
||||||
|
`/documents/${encodeURIComponent(collection)}/count`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: { filter },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run an aggregation pipeline
|
||||||
|
*/
|
||||||
|
async aggregate<T = unknown>(
|
||||||
|
collection: string,
|
||||||
|
pipeline: AggregateStage[]
|
||||||
|
): Promise<T[]> {
|
||||||
|
const response = await this.client.request<{ results: T[] }>(
|
||||||
|
`/documents/${encodeURIComponent(collection)}/aggregate`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: { pipeline },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an index on a collection
|
||||||
|
*/
|
||||||
|
async createIndex(
|
||||||
|
collection: string,
|
||||||
|
fields: string[],
|
||||||
|
options?: { unique?: boolean; sparse?: boolean }
|
||||||
|
): Promise<{ name: string }> {
|
||||||
|
return this.client.request<{ name: string }>(
|
||||||
|
`/documents/${encodeURIComponent(collection)}/indexes`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: { fields, ...options },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List indexes on a collection
|
||||||
|
*/
|
||||||
|
async listIndexes(collection: string): Promise<{ name: string; fields: string[]; unique: boolean }[]> {
|
||||||
|
const response = await this.client.request<{ indexes: { name: string; fields: string[]; unique: boolean }[] }>(
|
||||||
|
`/documents/${encodeURIComponent(collection)}/indexes`
|
||||||
|
);
|
||||||
|
return response.indexes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vector store interface
|
||||||
|
*/
|
||||||
|
class VectorStore {
|
||||||
|
constructor(private client: SynorDatabase) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a vector collection
|
||||||
|
*/
|
||||||
|
async createCollection(config: VectorCollectionConfig): Promise<void> {
|
||||||
|
await this.client.request('/vectors/collections', {
|
||||||
|
method: 'POST',
|
||||||
|
body: config,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a vector collection
|
||||||
|
*/
|
||||||
|
async deleteCollection(name: string): Promise<void> {
|
||||||
|
await this.client.request(`/vectors/collections/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get collection stats
|
||||||
|
*/
|
||||||
|
async getCollectionStats(name: string): Promise<VectorCollectionStats> {
|
||||||
|
return this.client.request<VectorCollectionStats>(
|
||||||
|
`/vectors/collections/${encodeURIComponent(name)}/stats`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert vectors
|
||||||
|
*/
|
||||||
|
async upsert(
|
||||||
|
collection: string,
|
||||||
|
vectors: VectorEntry[],
|
||||||
|
options?: UpsertVectorOptions
|
||||||
|
): Promise<{ upserted: number }> {
|
||||||
|
return this.client.request<{ upserted: number }>(
|
||||||
|
`/vectors/${encodeURIComponent(collection)}/upsert`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: { vectors, ...options },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for similar vectors
|
||||||
|
*/
|
||||||
|
async search(
|
||||||
|
collection: string,
|
||||||
|
vector: number[],
|
||||||
|
options?: SearchOptions
|
||||||
|
): Promise<SearchResult[]> {
|
||||||
|
const response = await this.client.request<{ results: SearchResult[] }>(
|
||||||
|
`/vectors/${encodeURIComponent(collection)}/search`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: { vector, ...options },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search using text (requires embedding generation)
|
||||||
|
*/
|
||||||
|
async searchText(
|
||||||
|
collection: string,
|
||||||
|
text: string,
|
||||||
|
options?: SearchOptions
|
||||||
|
): Promise<SearchResult[]> {
|
||||||
|
const response = await this.client.request<{ results: SearchResult[] }>(
|
||||||
|
`/vectors/${encodeURIComponent(collection)}/search/text`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: { text, ...options },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete vectors by ID
|
||||||
|
*/
|
||||||
|
async delete(
|
||||||
|
collection: string,
|
||||||
|
ids: string[],
|
||||||
|
namespace?: string
|
||||||
|
): Promise<{ deleted: number }> {
|
||||||
|
return this.client.request<{ deleted: number }>(
|
||||||
|
`/vectors/${encodeURIComponent(collection)}/delete`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: { ids, namespace },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch vectors by ID
|
||||||
|
*/
|
||||||
|
async fetch(
|
||||||
|
collection: string,
|
||||||
|
ids: string[],
|
||||||
|
namespace?: string
|
||||||
|
): Promise<VectorEntry[]> {
|
||||||
|
const response = await this.client.request<{ vectors: VectorEntry[] }>(
|
||||||
|
`/vectors/${encodeURIComponent(collection)}/fetch`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: { ids, namespace },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.vectors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Time series store interface
|
||||||
|
*/
|
||||||
|
class TimeSeriesStore {
|
||||||
|
constructor(private client: SynorDatabase) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write data points to a series
|
||||||
|
*/
|
||||||
|
async write(
|
||||||
|
series: string,
|
||||||
|
points: DataPoint[],
|
||||||
|
options?: WritePointsOptions
|
||||||
|
): Promise<{ written: number }> {
|
||||||
|
return this.client.request<{ written: number }>(
|
||||||
|
`/timeseries/${encodeURIComponent(series)}/write`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: { points, ...options },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query a time series
|
||||||
|
*/
|
||||||
|
async query(
|
||||||
|
series: string,
|
||||||
|
range: TimeRange,
|
||||||
|
options?: TimeSeriesQueryOptions
|
||||||
|
): Promise<TimeSeriesResult> {
|
||||||
|
return this.client.request<TimeSeriesResult>(
|
||||||
|
`/timeseries/${encodeURIComponent(series)}/query`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: { range, ...options },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query multiple series
|
||||||
|
*/
|
||||||
|
async queryMulti(
|
||||||
|
series: string[],
|
||||||
|
range: TimeRange,
|
||||||
|
options?: TimeSeriesQueryOptions
|
||||||
|
): Promise<TimeSeriesResult[]> {
|
||||||
|
const response = await this.client.request<{ results: TimeSeriesResult[] }>(
|
||||||
|
'/timeseries/query',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: { series, range, ...options },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete data points in a range
|
||||||
|
*/
|
||||||
|
async delete(
|
||||||
|
series: string,
|
||||||
|
range: TimeRange,
|
||||||
|
tags?: Record<string, string>
|
||||||
|
): Promise<{ deleted: number }> {
|
||||||
|
return this.client.request<{ deleted: number }>(
|
||||||
|
`/timeseries/${encodeURIComponent(series)}/delete`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: { range, tags },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get series information
|
||||||
|
*/
|
||||||
|
async getSeriesInfo(series: string): Promise<SeriesInfo> {
|
||||||
|
return this.client.request<SeriesInfo>(`/timeseries/${encodeURIComponent(series)}/info`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all series
|
||||||
|
*/
|
||||||
|
async listSeries(prefix?: string): Promise<SeriesInfo[]> {
|
||||||
|
const params = prefix ? `?prefix=${encodeURIComponent(prefix)}` : '';
|
||||||
|
const response = await this.client.request<{ series: SeriesInfo[] }>(`/timeseries${params}`);
|
||||||
|
return response.series;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set retention policy for a series
|
||||||
|
*/
|
||||||
|
async setRetention(series: string, retentionDays: number): Promise<void> {
|
||||||
|
await this.client.request(`/timeseries/${encodeURIComponent(series)}/retention`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { retentionDays },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synor Database SDK Client
|
||||||
|
*
|
||||||
|
* Multi-model database supporting Key-Value, Document, Vector, and Time Series data.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const db = new SynorDatabase({ apiKey: 'your-api-key' });
|
||||||
|
*
|
||||||
|
* // Key-Value operations
|
||||||
|
* await db.kv.set('user:1', { name: 'Alice' });
|
||||||
|
* const user = await db.kv.get('user:1');
|
||||||
|
*
|
||||||
|
* // Document operations
|
||||||
|
* const doc = await db.documents.create('users', { name: 'Bob', age: 30 });
|
||||||
|
* const results = await db.documents.query('users', { filter: { age: { $gt: 25 } } });
|
||||||
|
*
|
||||||
|
* // Vector operations
|
||||||
|
* await db.vectors.upsert('embeddings', [{ id: '1', vector: [0.1, 0.2, ...] }]);
|
||||||
|
* const similar = await db.vectors.search('embeddings', queryVector, { topK: 10 });
|
||||||
|
*
|
||||||
|
* // Time series operations
|
||||||
|
* await db.timeseries.write('metrics', [{ timestamp: Date.now(), value: 42.5 }]);
|
||||||
|
* const data = await db.timeseries.query('metrics', { start: '-1h', end: 'now' });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class SynorDatabase {
|
||||||
|
private config: Required<DatabaseConfig>;
|
||||||
|
private closed = false;
|
||||||
|
|
||||||
|
/** Key-Value store */
|
||||||
|
readonly kv: KeyValueStore;
|
||||||
|
/** Document store */
|
||||||
|
readonly documents: DocumentStore;
|
||||||
|
/** Vector store */
|
||||||
|
readonly vectors: VectorStore;
|
||||||
|
/** Time series store */
|
||||||
|
readonly timeseries: TimeSeriesStore;
|
||||||
|
|
||||||
|
constructor(config: DatabaseConfig) {
|
||||||
|
this.config = {
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!this.config.apiKey) {
|
||||||
|
throw new DatabaseError('API key is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.kv = new KeyValueStore(this);
|
||||||
|
this.documents = new DocumentStore(this);
|
||||||
|
this.vectors = new VectorStore(this);
|
||||||
|
this.timeseries = new TimeSeriesStore(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get database statistics
|
||||||
|
*/
|
||||||
|
async getStats(): Promise<DatabaseStats> {
|
||||||
|
return this.request<DatabaseStats>('/stats');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check
|
||||||
|
*/
|
||||||
|
async healthCheck(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await this.request<{ status: string }>('/health');
|
||||||
|
return response.status === 'healthy';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the client
|
||||||
|
*/
|
||||||
|
close(): void {
|
||||||
|
this.closed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the client is closed
|
||||||
|
*/
|
||||||
|
isClosed(): boolean {
|
||||||
|
return this.closed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal request method
|
||||||
|
*/
|
||||||
|
async request<T>(
|
||||||
|
path: string,
|
||||||
|
options: { method?: string; body?: unknown } = {}
|
||||||
|
): Promise<T> {
|
||||||
|
if (this.closed) {
|
||||||
|
throw new DatabaseError('Client has been closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { method = 'GET', body } = options;
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < this.config.retries; attempt++) {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
||||||
|
|
||||||
|
const response = await fetch(`${this.config.endpoint}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${this.config.apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-SDK-Version': 'js/0.1.0',
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new DatabaseError(
|
||||||
|
errorData.message || errorData.error || `HTTP ${response.status}`,
|
||||||
|
errorData.code,
|
||||||
|
response.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
|
||||||
|
if (this.config.debug) {
|
||||||
|
console.error(`Attempt ${attempt + 1} failed:`, lastError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < this.config.retries - 1) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new DatabaseError('Unknown error after retries');
|
||||||
|
}
|
||||||
|
}
|
||||||
7
sdk/js/src/database/index.ts
Normal file
7
sdk/js/src/database/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/**
|
||||||
|
* Synor Database SDK
|
||||||
|
* Multi-model database: Key-Value, Document, Vector, and Time Series
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './types';
|
||||||
|
export * from './client';
|
||||||
234
sdk/js/src/database/types.ts
Normal file
234
sdk/js/src/database/types.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
/**
|
||||||
|
* Synor Database SDK Types
|
||||||
|
* Multi-model database: Key-Value, Document, Vector, and Time Series
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Network environments
|
||||||
|
export type DatabaseNetwork = 'mainnet' | 'testnet' | 'devnet';
|
||||||
|
|
||||||
|
// Database configuration
|
||||||
|
export interface DatabaseConfig {
|
||||||
|
apiKey: string;
|
||||||
|
endpoint?: string;
|
||||||
|
network?: DatabaseNetwork;
|
||||||
|
timeout?: number;
|
||||||
|
retries?: number;
|
||||||
|
debug?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Key-Value Types ====================
|
||||||
|
|
||||||
|
export interface KeyValueEntry<T = unknown> {
|
||||||
|
key: string;
|
||||||
|
value: T;
|
||||||
|
metadata?: Record<string, string>;
|
||||||
|
expiresAt?: number;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetOptions {
|
||||||
|
ttl?: number; // Time-to-live in seconds
|
||||||
|
metadata?: Record<string, string>;
|
||||||
|
ifNotExists?: boolean;
|
||||||
|
ifExists?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListOptions {
|
||||||
|
prefix?: string;
|
||||||
|
cursor?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListResult<T = unknown> {
|
||||||
|
entries: KeyValueEntry<T>[];
|
||||||
|
cursor?: string;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Document Store Types ====================
|
||||||
|
|
||||||
|
export interface Document<T = Record<string, unknown>> {
|
||||||
|
id: string;
|
||||||
|
data: T;
|
||||||
|
metadata?: Record<string, string>;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDocumentOptions {
|
||||||
|
id?: string; // Auto-generate if not provided
|
||||||
|
metadata?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateDocumentOptions {
|
||||||
|
upsert?: boolean;
|
||||||
|
returnDocument?: 'before' | 'after';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QueryOperator =
|
||||||
|
| '$eq' | '$ne' | '$gt' | '$gte' | '$lt' | '$lte'
|
||||||
|
| '$in' | '$nin' | '$exists' | '$regex'
|
||||||
|
| '$and' | '$or' | '$not';
|
||||||
|
|
||||||
|
export type QueryFilter = {
|
||||||
|
[field: string]: unknown | { [K in QueryOperator]?: unknown };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SortOrder = 'asc' | 'desc';
|
||||||
|
|
||||||
|
export interface QueryOptions {
|
||||||
|
filter?: QueryFilter;
|
||||||
|
sort?: { [field: string]: SortOrder };
|
||||||
|
skip?: number;
|
||||||
|
limit?: number;
|
||||||
|
projection?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryResult<T = Record<string, unknown>> {
|
||||||
|
documents: Document<T>[];
|
||||||
|
total: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AggregateStage {
|
||||||
|
$match?: QueryFilter;
|
||||||
|
$group?: { _id: string; [field: string]: { $sum?: string; $avg?: string; $count?: boolean; $min?: string; $max?: string } };
|
||||||
|
$sort?: { [field: string]: SortOrder };
|
||||||
|
$limit?: number;
|
||||||
|
$skip?: number;
|
||||||
|
$project?: { [field: string]: boolean | string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Vector Store Types ====================
|
||||||
|
|
||||||
|
export interface VectorEntry {
|
||||||
|
id: string;
|
||||||
|
vector: number[];
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
content?: string; // Optional text content for hybrid search
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpsertVectorOptions {
|
||||||
|
namespace?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DistanceMetric = 'cosine' | 'euclidean' | 'dotProduct';
|
||||||
|
|
||||||
|
export interface SearchOptions {
|
||||||
|
namespace?: string;
|
||||||
|
topK?: number;
|
||||||
|
filter?: QueryFilter;
|
||||||
|
includeMetadata?: boolean;
|
||||||
|
includeVectors?: boolean;
|
||||||
|
minScore?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
id: string;
|
||||||
|
score: number;
|
||||||
|
vector?: number[];
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VectorCollectionConfig {
|
||||||
|
name: string;
|
||||||
|
dimension: number;
|
||||||
|
metric?: DistanceMetric;
|
||||||
|
indexType?: 'flat' | 'hnsw' | 'ivf';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VectorCollectionStats {
|
||||||
|
name: string;
|
||||||
|
vectorCount: number;
|
||||||
|
dimension: number;
|
||||||
|
indexSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Time Series Types ====================
|
||||||
|
|
||||||
|
export interface DataPoint {
|
||||||
|
timestamp: number;
|
||||||
|
value: number;
|
||||||
|
tags?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WritePointsOptions {
|
||||||
|
precision?: 'ns' | 'us' | 'ms' | 's';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Aggregation =
|
||||||
|
| 'mean' | 'sum' | 'count' | 'min' | 'max'
|
||||||
|
| 'first' | 'last' | 'stddev' | 'variance';
|
||||||
|
|
||||||
|
export interface TimeRange {
|
||||||
|
start: number | Date | string;
|
||||||
|
end: number | Date | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeSeriesQueryOptions {
|
||||||
|
tags?: Record<string, string>;
|
||||||
|
aggregation?: Aggregation;
|
||||||
|
interval?: string; // e.g., '1m', '5m', '1h', '1d'
|
||||||
|
fill?: 'none' | 'null' | 'previous' | number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeSeriesResult {
|
||||||
|
series: string;
|
||||||
|
points: DataPoint[];
|
||||||
|
statistics?: {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
mean: number;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeriesInfo {
|
||||||
|
name: string;
|
||||||
|
tags: string[];
|
||||||
|
retentionDays: number;
|
||||||
|
pointCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Database Stats ====================
|
||||||
|
|
||||||
|
export interface DatabaseStats {
|
||||||
|
kvEntries: number;
|
||||||
|
documentCount: number;
|
||||||
|
vectorCount: number;
|
||||||
|
timeseriesPoints: number;
|
||||||
|
storageUsed: number;
|
||||||
|
storageLimit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Error Types ====================
|
||||||
|
|
||||||
|
export class DatabaseError extends Error {
|
||||||
|
code?: string;
|
||||||
|
statusCode?: number;
|
||||||
|
|
||||||
|
constructor(message: string, code?: string, statusCode?: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'DatabaseError';
|
||||||
|
this.code = code;
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DocumentNotFoundError extends DatabaseError {
|
||||||
|
constructor(collection: string, id: string) {
|
||||||
|
super(`Document not found: ${collection}/${id}`, 'DOCUMENT_NOT_FOUND', 404);
|
||||||
|
this.name = 'DocumentNotFoundError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class KeyNotFoundError extends DatabaseError {
|
||||||
|
constructor(key: string) {
|
||||||
|
super(`Key not found: ${key}`, 'KEY_NOT_FOUND', 404);
|
||||||
|
this.name = 'KeyNotFoundError';
|
||||||
|
}
|
||||||
|
}
|
||||||
396
sdk/js/src/hosting/client.ts
Normal file
396
sdk/js/src/hosting/client.ts
Normal file
|
|
@ -0,0 +1,396 @@
|
||||||
|
/**
|
||||||
|
* Synor Hosting SDK Client
|
||||||
|
* Decentralized web hosting with domain management, DNS, and deployments.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
HostingConfig,
|
||||||
|
Domain,
|
||||||
|
DomainRecord,
|
||||||
|
RegisterDomainOptions,
|
||||||
|
DomainAvailability,
|
||||||
|
DnsRecord,
|
||||||
|
DnsZone,
|
||||||
|
Deployment,
|
||||||
|
DeployOptions,
|
||||||
|
DeploymentStats,
|
||||||
|
Certificate,
|
||||||
|
ProvisionSslOptions,
|
||||||
|
SiteConfig,
|
||||||
|
AnalyticsData,
|
||||||
|
AnalyticsOptions,
|
||||||
|
} from './types';
|
||||||
|
import { HostingError } from './types';
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
endpoint: 'https://hosting.synor.io/v1',
|
||||||
|
network: 'mainnet' as const,
|
||||||
|
timeout: 60000,
|
||||||
|
retries: 3,
|
||||||
|
debug: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synor Hosting SDK Client
|
||||||
|
*
|
||||||
|
* Provides domain registration, DNS management, site deployment, and SSL provisioning.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const hosting = new SynorHosting({ apiKey: 'your-api-key' });
|
||||||
|
*
|
||||||
|
* // Register a domain
|
||||||
|
* const domain = await hosting.registerDomain('mysite.synor');
|
||||||
|
*
|
||||||
|
* // Deploy a site from IPFS CID
|
||||||
|
* const deployment = await hosting.deploy('Qm...', { domain: 'mysite.synor' });
|
||||||
|
*
|
||||||
|
* // Provision SSL
|
||||||
|
* const cert = await hosting.provisionSsl('mysite.synor');
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class SynorHosting {
|
||||||
|
private config: Required<HostingConfig>;
|
||||||
|
private closed = false;
|
||||||
|
|
||||||
|
constructor(config: HostingConfig) {
|
||||||
|
this.config = {
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!this.config.apiKey) {
|
||||||
|
throw new HostingError('API key is required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Domain Operations ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check domain availability
|
||||||
|
*/
|
||||||
|
async checkAvailability(name: string): Promise<DomainAvailability> {
|
||||||
|
return this.request<DomainAvailability>(`/domains/check/${encodeURIComponent(name)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new domain
|
||||||
|
*/
|
||||||
|
async registerDomain(name: string, options?: RegisterDomainOptions): Promise<Domain> {
|
||||||
|
return this.request<Domain>('/domains', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { name, ...options },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get domain information
|
||||||
|
*/
|
||||||
|
async getDomain(name: string): Promise<Domain> {
|
||||||
|
return this.request<Domain>(`/domains/${encodeURIComponent(name)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all domains
|
||||||
|
*/
|
||||||
|
async listDomains(): Promise<Domain[]> {
|
||||||
|
const response = await this.request<{ domains: Domain[] }>('/domains');
|
||||||
|
return response.domains;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update domain record (for IPFS/IPNS resolution)
|
||||||
|
*/
|
||||||
|
async updateDomainRecord(name: string, record: DomainRecord): Promise<Domain> {
|
||||||
|
return this.request<Domain>(`/domains/${encodeURIComponent(name)}/record`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: record,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a domain to its record
|
||||||
|
*/
|
||||||
|
async resolveDomain(name: string): Promise<DomainRecord> {
|
||||||
|
return this.request<DomainRecord>(`/domains/${encodeURIComponent(name)}/resolve`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renew a domain
|
||||||
|
*/
|
||||||
|
async renewDomain(name: string, years: number = 1): Promise<Domain> {
|
||||||
|
return this.request<Domain>(`/domains/${encodeURIComponent(name)}/renew`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { years },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transfer domain ownership
|
||||||
|
*/
|
||||||
|
async transferDomain(name: string, newOwner: string): Promise<Domain> {
|
||||||
|
return this.request<Domain>(`/domains/${encodeURIComponent(name)}/transfer`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { new_owner: newOwner },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DNS Operations ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get DNS zone for a domain
|
||||||
|
*/
|
||||||
|
async getDnsZone(domain: string): Promise<DnsZone> {
|
||||||
|
return this.request<DnsZone>(`/dns/${encodeURIComponent(domain)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set DNS records for a domain
|
||||||
|
*/
|
||||||
|
async setDnsRecords(domain: string, records: DnsRecord[]): Promise<DnsZone> {
|
||||||
|
return this.request<DnsZone>(`/dns/${encodeURIComponent(domain)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: { records },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a DNS record
|
||||||
|
*/
|
||||||
|
async addDnsRecord(domain: string, record: DnsRecord): Promise<DnsZone> {
|
||||||
|
return this.request<DnsZone>(`/dns/${encodeURIComponent(domain)}/records`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: record,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a DNS record
|
||||||
|
*/
|
||||||
|
async deleteDnsRecord(domain: string, recordType: string, name: string): Promise<DnsZone> {
|
||||||
|
return this.request<DnsZone>(
|
||||||
|
`/dns/${encodeURIComponent(domain)}/records/${recordType}/${encodeURIComponent(name)}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Deployment Operations ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deploy a site from CID
|
||||||
|
*/
|
||||||
|
async deploy(cid: string, options?: DeployOptions): Promise<Deployment> {
|
||||||
|
return this.request<Deployment>('/deployments', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { cid, ...options },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get deployment by ID
|
||||||
|
*/
|
||||||
|
async getDeployment(id: string): Promise<Deployment> {
|
||||||
|
return this.request<Deployment>(`/deployments/${encodeURIComponent(id)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List deployments
|
||||||
|
*/
|
||||||
|
async listDeployments(domain?: string): Promise<Deployment[]> {
|
||||||
|
const query = domain ? `?domain=${encodeURIComponent(domain)}` : '';
|
||||||
|
const response = await this.request<{ deployments: Deployment[] }>(`/deployments${query}`);
|
||||||
|
return response.deployments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rollback to a previous deployment
|
||||||
|
*/
|
||||||
|
async rollback(domain: string, deploymentId: string): Promise<Deployment> {
|
||||||
|
return this.request<Deployment>(`/deployments/${encodeURIComponent(deploymentId)}/rollback`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { domain },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a deployment
|
||||||
|
*/
|
||||||
|
async deleteDeployment(id: string): Promise<void> {
|
||||||
|
await this.request(`/deployments/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get deployment stats
|
||||||
|
*/
|
||||||
|
async getDeploymentStats(id: string, period: string = '24h'): Promise<DeploymentStats> {
|
||||||
|
return this.request<DeploymentStats>(
|
||||||
|
`/deployments/${encodeURIComponent(id)}/stats?period=${period}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SSL Operations ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provision SSL certificate
|
||||||
|
*/
|
||||||
|
async provisionSsl(domain: string, options?: ProvisionSslOptions): Promise<Certificate> {
|
||||||
|
return this.request<Certificate>(`/ssl/${encodeURIComponent(domain)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: options || {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get certificate status
|
||||||
|
*/
|
||||||
|
async getCertificate(domain: string): Promise<Certificate> {
|
||||||
|
return this.request<Certificate>(`/ssl/${encodeURIComponent(domain)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renew SSL certificate
|
||||||
|
*/
|
||||||
|
async renewCertificate(domain: string): Promise<Certificate> {
|
||||||
|
return this.request<Certificate>(`/ssl/${encodeURIComponent(domain)}/renew`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete/revoke SSL certificate
|
||||||
|
*/
|
||||||
|
async deleteCertificate(domain: string): Promise<void> {
|
||||||
|
await this.request(`/ssl/${encodeURIComponent(domain)}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Site Configuration ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get site configuration
|
||||||
|
*/
|
||||||
|
async getSiteConfig(domain: string): Promise<SiteConfig> {
|
||||||
|
return this.request<SiteConfig>(`/sites/${encodeURIComponent(domain)}/config`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update site configuration
|
||||||
|
*/
|
||||||
|
async updateSiteConfig(domain: string, config: Partial<SiteConfig>): Promise<SiteConfig> {
|
||||||
|
return this.request<SiteConfig>(`/sites/${encodeURIComponent(domain)}/config`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: config,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purge CDN cache
|
||||||
|
*/
|
||||||
|
async purgeCache(domain: string, paths?: string[]): Promise<{ purged: number }> {
|
||||||
|
return this.request<{ purged: number }>(`/sites/${encodeURIComponent(domain)}/cache`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
body: paths ? { paths } : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Analytics ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get site analytics
|
||||||
|
*/
|
||||||
|
async getAnalytics(domain: string, options?: AnalyticsOptions): Promise<AnalyticsData> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options?.period) params.set('period', options.period);
|
||||||
|
if (options?.startDate) params.set('start', options.startDate);
|
||||||
|
if (options?.endDate) params.set('end', options.endDate);
|
||||||
|
|
||||||
|
const query = params.toString() ? `?${params}` : '';
|
||||||
|
return this.request<AnalyticsData>(`/sites/${encodeURIComponent(domain)}/analytics${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Lifecycle ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the client
|
||||||
|
*/
|
||||||
|
close(): void {
|
||||||
|
this.closed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the client is closed
|
||||||
|
*/
|
||||||
|
isClosed(): boolean {
|
||||||
|
return this.closed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check
|
||||||
|
*/
|
||||||
|
async healthCheck(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await this.request<{ status: string }>('/health');
|
||||||
|
return response.status === 'healthy';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal request method
|
||||||
|
*/
|
||||||
|
private async request<T>(
|
||||||
|
path: string,
|
||||||
|
options: { method?: string; body?: unknown } = {}
|
||||||
|
): Promise<T> {
|
||||||
|
if (this.closed) {
|
||||||
|
throw new HostingError('Client has been closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { method = 'GET', body } = options;
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < this.config.retries; attempt++) {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
||||||
|
|
||||||
|
const response = await fetch(`${this.config.endpoint}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${this.config.apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-SDK-Version': 'js/0.1.0',
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new HostingError(
|
||||||
|
errorData.message || errorData.error || `HTTP ${response.status}`,
|
||||||
|
errorData.code,
|
||||||
|
response.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
return text ? JSON.parse(text) : ({} as T);
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
|
||||||
|
if (this.config.debug) {
|
||||||
|
console.error(`Attempt ${attempt + 1} failed:`, lastError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < this.config.retries - 1) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new HostingError('Unknown error after retries');
|
||||||
|
}
|
||||||
|
}
|
||||||
7
sdk/js/src/hosting/index.ts
Normal file
7
sdk/js/src/hosting/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/**
|
||||||
|
* Synor Hosting SDK
|
||||||
|
* Decentralized web hosting with domain management, DNS, and deployments.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './types';
|
||||||
|
export * from './client';
|
||||||
193
sdk/js/src/hosting/types.ts
Normal file
193
sdk/js/src/hosting/types.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
/**
|
||||||
|
* Synor Hosting SDK Types
|
||||||
|
* Decentralized web hosting with domain management, DNS, and deployments.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Network environments
|
||||||
|
export type HostingNetwork = 'mainnet' | 'testnet' | 'devnet';
|
||||||
|
|
||||||
|
// Hosting configuration
|
||||||
|
export interface HostingConfig {
|
||||||
|
apiKey: string;
|
||||||
|
endpoint?: string;
|
||||||
|
network?: HostingNetwork;
|
||||||
|
timeout?: number;
|
||||||
|
retries?: number;
|
||||||
|
debug?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Domain Types ====================
|
||||||
|
|
||||||
|
export type DomainStatus = 'pending' | 'active' | 'expired' | 'suspended';
|
||||||
|
|
||||||
|
export interface Domain {
|
||||||
|
name: string;
|
||||||
|
status: DomainStatus;
|
||||||
|
owner: string;
|
||||||
|
registeredAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
autoRenew: boolean;
|
||||||
|
records?: DomainRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DomainRecord {
|
||||||
|
cid?: string;
|
||||||
|
ipv4?: string[];
|
||||||
|
ipv6?: string[];
|
||||||
|
cname?: string;
|
||||||
|
txt?: string[];
|
||||||
|
metadata?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterDomainOptions {
|
||||||
|
years?: number;
|
||||||
|
autoRenew?: boolean;
|
||||||
|
records?: DomainRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DomainAvailability {
|
||||||
|
name: string;
|
||||||
|
available: boolean;
|
||||||
|
price?: number;
|
||||||
|
premium?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DNS Types ====================
|
||||||
|
|
||||||
|
export type DnsRecordType = 'A' | 'AAAA' | 'CNAME' | 'TXT' | 'MX' | 'NS' | 'SRV' | 'CAA';
|
||||||
|
|
||||||
|
export interface DnsRecord {
|
||||||
|
type: DnsRecordType;
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
ttl?: number;
|
||||||
|
priority?: number; // For MX and SRV
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DnsZone {
|
||||||
|
domain: string;
|
||||||
|
records: DnsRecord[];
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Deployment Types ====================
|
||||||
|
|
||||||
|
export type DeploymentStatus = 'pending' | 'building' | 'deploying' | 'active' | 'failed' | 'inactive';
|
||||||
|
|
||||||
|
export interface Deployment {
|
||||||
|
id: string;
|
||||||
|
domain: string;
|
||||||
|
cid: string;
|
||||||
|
status: DeploymentStatus;
|
||||||
|
url: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
buildLogs?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeployOptions {
|
||||||
|
domain?: string;
|
||||||
|
subdomain?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
redirects?: RedirectRule[];
|
||||||
|
spa?: boolean; // Single-page app mode
|
||||||
|
cleanUrls?: boolean;
|
||||||
|
trailingSlash?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedirectRule {
|
||||||
|
source: string;
|
||||||
|
destination: string;
|
||||||
|
statusCode?: number;
|
||||||
|
permanent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeploymentStats {
|
||||||
|
requests: number;
|
||||||
|
bandwidth: number;
|
||||||
|
uniqueVisitors: number;
|
||||||
|
period: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SSL Types ====================
|
||||||
|
|
||||||
|
export type CertificateStatus = 'pending' | 'issued' | 'expired' | 'revoked';
|
||||||
|
|
||||||
|
export interface Certificate {
|
||||||
|
domain: string;
|
||||||
|
status: CertificateStatus;
|
||||||
|
issuedAt?: number;
|
||||||
|
expiresAt?: number;
|
||||||
|
autoRenew: boolean;
|
||||||
|
issuer: string;
|
||||||
|
fingerprint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProvisionSslOptions {
|
||||||
|
includeWww?: boolean;
|
||||||
|
autoRenew?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Site Configuration ====================
|
||||||
|
|
||||||
|
export interface SiteConfig {
|
||||||
|
domain: string;
|
||||||
|
cid?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
redirects?: RedirectRule[];
|
||||||
|
errorPages?: {
|
||||||
|
'404'?: string;
|
||||||
|
'500'?: string;
|
||||||
|
};
|
||||||
|
spa?: boolean;
|
||||||
|
cleanUrls?: boolean;
|
||||||
|
trailingSlash?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Analytics ====================
|
||||||
|
|
||||||
|
export interface AnalyticsData {
|
||||||
|
domain: string;
|
||||||
|
period: string;
|
||||||
|
pageViews: number;
|
||||||
|
uniqueVisitors: number;
|
||||||
|
bandwidth: number;
|
||||||
|
topPages: { path: string; views: number }[];
|
||||||
|
topReferrers: { referrer: string; count: number }[];
|
||||||
|
topCountries: { country: string; count: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsOptions {
|
||||||
|
period?: '24h' | '7d' | '30d' | '90d';
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Error Types ====================
|
||||||
|
|
||||||
|
export class HostingError extends Error {
|
||||||
|
code?: string;
|
||||||
|
statusCode?: number;
|
||||||
|
|
||||||
|
constructor(message: string, code?: string, statusCode?: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'HostingError';
|
||||||
|
this.code = code;
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DomainNotFoundError extends HostingError {
|
||||||
|
constructor(domain: string) {
|
||||||
|
super(`Domain not found: ${domain}`, 'DOMAIN_NOT_FOUND', 404);
|
||||||
|
this.name = 'DomainNotFoundError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DomainUnavailableError extends HostingError {
|
||||||
|
constructor(domain: string) {
|
||||||
|
super(`Domain is not available: ${domain}`, 'DOMAIN_UNAVAILABLE', 409);
|
||||||
|
this.name = 'DomainUnavailableError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,11 +21,13 @@ dependencies {
|
||||||
implementation("io.ktor:ktor-client-core:2.3.7")
|
implementation("io.ktor:ktor-client-core:2.3.7")
|
||||||
implementation("io.ktor:ktor-client-cio:2.3.7")
|
implementation("io.ktor:ktor-client-cio:2.3.7")
|
||||||
implementation("io.ktor:ktor-client-content-negotiation:2.3.7")
|
implementation("io.ktor:ktor-client-content-negotiation:2.3.7")
|
||||||
|
implementation("io.ktor:ktor-client-websockets:2.3.7")
|
||||||
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.7")
|
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.7")
|
||||||
|
|
||||||
// Testing
|
// Testing
|
||||||
testImplementation(kotlin("test"))
|
testImplementation(kotlin("test"))
|
||||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||||
|
testImplementation("io.ktor:ktor-client-mock:2.3.7")
|
||||||
testImplementation("io.mockk:mockk:1.13.8")
|
testImplementation("io.mockk:mockk:1.13.8")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,9 +44,16 @@ publishing {
|
||||||
create<MavenPublication>("maven") {
|
create<MavenPublication>("maven") {
|
||||||
from(components["java"])
|
from(components["java"])
|
||||||
pom {
|
pom {
|
||||||
name.set("Synor Compute SDK")
|
name.set("Synor SDK")
|
||||||
description.set("Kotlin SDK for Synor Compute - Distributed Heterogeneous Computing")
|
description.set("Kotlin SDK for Synor - Compute, Wallet, RPC, and Storage")
|
||||||
url.set("https://github.com/synor/synor-compute-kotlin")
|
url.set("https://synor.cc")
|
||||||
|
|
||||||
|
licenses {
|
||||||
|
license {
|
||||||
|
name.set("MIT License")
|
||||||
|
url.set("https://opensource.org/licenses/MIT")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
sdk/kotlin/settings.gradle.kts
Normal file
1
sdk/kotlin/settings.gradle.kts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
rootProject.name = "synor-sdk"
|
||||||
355
sdk/kotlin/src/main/kotlin/io/synor/rpc/SynorRpc.kt
Normal file
355
sdk/kotlin/src/main/kotlin/io/synor/rpc/SynorRpc.kt
Normal file
|
|
@ -0,0 +1,355 @@
|
||||||
|
package io.synor.rpc
|
||||||
|
|
||||||
|
import io.ktor.client.*
|
||||||
|
import io.ktor.client.call.*
|
||||||
|
import io.ktor.client.engine.cio.*
|
||||||
|
import io.ktor.client.plugins.*
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.*
|
||||||
|
import io.ktor.client.plugins.websocket.*
|
||||||
|
import io.ktor.client.request.*
|
||||||
|
import io.ktor.client.statement.*
|
||||||
|
import io.ktor.http.*
|
||||||
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
|
import io.ktor.websocket.*
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.Closeable
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synor RPC SDK client for Kotlin.
|
||||||
|
*
|
||||||
|
* Provides blockchain queries, transaction submission, and real-time
|
||||||
|
* subscriptions via WebSocket.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* ```kotlin
|
||||||
|
* val rpc = SynorRpc(RpcConfig.builder("your-api-key").build())
|
||||||
|
*
|
||||||
|
* // Get latest block
|
||||||
|
* val block = rpc.getLatestBlock()
|
||||||
|
* println("Latest block: ${block.height}")
|
||||||
|
*
|
||||||
|
* // Subscribe to new blocks
|
||||||
|
* val scope = CoroutineScope(Dispatchers.Default)
|
||||||
|
* val subscription = rpc.subscribeBlocks(scope) { block ->
|
||||||
|
* println("New block: ${block.height}")
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Later: cancel subscription
|
||||||
|
* subscription.cancel()
|
||||||
|
* rpc.close()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class SynorRpc(private val config: RpcConfig) : Closeable {
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private val client = HttpClient(CIO) {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
install(WebSockets)
|
||||||
|
|
||||||
|
install(HttpTimeout) {
|
||||||
|
requestTimeoutMillis = config.timeout
|
||||||
|
connectTimeoutMillis = config.timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultRequest {
|
||||||
|
header("Authorization", "Bearer ${config.apiKey}")
|
||||||
|
header("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var wsSession: WebSocketSession? = null
|
||||||
|
private val subscriptions = ConcurrentHashMap<String, (String) -> Unit>()
|
||||||
|
private var wsJob: Job? = null
|
||||||
|
|
||||||
|
// Block operations
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the latest block.
|
||||||
|
*/
|
||||||
|
suspend fun getLatestBlock(): Block {
|
||||||
|
return get("/blocks/latest")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a block by hash or height.
|
||||||
|
*/
|
||||||
|
suspend fun getBlock(hashOrHeight: String): Block {
|
||||||
|
return get("/blocks/$hashOrHeight")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a block header by hash or height.
|
||||||
|
*/
|
||||||
|
suspend fun getBlockHeader(hashOrHeight: String): BlockHeader {
|
||||||
|
return get("/blocks/$hashOrHeight/header")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get blocks in a range.
|
||||||
|
*/
|
||||||
|
suspend fun getBlocks(startHeight: Long, endHeight: Long): List<Block> {
|
||||||
|
return get("/blocks?start=$startHeight&end=$endHeight")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction operations
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a transaction by ID.
|
||||||
|
*/
|
||||||
|
suspend fun getTransaction(txid: String): Transaction {
|
||||||
|
return get("/transactions/$txid")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get raw transaction hex.
|
||||||
|
*/
|
||||||
|
suspend fun getRawTransaction(txid: String): String {
|
||||||
|
val response: Map<String, String> = get("/transactions/$txid/raw")
|
||||||
|
return response["hex"] ?: throw RpcException("Missing hex in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a raw transaction.
|
||||||
|
*/
|
||||||
|
suspend fun sendRawTransaction(hex: String): SubmitResult {
|
||||||
|
return post("/transactions/send", mapOf("hex" to hex))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a raw transaction without broadcasting.
|
||||||
|
*/
|
||||||
|
suspend fun decodeRawTransaction(hex: String): Transaction {
|
||||||
|
return post("/transactions/decode", mapOf("hex" to hex))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get transactions for an address.
|
||||||
|
*/
|
||||||
|
suspend fun getAddressTransactions(
|
||||||
|
address: String,
|
||||||
|
limit: Int = 50,
|
||||||
|
offset: Int = 0
|
||||||
|
): List<Transaction> {
|
||||||
|
return get("/addresses/$address/transactions?limit=$limit&offset=$offset")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fee estimation
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate fee for a given priority.
|
||||||
|
*/
|
||||||
|
suspend fun estimateFee(priority: Priority = Priority.MEDIUM): FeeEstimate {
|
||||||
|
return get("/fees/estimate?priority=${priority.name.lowercase()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all fee estimates.
|
||||||
|
*/
|
||||||
|
suspend fun getAllFeeEstimates(): Map<Priority, FeeEstimate> {
|
||||||
|
val estimates: List<FeeEstimate> = get("/fees/estimates")
|
||||||
|
return estimates.associateBy { it.priority }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chain information
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get chain information.
|
||||||
|
*/
|
||||||
|
suspend fun getChainInfo(): ChainInfo {
|
||||||
|
return get("/chain/info")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get mempool information.
|
||||||
|
*/
|
||||||
|
suspend fun getMempoolInfo(): MempoolInfo {
|
||||||
|
return get("/mempool/info")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get mempool transactions.
|
||||||
|
*/
|
||||||
|
suspend fun getMempoolTransactions(limit: Int = 100): List<String> {
|
||||||
|
return get("/mempool/transactions?limit=$limit")
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket subscriptions
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to new blocks.
|
||||||
|
*/
|
||||||
|
suspend fun subscribeBlocks(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
callback: (Block) -> Unit
|
||||||
|
): Subscription {
|
||||||
|
return subscribe(scope, "blocks", null) { data ->
|
||||||
|
val block = json.decodeFromString<Block>(data)
|
||||||
|
callback(block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to transactions for a specific address.
|
||||||
|
*/
|
||||||
|
suspend fun subscribeAddress(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
address: String,
|
||||||
|
callback: (Transaction) -> Unit
|
||||||
|
): Subscription {
|
||||||
|
return subscribe(scope, "address", address) { data ->
|
||||||
|
val tx = json.decodeFromString<Transaction>(data)
|
||||||
|
callback(tx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to mempool transactions.
|
||||||
|
*/
|
||||||
|
suspend fun subscribeMempool(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
callback: (Transaction) -> Unit
|
||||||
|
): Subscription {
|
||||||
|
return subscribe(scope, "mempool", null) { data ->
|
||||||
|
val tx = json.decodeFromString<Transaction>(data)
|
||||||
|
callback(tx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun subscribe(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
channel: String,
|
||||||
|
filter: String?,
|
||||||
|
callback: (String) -> Unit
|
||||||
|
): Subscription {
|
||||||
|
ensureWebSocketConnection(scope)
|
||||||
|
|
||||||
|
val subscriptionId = UUID.randomUUID().toString()
|
||||||
|
subscriptions[subscriptionId] = callback
|
||||||
|
|
||||||
|
val message = WsMessage(
|
||||||
|
type = "subscribe",
|
||||||
|
channel = channel,
|
||||||
|
filter = filter
|
||||||
|
)
|
||||||
|
wsSession?.send(Frame.Text(json.encodeToString(WsMessage.serializer(), message)))
|
||||||
|
|
||||||
|
return Subscription(
|
||||||
|
id = subscriptionId,
|
||||||
|
channel = channel,
|
||||||
|
onCancel = {
|
||||||
|
subscriptions.remove(subscriptionId)
|
||||||
|
scope.launch {
|
||||||
|
val unsubMsg = WsMessage(type = "unsubscribe", channel = channel)
|
||||||
|
wsSession?.send(Frame.Text(json.encodeToString(WsMessage.serializer(), unsubMsg)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureWebSocketConnection(scope: CoroutineScope) {
|
||||||
|
if (wsSession != null && wsSession?.isActive == true) return
|
||||||
|
|
||||||
|
wsSession = client.webSocketSession("${config.wsEndpoint}?token=${config.apiKey}")
|
||||||
|
|
||||||
|
wsJob = scope.launch {
|
||||||
|
try {
|
||||||
|
wsSession?.incoming?.consumeAsFlow()?.collect { frame ->
|
||||||
|
when (frame) {
|
||||||
|
is Frame.Text -> {
|
||||||
|
val text = frame.readText()
|
||||||
|
try {
|
||||||
|
val message = json.decodeFromString<WsMessage>(text)
|
||||||
|
if (message.type == "data" && message.data != null) {
|
||||||
|
subscriptions.values.forEach { it(message.data) }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (config.debug) {
|
||||||
|
println("Failed to parse WS message: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (config.debug) {
|
||||||
|
println("WebSocket error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun <reified T> get(path: String): T {
|
||||||
|
return executeWithRetry {
|
||||||
|
val response = client.get("${config.endpoint}$path")
|
||||||
|
handleResponse(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun <reified T, reified R> post(path: String, body: R): T {
|
||||||
|
return executeWithRetry {
|
||||||
|
val response = client.post("${config.endpoint}$path") {
|
||||||
|
setBody(body)
|
||||||
|
}
|
||||||
|
handleResponse(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun <reified T> handleResponse(response: HttpResponse): T {
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
return response.body()
|
||||||
|
}
|
||||||
|
|
||||||
|
val errorBody = try {
|
||||||
|
response.bodyAsText()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"Unknown error"
|
||||||
|
}
|
||||||
|
|
||||||
|
throw RpcException(
|
||||||
|
message = "RPC API error: $errorBody",
|
||||||
|
statusCode = response.status.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun <T> executeWithRetry(block: () -> T): T {
|
||||||
|
var lastException: Exception? = null
|
||||||
|
|
||||||
|
repeat(config.retries) { attempt ->
|
||||||
|
try {
|
||||||
|
return block()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
lastException = e
|
||||||
|
if (config.debug) {
|
||||||
|
println("Attempt ${attempt + 1} failed: ${e.message}")
|
||||||
|
}
|
||||||
|
if (attempt < config.retries - 1) {
|
||||||
|
delay(1000L * (attempt + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastException ?: RpcException("Unknown error after ${config.retries} retries")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
wsJob?.cancel()
|
||||||
|
runBlocking {
|
||||||
|
wsSession?.close()
|
||||||
|
}
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
237
sdk/kotlin/src/main/kotlin/io/synor/rpc/Types.kt
Normal file
237
sdk/kotlin/src/main/kotlin/io/synor/rpc/Types.kt
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
package io.synor.rpc
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network environment.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class Network {
|
||||||
|
@SerialName("mainnet") MAINNET,
|
||||||
|
@SerialName("testnet") TESTNET,
|
||||||
|
@SerialName("devnet") DEVNET
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transaction priority for fee estimation.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class Priority {
|
||||||
|
@SerialName("low") LOW,
|
||||||
|
@SerialName("medium") MEDIUM,
|
||||||
|
@SerialName("high") HIGH
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transaction status.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class TransactionStatus {
|
||||||
|
@SerialName("pending") PENDING,
|
||||||
|
@SerialName("confirmed") CONFIRMED,
|
||||||
|
@SerialName("failed") FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for the RPC client.
|
||||||
|
*/
|
||||||
|
data class RpcConfig(
|
||||||
|
val apiKey: String,
|
||||||
|
val endpoint: String = "https://rpc.synor.io/v1",
|
||||||
|
val wsEndpoint: String = "wss://rpc.synor.io/v1/ws",
|
||||||
|
val network: Network = Network.MAINNET,
|
||||||
|
val timeout: Long = 30_000L,
|
||||||
|
val retries: Int = 3,
|
||||||
|
val debug: Boolean = false
|
||||||
|
) {
|
||||||
|
class Builder(private val apiKey: String) {
|
||||||
|
private var endpoint: String = "https://rpc.synor.io/v1"
|
||||||
|
private var wsEndpoint: String = "wss://rpc.synor.io/v1/ws"
|
||||||
|
private var network: Network = Network.MAINNET
|
||||||
|
private var timeout: Long = 30_000L
|
||||||
|
private var retries: Int = 3
|
||||||
|
private var debug: Boolean = false
|
||||||
|
|
||||||
|
fun endpoint(endpoint: String) = apply { this.endpoint = endpoint }
|
||||||
|
fun wsEndpoint(wsEndpoint: String) = apply { this.wsEndpoint = wsEndpoint }
|
||||||
|
fun network(network: Network) = apply { this.network = network }
|
||||||
|
fun timeout(timeout: Long) = apply { this.timeout = timeout }
|
||||||
|
fun retries(retries: Int) = apply { this.retries = retries }
|
||||||
|
fun debug(debug: Boolean) = apply { this.debug = debug }
|
||||||
|
|
||||||
|
fun build() = RpcConfig(apiKey, endpoint, wsEndpoint, network, timeout, retries, debug)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun builder(apiKey: String) = Builder(apiKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block header information.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class BlockHeader(
|
||||||
|
val hash: String,
|
||||||
|
val height: Long,
|
||||||
|
@SerialName("previous_hash") val previousHash: String,
|
||||||
|
val timestamp: Long,
|
||||||
|
val version: Int,
|
||||||
|
@SerialName("merkle_root") val merkleRoot: String,
|
||||||
|
val nonce: Long,
|
||||||
|
val difficulty: Double
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A blockchain block.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class Block(
|
||||||
|
val hash: String,
|
||||||
|
val height: Long,
|
||||||
|
@SerialName("previous_hash") val previousHash: String,
|
||||||
|
val timestamp: Long,
|
||||||
|
val version: Int,
|
||||||
|
@SerialName("merkle_root") val merkleRoot: String,
|
||||||
|
val nonce: Long,
|
||||||
|
val difficulty: Double,
|
||||||
|
val transactions: List<Transaction>,
|
||||||
|
val size: Int,
|
||||||
|
val weight: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A blockchain transaction.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class Transaction(
|
||||||
|
val txid: String,
|
||||||
|
val hash: String,
|
||||||
|
val version: Int,
|
||||||
|
val size: Int,
|
||||||
|
val weight: Int,
|
||||||
|
@SerialName("lock_time") val lockTime: Long,
|
||||||
|
val inputs: List<TransactionInput>,
|
||||||
|
val outputs: List<TransactionOutput>,
|
||||||
|
val fee: Long,
|
||||||
|
val confirmations: Int,
|
||||||
|
@SerialName("block_hash") val blockHash: String? = null,
|
||||||
|
@SerialName("block_height") val blockHeight: Long? = null,
|
||||||
|
val timestamp: Long? = null,
|
||||||
|
val status: TransactionStatus = TransactionStatus.PENDING
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transaction input.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class TransactionInput(
|
||||||
|
val txid: String,
|
||||||
|
val vout: Int,
|
||||||
|
@SerialName("script_sig") val scriptSig: String? = null,
|
||||||
|
val sequence: Long,
|
||||||
|
val witness: List<String>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transaction output.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class TransactionOutput(
|
||||||
|
val value: Long,
|
||||||
|
val n: Int,
|
||||||
|
@SerialName("script_pubkey") val scriptPubKey: ScriptPubKey
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script public key.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ScriptPubKey(
|
||||||
|
val asm: String,
|
||||||
|
val hex: String,
|
||||||
|
val type: String,
|
||||||
|
val address: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chain information.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ChainInfo(
|
||||||
|
val chain: String,
|
||||||
|
val blocks: Long,
|
||||||
|
val headers: Long,
|
||||||
|
@SerialName("best_block_hash") val bestBlockHash: String,
|
||||||
|
val difficulty: Double,
|
||||||
|
@SerialName("median_time") val medianTime: Long,
|
||||||
|
@SerialName("verification_progress") val verificationProgress: Double,
|
||||||
|
@SerialName("chain_work") val chainWork: String,
|
||||||
|
val pruned: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mempool information.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class MempoolInfo(
|
||||||
|
val size: Int,
|
||||||
|
val bytes: Long,
|
||||||
|
val usage: Long,
|
||||||
|
@SerialName("max_mempool") val maxMempool: Long,
|
||||||
|
@SerialName("mempool_min_fee") val mempoolMinFee: Double,
|
||||||
|
@SerialName("min_relay_tx_fee") val minRelayTxFee: Double
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fee estimate for transactions.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class FeeEstimate(
|
||||||
|
val priority: Priority,
|
||||||
|
@SerialName("fee_rate") val feeRate: Long,
|
||||||
|
@SerialName("estimated_blocks") val estimatedBlocks: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of transaction submission.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class SubmitResult(
|
||||||
|
val txid: String,
|
||||||
|
val accepted: Boolean,
|
||||||
|
val reason: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscription handle for WebSocket events.
|
||||||
|
*/
|
||||||
|
data class Subscription(
|
||||||
|
val id: String,
|
||||||
|
val channel: String,
|
||||||
|
private val onCancel: () -> Unit
|
||||||
|
) {
|
||||||
|
fun cancel() = onCancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket message wrapper.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
internal data class WsMessage(
|
||||||
|
val type: String,
|
||||||
|
val channel: String,
|
||||||
|
val data: String? = null,
|
||||||
|
val filter: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown by RPC operations.
|
||||||
|
*/
|
||||||
|
class RpcException(
|
||||||
|
message: String,
|
||||||
|
val code: String? = null,
|
||||||
|
val statusCode: Int? = null,
|
||||||
|
cause: Throwable? = null
|
||||||
|
) : Exception(message, cause)
|
||||||
397
sdk/kotlin/src/main/kotlin/io/synor/storage/SynorStorage.kt
Normal file
397
sdk/kotlin/src/main/kotlin/io/synor/storage/SynorStorage.kt
Normal file
|
|
@ -0,0 +1,397 @@
|
||||||
|
package io.synor.storage
|
||||||
|
|
||||||
|
import io.ktor.client.*
|
||||||
|
import io.ktor.client.call.*
|
||||||
|
import io.ktor.client.engine.cio.*
|
||||||
|
import io.ktor.client.plugins.*
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.*
|
||||||
|
import io.ktor.client.request.*
|
||||||
|
import io.ktor.client.request.forms.*
|
||||||
|
import io.ktor.client.statement.*
|
||||||
|
import io.ktor.http.*
|
||||||
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
|
import io.ktor.utils.io.*
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.Closeable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synor Storage SDK client for Kotlin.
|
||||||
|
*
|
||||||
|
* Provides IPFS-compatible decentralized storage with upload, download,
|
||||||
|
* pinning, and CAR file operations.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* ```kotlin
|
||||||
|
* val storage = SynorStorage(StorageConfig.builder("your-api-key").build())
|
||||||
|
*
|
||||||
|
* // Upload a file
|
||||||
|
* val data = "Hello, World!".toByteArray()
|
||||||
|
* val result = storage.upload(data, UploadOptions(name = "hello.txt"))
|
||||||
|
* println("CID: ${result.cid}")
|
||||||
|
*
|
||||||
|
* // Download content
|
||||||
|
* val content = storage.download(result.cid)
|
||||||
|
* println("Content: ${String(content)}")
|
||||||
|
*
|
||||||
|
* // Pin content
|
||||||
|
* val pin = storage.pin(PinRequest(cid = result.cid, durationDays = 30))
|
||||||
|
* println("Pin status: ${pin.status}")
|
||||||
|
*
|
||||||
|
* storage.close()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class SynorStorage(private val config: StorageConfig) : Closeable {
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private val client = HttpClient(CIO) {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
install(HttpTimeout) {
|
||||||
|
requestTimeoutMillis = config.timeout
|
||||||
|
connectTimeoutMillis = config.timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultRequest {
|
||||||
|
header("Authorization", "Bearer ${config.apiKey}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload operations
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload data to storage.
|
||||||
|
*
|
||||||
|
* @param data The data to upload
|
||||||
|
* @param options Upload options
|
||||||
|
* @return Upload response with CID
|
||||||
|
*/
|
||||||
|
suspend fun upload(data: ByteArray, options: UploadOptions = UploadOptions()): UploadResponse {
|
||||||
|
return executeWithRetry {
|
||||||
|
val response = client.submitFormWithBinaryData(
|
||||||
|
url = "${config.endpoint}/upload",
|
||||||
|
formData = formData {
|
||||||
|
append("file", data, Headers.build {
|
||||||
|
append(HttpHeaders.ContentType, ContentType.Application.OctetStream)
|
||||||
|
append(HttpHeaders.ContentDisposition, "filename=\"${options.name ?: "file"}\"")
|
||||||
|
})
|
||||||
|
options.name?.let { append("name", it) }
|
||||||
|
append("wrap_with_directory", options.wrapWithDirectory.toString())
|
||||||
|
append("hash_algorithm", options.hashAlgorithm.name.lowercase().replace("_", "-"))
|
||||||
|
options.chunkSize?.let { append("chunk_size", it.toString()) }
|
||||||
|
options.pinDurationDays?.let { append("pin_duration_days", it.toString()) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
handleResponse(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload multiple files as a directory.
|
||||||
|
*
|
||||||
|
* @param files List of file entries
|
||||||
|
* @param dirName Optional directory name
|
||||||
|
* @return Upload response with root CID
|
||||||
|
*/
|
||||||
|
suspend fun uploadDirectory(files: List<FileEntry>, dirName: String? = null): UploadResponse {
|
||||||
|
return executeWithRetry {
|
||||||
|
val response = client.submitFormWithBinaryData(
|
||||||
|
url = "${config.endpoint}/upload/directory",
|
||||||
|
formData = formData {
|
||||||
|
files.forEach { file ->
|
||||||
|
append("files", file.content, Headers.build {
|
||||||
|
append(HttpHeaders.ContentType, ContentType.Application.OctetStream)
|
||||||
|
append(HttpHeaders.ContentDisposition, "filename=\"${file.path}\"")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
dirName?.let { append("name", it) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
handleResponse(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download operations
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download content by CID.
|
||||||
|
*
|
||||||
|
* @param cid The content identifier
|
||||||
|
* @return The content as bytes
|
||||||
|
*/
|
||||||
|
suspend fun download(cid: String): ByteArray {
|
||||||
|
return executeWithRetry {
|
||||||
|
val response = client.get("${config.endpoint}/content/$cid")
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
response.body()
|
||||||
|
} else {
|
||||||
|
throw StorageException(
|
||||||
|
"Failed to download content: ${response.status}",
|
||||||
|
statusCode = response.status.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download content as a stream.
|
||||||
|
*
|
||||||
|
* @param cid The content identifier
|
||||||
|
* @return Flow of byte chunks
|
||||||
|
*/
|
||||||
|
fun downloadStream(cid: String): Flow<ByteArray> = flow {
|
||||||
|
val response = client.get("${config.endpoint}/content/$cid")
|
||||||
|
if (!response.status.isSuccess()) {
|
||||||
|
throw StorageException(
|
||||||
|
"Failed to download content: ${response.status}",
|
||||||
|
statusCode = response.status.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val channel: ByteReadChannel = response.body()
|
||||||
|
val buffer = ByteArray(8192)
|
||||||
|
while (!channel.isClosedForRead) {
|
||||||
|
val bytesRead = channel.readAvailable(buffer)
|
||||||
|
if (bytesRead > 0) {
|
||||||
|
emit(buffer.copyOf(bytesRead))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the gateway URL for content.
|
||||||
|
*
|
||||||
|
* @param cid The content identifier
|
||||||
|
* @param path Optional path within directory
|
||||||
|
* @return Gateway URL
|
||||||
|
*/
|
||||||
|
fun getGatewayUrl(cid: String, path: String? = null): String {
|
||||||
|
return if (path != null) {
|
||||||
|
"${config.gateway}/ipfs/$cid/$path"
|
||||||
|
} else {
|
||||||
|
"${config.gateway}/ipfs/$cid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pinning operations
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pin content to ensure availability.
|
||||||
|
*
|
||||||
|
* @param request Pin request
|
||||||
|
* @return Pin status
|
||||||
|
*/
|
||||||
|
suspend fun pin(request: PinRequest): Pin {
|
||||||
|
return post("/pins", request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unpin content.
|
||||||
|
*
|
||||||
|
* @param cid The content identifier
|
||||||
|
*/
|
||||||
|
suspend fun unpin(cid: String) {
|
||||||
|
delete("/pins/$cid")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pin status.
|
||||||
|
*
|
||||||
|
* @param cid The content identifier
|
||||||
|
* @return Pin information
|
||||||
|
*/
|
||||||
|
suspend fun getPinStatus(cid: String): Pin {
|
||||||
|
return get("/pins/$cid")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all pins.
|
||||||
|
*
|
||||||
|
* @param status Optional status filter
|
||||||
|
* @param limit Maximum number of results
|
||||||
|
* @param offset Pagination offset
|
||||||
|
* @return List of pins
|
||||||
|
*/
|
||||||
|
suspend fun listPins(
|
||||||
|
status: PinStatus? = null,
|
||||||
|
limit: Int = 50,
|
||||||
|
offset: Int = 0
|
||||||
|
): List<Pin> {
|
||||||
|
val query = buildString {
|
||||||
|
append("?limit=$limit&offset=$offset")
|
||||||
|
status?.let { append("&status=${it.name.lowercase()}") }
|
||||||
|
}
|
||||||
|
return get("/pins$query")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CAR file operations
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a CAR file from entries.
|
||||||
|
*
|
||||||
|
* @param entries List of entries
|
||||||
|
* @return CAR file information
|
||||||
|
*/
|
||||||
|
suspend fun createCar(entries: List<CarEntry>): CarFile {
|
||||||
|
return executeWithRetry {
|
||||||
|
val response = client.submitFormWithBinaryData(
|
||||||
|
url = "${config.endpoint}/car",
|
||||||
|
formData = formData {
|
||||||
|
entries.forEach { entry ->
|
||||||
|
append("files", entry.content, Headers.build {
|
||||||
|
append(HttpHeaders.ContentType, ContentType.Application.OctetStream)
|
||||||
|
append(HttpHeaders.ContentDisposition, "filename=\"${entry.path}\"")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
handleResponse(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a CAR file.
|
||||||
|
*
|
||||||
|
* @param carData CAR file bytes
|
||||||
|
* @return List of imported CIDs
|
||||||
|
*/
|
||||||
|
suspend fun importCar(carData: ByteArray): List<String> {
|
||||||
|
return executeWithRetry {
|
||||||
|
val response = client.submitFormWithBinaryData(
|
||||||
|
url = "${config.endpoint}/car/import",
|
||||||
|
formData = formData {
|
||||||
|
append("file", carData, Headers.build {
|
||||||
|
append(HttpHeaders.ContentType, ContentType.Application.OctetStream)
|
||||||
|
append(HttpHeaders.ContentDisposition, "filename=\"archive.car\"")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
val result: Map<String, List<String>> = handleResponse(response)
|
||||||
|
result["cids"] ?: emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export content as a CAR file.
|
||||||
|
*
|
||||||
|
* @param cid The content identifier
|
||||||
|
* @return CAR file bytes
|
||||||
|
*/
|
||||||
|
suspend fun exportCar(cid: String): ByteArray {
|
||||||
|
return executeWithRetry {
|
||||||
|
val response = client.get("${config.endpoint}/car/$cid")
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
response.body()
|
||||||
|
} else {
|
||||||
|
throw StorageException(
|
||||||
|
"Failed to export CAR: ${response.status}",
|
||||||
|
statusCode = response.status.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directory operations
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List directory contents.
|
||||||
|
*
|
||||||
|
* @param cid The directory CID
|
||||||
|
* @return List of entries
|
||||||
|
*/
|
||||||
|
suspend fun listDirectory(cid: String): List<DirectoryEntry> {
|
||||||
|
return get("/content/$cid/ls")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get storage statistics.
|
||||||
|
*
|
||||||
|
* @return Storage stats
|
||||||
|
*/
|
||||||
|
suspend fun getStats(): StorageStats {
|
||||||
|
return get("/stats")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun <reified T> get(path: String): T {
|
||||||
|
return executeWithRetry {
|
||||||
|
val response = client.get("${config.endpoint}$path") {
|
||||||
|
header("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
handleResponse(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun <reified T, reified R> post(path: String, body: R): T {
|
||||||
|
return executeWithRetry {
|
||||||
|
val response = client.post("${config.endpoint}$path") {
|
||||||
|
header("Content-Type", "application/json")
|
||||||
|
setBody(body)
|
||||||
|
}
|
||||||
|
handleResponse(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun delete(path: String) {
|
||||||
|
executeWithRetry {
|
||||||
|
val response = client.delete("${config.endpoint}$path")
|
||||||
|
if (!response.status.isSuccess()) {
|
||||||
|
throw StorageException(
|
||||||
|
"Delete failed: ${response.status}",
|
||||||
|
statusCode = response.status.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun <reified T> handleResponse(response: HttpResponse): T {
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
return response.body()
|
||||||
|
}
|
||||||
|
|
||||||
|
val errorBody = try {
|
||||||
|
response.bodyAsText()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"Unknown error"
|
||||||
|
}
|
||||||
|
|
||||||
|
throw StorageException(
|
||||||
|
message = "Storage API error: $errorBody",
|
||||||
|
statusCode = response.status.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun <T> executeWithRetry(block: () -> T): T {
|
||||||
|
var lastException: Exception? = null
|
||||||
|
|
||||||
|
repeat(config.retries) { attempt ->
|
||||||
|
try {
|
||||||
|
return block()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
lastException = e
|
||||||
|
if (config.debug) {
|
||||||
|
println("Attempt ${attempt + 1} failed: ${e.message}")
|
||||||
|
}
|
||||||
|
if (attempt < config.retries - 1) {
|
||||||
|
delay(1000L * (attempt + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastException ?: StorageException("Unknown error after ${config.retries} retries")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
194
sdk/kotlin/src/main/kotlin/io/synor/storage/Types.kt
Normal file
194
sdk/kotlin/src/main/kotlin/io/synor/storage/Types.kt
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
package io.synor.storage
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status of a pinned item.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class PinStatus {
|
||||||
|
@SerialName("queued") QUEUED,
|
||||||
|
@SerialName("pinning") PINNING,
|
||||||
|
@SerialName("pinned") PINNED,
|
||||||
|
@SerialName("failed") FAILED,
|
||||||
|
@SerialName("unpinned") UNPINNED
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash algorithm for content addressing.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class HashAlgorithm {
|
||||||
|
@SerialName("sha2-256") SHA2_256,
|
||||||
|
@SerialName("blake3") BLAKE3
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type of directory entry.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class EntryType {
|
||||||
|
@SerialName("file") FILE,
|
||||||
|
@SerialName("directory") DIRECTORY
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for the storage client.
|
||||||
|
*/
|
||||||
|
data class StorageConfig(
|
||||||
|
val apiKey: String,
|
||||||
|
val endpoint: String = "https://storage.synor.io/v1",
|
||||||
|
val gateway: String = "https://gateway.synor.io",
|
||||||
|
val timeout: Long = 60_000L,
|
||||||
|
val retries: Int = 3,
|
||||||
|
val debug: Boolean = false
|
||||||
|
) {
|
||||||
|
class Builder(private val apiKey: String) {
|
||||||
|
private var endpoint: String = "https://storage.synor.io/v1"
|
||||||
|
private var gateway: String = "https://gateway.synor.io"
|
||||||
|
private var timeout: Long = 60_000L
|
||||||
|
private var retries: Int = 3
|
||||||
|
private var debug: Boolean = false
|
||||||
|
|
||||||
|
fun endpoint(endpoint: String) = apply { this.endpoint = endpoint }
|
||||||
|
fun gateway(gateway: String) = apply { this.gateway = gateway }
|
||||||
|
fun timeout(timeout: Long) = apply { this.timeout = timeout }
|
||||||
|
fun retries(retries: Int) = apply { this.retries = retries }
|
||||||
|
fun debug(debug: Boolean) = apply { this.debug = debug }
|
||||||
|
|
||||||
|
fun build() = StorageConfig(apiKey, endpoint, gateway, timeout, retries, debug)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun builder(apiKey: String) = Builder(apiKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for file upload.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class UploadOptions(
|
||||||
|
val name: String? = null,
|
||||||
|
@SerialName("wrap_with_directory") val wrapWithDirectory: Boolean = false,
|
||||||
|
@SerialName("hash_algorithm") val hashAlgorithm: HashAlgorithm = HashAlgorithm.SHA2_256,
|
||||||
|
@SerialName("chunk_size") val chunkSize: Int? = null,
|
||||||
|
@SerialName("pin_duration_days") val pinDurationDays: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response from upload operation.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class UploadResponse(
|
||||||
|
val cid: String,
|
||||||
|
val size: Long,
|
||||||
|
val name: String? = null,
|
||||||
|
@SerialName("created_at") val createdAt: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pin information.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class Pin(
|
||||||
|
val cid: String,
|
||||||
|
val name: String? = null,
|
||||||
|
val status: PinStatus,
|
||||||
|
val size: Long,
|
||||||
|
@SerialName("created_at") val createdAt: String,
|
||||||
|
@SerialName("expires_at") val expiresAt: String? = null,
|
||||||
|
val delegates: List<String>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request to pin content.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PinRequest(
|
||||||
|
val cid: String,
|
||||||
|
val name: String? = null,
|
||||||
|
@SerialName("duration_days") val durationDays: Int? = null,
|
||||||
|
val origins: List<String>? = null,
|
||||||
|
val meta: Map<String, String>? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CAR (Content Addressable Archive) file.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class CarFile(
|
||||||
|
val cid: String,
|
||||||
|
val size: Long,
|
||||||
|
val roots: List<String>,
|
||||||
|
@SerialName("created_at") val createdAt: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry in a CAR file for creation.
|
||||||
|
*/
|
||||||
|
data class CarEntry(
|
||||||
|
val path: String,
|
||||||
|
val content: ByteArray
|
||||||
|
) {
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other !is CarEntry) return false
|
||||||
|
return path == other.path && content.contentEquals(other.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
return 31 * path.hashCode() + content.contentHashCode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directory entry.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class DirectoryEntry(
|
||||||
|
val name: String,
|
||||||
|
val cid: String,
|
||||||
|
val size: Long,
|
||||||
|
val type: EntryType
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File entry for directory creation.
|
||||||
|
*/
|
||||||
|
data class FileEntry(
|
||||||
|
val path: String,
|
||||||
|
val content: ByteArray
|
||||||
|
) {
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other !is FileEntry) return false
|
||||||
|
return path == other.path && content.contentEquals(other.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
return 31 * path.hashCode() + content.contentHashCode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage usage statistics.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class StorageStats(
|
||||||
|
@SerialName("total_size") val totalSize: Long,
|
||||||
|
@SerialName("pin_count") val pinCount: Int,
|
||||||
|
@SerialName("bandwidth_used") val bandwidthUsed: Long,
|
||||||
|
@SerialName("quota_used") val quotaUsed: Double
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown by storage operations.
|
||||||
|
*/
|
||||||
|
class StorageException(
|
||||||
|
message: String,
|
||||||
|
val code: String? = null,
|
||||||
|
val statusCode: Int? = null,
|
||||||
|
cause: Throwable? = null
|
||||||
|
) : Exception(message, cause)
|
||||||
246
sdk/kotlin/src/main/kotlin/io/synor/wallet/SynorWallet.kt
Normal file
246
sdk/kotlin/src/main/kotlin/io/synor/wallet/SynorWallet.kt
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
package io.synor.wallet
|
||||||
|
|
||||||
|
import io.ktor.client.*
|
||||||
|
import io.ktor.client.call.*
|
||||||
|
import io.ktor.client.engine.cio.*
|
||||||
|
import io.ktor.client.plugins.*
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.*
|
||||||
|
import io.ktor.client.request.*
|
||||||
|
import io.ktor.client.statement.*
|
||||||
|
import io.ktor.http.*
|
||||||
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.Closeable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synor Wallet SDK client for Kotlin.
|
||||||
|
*
|
||||||
|
* Provides key management, transaction signing, and balance queries
|
||||||
|
* for the Synor blockchain.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* ```kotlin
|
||||||
|
* val wallet = SynorWallet(WalletConfig.builder("your-api-key").build())
|
||||||
|
*
|
||||||
|
* // Create a new wallet
|
||||||
|
* val result = wallet.createWallet(WalletType.STANDARD)
|
||||||
|
* println("Wallet ID: ${result.wallet.id}")
|
||||||
|
* println("Mnemonic: ${result.mnemonic}")
|
||||||
|
*
|
||||||
|
* // Get balance
|
||||||
|
* val balance = wallet.getBalance(result.wallet.addresses[0].address)
|
||||||
|
* println("Balance: ${balance.total}")
|
||||||
|
*
|
||||||
|
* wallet.close()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class SynorWallet(private val config: WalletConfig) : Closeable {
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private val client = HttpClient(CIO) {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
install(HttpTimeout) {
|
||||||
|
requestTimeoutMillis = config.timeout
|
||||||
|
connectTimeoutMillis = config.timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultRequest {
|
||||||
|
header("Authorization", "Bearer ${config.apiKey}")
|
||||||
|
header("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new wallet.
|
||||||
|
*
|
||||||
|
* @param type The type of wallet to create
|
||||||
|
* @return Result containing the wallet and optionally the mnemonic
|
||||||
|
*/
|
||||||
|
suspend fun createWallet(type: WalletType = WalletType.STANDARD): CreateWalletResult {
|
||||||
|
val request = CreateWalletRequest(type, config.network)
|
||||||
|
return post("/wallets", request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a wallet from a mnemonic phrase.
|
||||||
|
*
|
||||||
|
* @param mnemonic The 12 or 24 word mnemonic phrase
|
||||||
|
* @param passphrase Optional BIP39 passphrase
|
||||||
|
* @return The imported wallet
|
||||||
|
*/
|
||||||
|
suspend fun importWallet(mnemonic: String, passphrase: String? = null): Wallet {
|
||||||
|
val request = ImportWalletRequest(mnemonic, passphrase, config.network)
|
||||||
|
return post("/wallets/import", request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a wallet by ID.
|
||||||
|
*
|
||||||
|
* @param walletId The wallet ID
|
||||||
|
* @return The wallet
|
||||||
|
*/
|
||||||
|
suspend fun getWallet(walletId: String): Wallet {
|
||||||
|
return get("/wallets/$walletId")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new address for a wallet.
|
||||||
|
*
|
||||||
|
* @param walletId The wallet ID
|
||||||
|
* @param isChange Whether this is a change address
|
||||||
|
* @return The new address
|
||||||
|
*/
|
||||||
|
suspend fun generateAddress(walletId: String, isChange: Boolean = false): Address {
|
||||||
|
return post("/wallets/$walletId/addresses", mapOf("is_change" to isChange))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a stealth address for privacy transactions.
|
||||||
|
*
|
||||||
|
* @param walletId The wallet ID
|
||||||
|
* @return The stealth address
|
||||||
|
*/
|
||||||
|
suspend fun getStealthAddress(walletId: String): StealthAddress {
|
||||||
|
return get("/wallets/$walletId/stealth-address")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a transaction.
|
||||||
|
*
|
||||||
|
* @param walletId The wallet ID to sign with
|
||||||
|
* @param transaction The transaction to sign
|
||||||
|
* @return The signed transaction
|
||||||
|
*/
|
||||||
|
suspend fun signTransaction(walletId: String, transaction: Transaction): SignedTransaction {
|
||||||
|
val request = SignTransactionRequest(walletId, transaction)
|
||||||
|
return post("/transactions/sign", request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a message with a wallet address.
|
||||||
|
*
|
||||||
|
* @param walletId The wallet ID
|
||||||
|
* @param message The message to sign
|
||||||
|
* @param addressIndex The address index to use for signing
|
||||||
|
* @return The signature
|
||||||
|
*/
|
||||||
|
suspend fun signMessage(walletId: String, message: String, addressIndex: Int = 0): Signature {
|
||||||
|
val request = SignMessageRequest(walletId, message, addressIndex)
|
||||||
|
return post("/messages/sign", request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a message signature.
|
||||||
|
*
|
||||||
|
* @param message The original message
|
||||||
|
* @param signature The signature to verify
|
||||||
|
* @param address The address that signed the message
|
||||||
|
* @return True if the signature is valid
|
||||||
|
*/
|
||||||
|
suspend fun verifyMessage(message: String, signature: String, address: String): Boolean {
|
||||||
|
val request = mapOf(
|
||||||
|
"message" to message,
|
||||||
|
"signature" to signature,
|
||||||
|
"address" to address
|
||||||
|
)
|
||||||
|
val response: Map<String, Boolean> = post("/messages/verify", request)
|
||||||
|
return response["valid"] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the balance for an address.
|
||||||
|
*
|
||||||
|
* @param address The address to query
|
||||||
|
* @return The balance information
|
||||||
|
*/
|
||||||
|
suspend fun getBalance(address: String): Balance {
|
||||||
|
return get("/addresses/$address/balance")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get UTXOs for an address.
|
||||||
|
*
|
||||||
|
* @param address The address to query
|
||||||
|
* @param minConfirmations Minimum confirmations required
|
||||||
|
* @return List of UTXOs
|
||||||
|
*/
|
||||||
|
suspend fun getUTXOs(address: String, minConfirmations: Int = 1): List<UTXO> {
|
||||||
|
return get("/addresses/$address/utxos?min_confirmations=$minConfirmations")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate transaction fee.
|
||||||
|
*
|
||||||
|
* @param priority The transaction priority
|
||||||
|
* @return Estimated fee in satoshis per byte
|
||||||
|
*/
|
||||||
|
suspend fun estimateFee(priority: Priority = Priority.MEDIUM): Long {
|
||||||
|
val response: Map<String, Long> = get("/fees/estimate?priority=${priority.name.lowercase()}")
|
||||||
|
return response["fee_per_byte"] ?: 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun <reified T> get(path: String): T {
|
||||||
|
return executeWithRetry {
|
||||||
|
val response = client.get("${config.endpoint}$path")
|
||||||
|
handleResponse(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun <reified T, reified R> post(path: String, body: R): T {
|
||||||
|
return executeWithRetry {
|
||||||
|
val response = client.post("${config.endpoint}$path") {
|
||||||
|
setBody(body)
|
||||||
|
}
|
||||||
|
handleResponse(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun <reified T> handleResponse(response: HttpResponse): T {
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
return response.body()
|
||||||
|
}
|
||||||
|
|
||||||
|
val errorBody = try {
|
||||||
|
response.bodyAsText()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"Unknown error"
|
||||||
|
}
|
||||||
|
|
||||||
|
throw WalletException(
|
||||||
|
message = "Wallet API error: $errorBody",
|
||||||
|
statusCode = response.status.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun <T> executeWithRetry(block: () -> T): T {
|
||||||
|
var lastException: Exception? = null
|
||||||
|
|
||||||
|
repeat(config.retries) { attempt ->
|
||||||
|
try {
|
||||||
|
return block()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
lastException = e
|
||||||
|
if (config.debug) {
|
||||||
|
println("Attempt ${attempt + 1} failed: ${e.message}")
|
||||||
|
}
|
||||||
|
if (attempt < config.retries - 1) {
|
||||||
|
kotlinx.coroutines.delay(1000L * (attempt + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastException ?: WalletException("Unknown error after ${config.retries} retries")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
230
sdk/kotlin/src/main/kotlin/io/synor/wallet/Types.kt
Normal file
230
sdk/kotlin/src/main/kotlin/io/synor/wallet/Types.kt
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
package io.synor.wallet
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network environment for wallet operations.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class Network {
|
||||||
|
@SerialName("mainnet") MAINNET,
|
||||||
|
@SerialName("testnet") TESTNET,
|
||||||
|
@SerialName("devnet") DEVNET
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type of wallet to create.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class WalletType {
|
||||||
|
@SerialName("standard") STANDARD,
|
||||||
|
@SerialName("multisig") MULTISIG,
|
||||||
|
@SerialName("hardware") HARDWARE,
|
||||||
|
@SerialName("stealth") STEALTH
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transaction priority for fee estimation.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class Priority {
|
||||||
|
@SerialName("low") LOW,
|
||||||
|
@SerialName("medium") MEDIUM,
|
||||||
|
@SerialName("high") HIGH
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for the wallet client.
|
||||||
|
*/
|
||||||
|
data class WalletConfig(
|
||||||
|
val apiKey: String,
|
||||||
|
val endpoint: String = "https://wallet.synor.io/v1",
|
||||||
|
val network: Network = Network.MAINNET,
|
||||||
|
val timeout: Long = 30_000L,
|
||||||
|
val retries: Int = 3,
|
||||||
|
val debug: Boolean = false
|
||||||
|
) {
|
||||||
|
class Builder(private val apiKey: String) {
|
||||||
|
private var endpoint: String = "https://wallet.synor.io/v1"
|
||||||
|
private var network: Network = Network.MAINNET
|
||||||
|
private var timeout: Long = 30_000L
|
||||||
|
private var retries: Int = 3
|
||||||
|
private var debug: Boolean = false
|
||||||
|
|
||||||
|
fun endpoint(endpoint: String) = apply { this.endpoint = endpoint }
|
||||||
|
fun network(network: Network) = apply { this.network = network }
|
||||||
|
fun timeout(timeout: Long) = apply { this.timeout = timeout }
|
||||||
|
fun retries(retries: Int) = apply { this.retries = retries }
|
||||||
|
fun debug(debug: Boolean) = apply { this.debug = debug }
|
||||||
|
|
||||||
|
fun build() = WalletConfig(apiKey, endpoint, network, timeout, retries, debug)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun builder(apiKey: String) = Builder(apiKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Synor wallet with addresses and keys.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class Wallet(
|
||||||
|
val id: String,
|
||||||
|
val type: WalletType,
|
||||||
|
val network: Network,
|
||||||
|
val addresses: List<Address>,
|
||||||
|
@SerialName("created_at") val createdAt: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wallet address.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class Address(
|
||||||
|
val address: String,
|
||||||
|
val index: Int,
|
||||||
|
@SerialName("is_change") val isChange: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stealth address for privacy transactions.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class StealthAddress(
|
||||||
|
@SerialName("spend_public_key") val spendPublicKey: String,
|
||||||
|
@SerialName("view_public_key") val viewPublicKey: String,
|
||||||
|
@SerialName("one_time_address") val oneTimeAddress: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of wallet creation.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class CreateWalletResult(
|
||||||
|
val wallet: Wallet,
|
||||||
|
val mnemonic: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request to create a new wallet.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
internal data class CreateWalletRequest(
|
||||||
|
val type: WalletType,
|
||||||
|
val network: Network
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request to import a wallet.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
internal data class ImportWalletRequest(
|
||||||
|
val mnemonic: String,
|
||||||
|
val passphrase: String?,
|
||||||
|
val network: Network
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A blockchain transaction.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class Transaction(
|
||||||
|
val inputs: List<TransactionInput>,
|
||||||
|
val outputs: List<TransactionOutput>,
|
||||||
|
val fee: Long? = null,
|
||||||
|
val priority: Priority = Priority.MEDIUM
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transaction input (UTXO reference).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class TransactionInput(
|
||||||
|
val txid: String,
|
||||||
|
val vout: Int,
|
||||||
|
val amount: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transaction output.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class TransactionOutput(
|
||||||
|
val address: String,
|
||||||
|
val amount: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A signed transaction ready for broadcast.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class SignedTransaction(
|
||||||
|
val txid: String,
|
||||||
|
val hex: String,
|
||||||
|
val size: Int,
|
||||||
|
val fee: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request to sign a transaction.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
internal data class SignTransactionRequest(
|
||||||
|
@SerialName("wallet_id") val walletId: String,
|
||||||
|
val transaction: Transaction
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request to sign a message.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
internal data class SignMessageRequest(
|
||||||
|
@SerialName("wallet_id") val walletId: String,
|
||||||
|
val message: String,
|
||||||
|
@SerialName("address_index") val addressIndex: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cryptographic signature.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class Signature(
|
||||||
|
val signature: String,
|
||||||
|
val address: String,
|
||||||
|
@SerialName("recovery_id") val recoveryId: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unspent transaction output.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class UTXO(
|
||||||
|
val txid: String,
|
||||||
|
val vout: Int,
|
||||||
|
val amount: Long,
|
||||||
|
val address: String,
|
||||||
|
val confirmations: Int,
|
||||||
|
@SerialName("script_pubkey") val scriptPubKey: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wallet balance information.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class Balance(
|
||||||
|
val confirmed: Long,
|
||||||
|
val unconfirmed: Long,
|
||||||
|
val total: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown by wallet operations.
|
||||||
|
*/
|
||||||
|
class WalletException(
|
||||||
|
message: String,
|
||||||
|
val code: String? = null,
|
||||||
|
val statusCode: Int? = null,
|
||||||
|
cause: Throwable? = null
|
||||||
|
) : Exception(message, cause)
|
||||||
74
sdk/python/src/synor_bridge/__init__.py
Normal file
74
sdk/python/src/synor_bridge/__init__.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""
|
||||||
|
Synor Bridge SDK for Python
|
||||||
|
|
||||||
|
Cross-chain asset transfers with lock-mint and burn-unlock patterns.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> from synor_bridge import SynorBridge
|
||||||
|
>>>
|
||||||
|
>>> bridge = SynorBridge(api_key="your-api-key")
|
||||||
|
>>>
|
||||||
|
>>> # Bridge tokens from Synor to Ethereum
|
||||||
|
>>> transfer = await bridge.bridge_to(
|
||||||
|
... asset="SYNR",
|
||||||
|
... amount="1000000000000000000",
|
||||||
|
... target_chain="ethereum",
|
||||||
|
... target_address="0x..."
|
||||||
|
... )
|
||||||
|
>>> print(f"Transfer {transfer.id}: {transfer.status}")
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .types import (
|
||||||
|
BridgeConfig,
|
||||||
|
Chain,
|
||||||
|
ChainId,
|
||||||
|
Asset,
|
||||||
|
AssetType,
|
||||||
|
WrappedAsset,
|
||||||
|
Transfer,
|
||||||
|
TransferStatus,
|
||||||
|
TransferDirection,
|
||||||
|
TransferFilter,
|
||||||
|
LockReceipt,
|
||||||
|
LockProof,
|
||||||
|
LockOptions,
|
||||||
|
BurnReceipt,
|
||||||
|
BurnProof,
|
||||||
|
BurnOptions,
|
||||||
|
MintOptions,
|
||||||
|
UnlockOptions,
|
||||||
|
FeeEstimate,
|
||||||
|
ExchangeRate,
|
||||||
|
ValidatorSignature,
|
||||||
|
SignedTransaction,
|
||||||
|
BridgeError,
|
||||||
|
)
|
||||||
|
from .client import SynorBridge
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
__all__ = [
|
||||||
|
"SynorBridge",
|
||||||
|
"BridgeConfig",
|
||||||
|
"Chain",
|
||||||
|
"ChainId",
|
||||||
|
"Asset",
|
||||||
|
"AssetType",
|
||||||
|
"WrappedAsset",
|
||||||
|
"Transfer",
|
||||||
|
"TransferStatus",
|
||||||
|
"TransferDirection",
|
||||||
|
"TransferFilter",
|
||||||
|
"LockReceipt",
|
||||||
|
"LockProof",
|
||||||
|
"LockOptions",
|
||||||
|
"BurnReceipt",
|
||||||
|
"BurnProof",
|
||||||
|
"BurnOptions",
|
||||||
|
"MintOptions",
|
||||||
|
"UnlockOptions",
|
||||||
|
"FeeEstimate",
|
||||||
|
"ExchangeRate",
|
||||||
|
"ValidatorSignature",
|
||||||
|
"SignedTransaction",
|
||||||
|
"BridgeError",
|
||||||
|
]
|
||||||
527
sdk/python/src/synor_bridge/client.py
Normal file
527
sdk/python/src/synor_bridge/client.py
Normal file
|
|
@ -0,0 +1,527 @@
|
||||||
|
"""
|
||||||
|
Synor Bridge SDK Client
|
||||||
|
|
||||||
|
Cross-chain asset transfers with lock-mint and burn-unlock patterns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .types import (
|
||||||
|
BridgeConfig,
|
||||||
|
BridgeError,
|
||||||
|
Chain,
|
||||||
|
ChainId,
|
||||||
|
Asset,
|
||||||
|
WrappedAsset,
|
||||||
|
Transfer,
|
||||||
|
TransferStatus,
|
||||||
|
TransferFilter,
|
||||||
|
LockReceipt,
|
||||||
|
LockProof,
|
||||||
|
LockOptions,
|
||||||
|
BurnReceipt,
|
||||||
|
BurnProof,
|
||||||
|
BurnOptions,
|
||||||
|
MintOptions,
|
||||||
|
UnlockOptions,
|
||||||
|
FeeEstimate,
|
||||||
|
ExchangeRate,
|
||||||
|
SignedTransaction,
|
||||||
|
DEFAULT_BRIDGE_ENDPOINT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SynorBridge:
|
||||||
|
"""Synor Bridge Client for cross-chain transfers."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_key: str,
|
||||||
|
endpoint: str = DEFAULT_BRIDGE_ENDPOINT,
|
||||||
|
timeout: float = 60.0,
|
||||||
|
retries: int = 3,
|
||||||
|
debug: bool = False,
|
||||||
|
):
|
||||||
|
self.config = BridgeConfig(
|
||||||
|
api_key=api_key,
|
||||||
|
endpoint=endpoint,
|
||||||
|
timeout=timeout,
|
||||||
|
retries=retries,
|
||||||
|
debug=debug,
|
||||||
|
)
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
base_url=endpoint,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-SDK-Version": "python/0.1.0",
|
||||||
|
},
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
self._closed = False
|
||||||
|
|
||||||
|
# ==================== Chain Operations ====================
|
||||||
|
|
||||||
|
async def get_supported_chains(self) -> List[Chain]:
|
||||||
|
"""Get all supported chains."""
|
||||||
|
response = await self._request("GET", "/chains")
|
||||||
|
return [Chain.from_dict(c) for c in response.get("chains", [])]
|
||||||
|
|
||||||
|
async def get_chain(self, chain_id: ChainId) -> Chain:
|
||||||
|
"""Get chain by ID."""
|
||||||
|
response = await self._request("GET", f"/chains/{chain_id}")
|
||||||
|
return Chain.from_dict(response)
|
||||||
|
|
||||||
|
async def is_chain_supported(self, chain_id: ChainId) -> bool:
|
||||||
|
"""Check if chain is supported."""
|
||||||
|
try:
|
||||||
|
chain = await self.get_chain(chain_id)
|
||||||
|
return chain.supported
|
||||||
|
except BridgeError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ==================== Asset Operations ====================
|
||||||
|
|
||||||
|
async def get_supported_assets(self, chain_id: ChainId) -> List[Asset]:
|
||||||
|
"""Get supported assets for a chain."""
|
||||||
|
response = await self._request("GET", f"/chains/{chain_id}/assets")
|
||||||
|
return [Asset.from_dict(a) for a in response.get("assets", [])]
|
||||||
|
|
||||||
|
async def get_asset(self, asset_id: str) -> Asset:
|
||||||
|
"""Get asset by ID."""
|
||||||
|
response = await self._request("GET", f"/assets/{asset_id}")
|
||||||
|
return Asset.from_dict(response)
|
||||||
|
|
||||||
|
async def get_wrapped_asset(
|
||||||
|
self, original_asset_id: str, target_chain: ChainId
|
||||||
|
) -> WrappedAsset:
|
||||||
|
"""Get wrapped asset mapping."""
|
||||||
|
response = await self._request(
|
||||||
|
"GET", f"/assets/{original_asset_id}/wrapped/{target_chain}"
|
||||||
|
)
|
||||||
|
return WrappedAsset.from_dict(response)
|
||||||
|
|
||||||
|
async def get_wrapped_assets(self, chain_id: ChainId) -> List[WrappedAsset]:
|
||||||
|
"""Get all wrapped assets for a chain."""
|
||||||
|
response = await self._request("GET", f"/chains/{chain_id}/wrapped")
|
||||||
|
return [WrappedAsset.from_dict(a) for a in response.get("assets", [])]
|
||||||
|
|
||||||
|
# ==================== Fee & Rate Operations ====================
|
||||||
|
|
||||||
|
async def estimate_fee(
|
||||||
|
self,
|
||||||
|
asset: str,
|
||||||
|
amount: str,
|
||||||
|
source_chain: ChainId,
|
||||||
|
target_chain: ChainId,
|
||||||
|
) -> FeeEstimate:
|
||||||
|
"""Estimate bridge fee."""
|
||||||
|
response = await self._request(
|
||||||
|
"POST",
|
||||||
|
"/fees/estimate",
|
||||||
|
{
|
||||||
|
"asset": asset,
|
||||||
|
"amount": amount,
|
||||||
|
"sourceChain": source_chain,
|
||||||
|
"targetChain": target_chain,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return FeeEstimate.from_dict(response)
|
||||||
|
|
||||||
|
async def get_exchange_rate(
|
||||||
|
self, from_asset: str, to_asset: str
|
||||||
|
) -> ExchangeRate:
|
||||||
|
"""Get exchange rate between assets."""
|
||||||
|
response = await self._request("GET", f"/rates/{from_asset}/{to_asset}")
|
||||||
|
return ExchangeRate.from_dict(response)
|
||||||
|
|
||||||
|
# ==================== Lock-Mint Flow ====================
|
||||||
|
|
||||||
|
async def lock(
|
||||||
|
self,
|
||||||
|
asset: str,
|
||||||
|
amount: str,
|
||||||
|
target_chain: ChainId,
|
||||||
|
options: Optional[LockOptions] = None,
|
||||||
|
) -> LockReceipt:
|
||||||
|
"""
|
||||||
|
Lock assets on source chain for cross-chain transfer.
|
||||||
|
Step 1 of lock-mint flow.
|
||||||
|
"""
|
||||||
|
body: Dict[str, Any] = {
|
||||||
|
"asset": asset,
|
||||||
|
"amount": amount,
|
||||||
|
"targetChain": target_chain,
|
||||||
|
}
|
||||||
|
if options:
|
||||||
|
if options.recipient:
|
||||||
|
body["recipient"] = options.recipient
|
||||||
|
if options.deadline:
|
||||||
|
body["deadline"] = options.deadline
|
||||||
|
if options.slippage is not None:
|
||||||
|
body["slippage"] = options.slippage
|
||||||
|
|
||||||
|
response = await self._request("POST", "/transfers/lock", body)
|
||||||
|
return LockReceipt.from_dict(response)
|
||||||
|
|
||||||
|
async def get_lock_proof(self, lock_receipt_id: str) -> LockProof:
|
||||||
|
"""
|
||||||
|
Get lock proof for minting.
|
||||||
|
Step 2 of lock-mint flow (wait for confirmations).
|
||||||
|
"""
|
||||||
|
response = await self._request(
|
||||||
|
"GET", f"/transfers/lock/{lock_receipt_id}/proof"
|
||||||
|
)
|
||||||
|
return LockProof.from_dict(response)
|
||||||
|
|
||||||
|
async def wait_for_lock_proof(
|
||||||
|
self,
|
||||||
|
lock_receipt_id: str,
|
||||||
|
poll_interval: float = 5.0,
|
||||||
|
max_wait: float = 600.0,
|
||||||
|
) -> LockProof:
|
||||||
|
"""
|
||||||
|
Wait for lock proof to be ready.
|
||||||
|
Polls until confirmations are sufficient.
|
||||||
|
"""
|
||||||
|
elapsed = 0.0
|
||||||
|
while elapsed < max_wait:
|
||||||
|
try:
|
||||||
|
return await self.get_lock_proof(lock_receipt_id)
|
||||||
|
except BridgeError as e:
|
||||||
|
if e.code == "CONFIRMATIONS_PENDING":
|
||||||
|
await asyncio.sleep(poll_interval)
|
||||||
|
elapsed += poll_interval
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
raise BridgeError(
|
||||||
|
"Timeout waiting for lock proof",
|
||||||
|
code="CONFIRMATIONS_PENDING",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def mint(
|
||||||
|
self,
|
||||||
|
proof: LockProof,
|
||||||
|
target_address: str,
|
||||||
|
options: Optional[MintOptions] = None,
|
||||||
|
) -> SignedTransaction:
|
||||||
|
"""
|
||||||
|
Mint wrapped tokens on target chain.
|
||||||
|
Step 3 of lock-mint flow.
|
||||||
|
"""
|
||||||
|
body: Dict[str, Any] = {
|
||||||
|
"proof": proof.to_dict(),
|
||||||
|
"targetAddress": target_address,
|
||||||
|
}
|
||||||
|
if options:
|
||||||
|
if options.gas_limit:
|
||||||
|
body["gasLimit"] = options.gas_limit
|
||||||
|
if options.max_fee_per_gas:
|
||||||
|
body["maxFeePerGas"] = options.max_fee_per_gas
|
||||||
|
if options.max_priority_fee_per_gas:
|
||||||
|
body["maxPriorityFeePerGas"] = options.max_priority_fee_per_gas
|
||||||
|
|
||||||
|
response = await self._request("POST", "/transfers/mint", body)
|
||||||
|
return SignedTransaction.from_dict(response)
|
||||||
|
|
||||||
|
# ==================== Burn-Unlock Flow ====================
|
||||||
|
|
||||||
|
async def burn(
|
||||||
|
self,
|
||||||
|
wrapped_asset: str,
|
||||||
|
amount: str,
|
||||||
|
options: Optional[BurnOptions] = None,
|
||||||
|
) -> BurnReceipt:
|
||||||
|
"""
|
||||||
|
Burn wrapped tokens on current chain.
|
||||||
|
Step 1 of burn-unlock flow.
|
||||||
|
"""
|
||||||
|
body: Dict[str, Any] = {
|
||||||
|
"wrappedAsset": wrapped_asset,
|
||||||
|
"amount": amount,
|
||||||
|
}
|
||||||
|
if options:
|
||||||
|
if options.recipient:
|
||||||
|
body["recipient"] = options.recipient
|
||||||
|
if options.deadline:
|
||||||
|
body["deadline"] = options.deadline
|
||||||
|
|
||||||
|
response = await self._request("POST", "/transfers/burn", body)
|
||||||
|
return BurnReceipt.from_dict(response)
|
||||||
|
|
||||||
|
async def get_burn_proof(self, burn_receipt_id: str) -> BurnProof:
|
||||||
|
"""
|
||||||
|
Get burn proof for unlocking.
|
||||||
|
Step 2 of burn-unlock flow (wait for confirmations).
|
||||||
|
"""
|
||||||
|
response = await self._request(
|
||||||
|
"GET", f"/transfers/burn/{burn_receipt_id}/proof"
|
||||||
|
)
|
||||||
|
return BurnProof.from_dict(response)
|
||||||
|
|
||||||
|
async def wait_for_burn_proof(
|
||||||
|
self,
|
||||||
|
burn_receipt_id: str,
|
||||||
|
poll_interval: float = 5.0,
|
||||||
|
max_wait: float = 600.0,
|
||||||
|
) -> BurnProof:
|
||||||
|
"""
|
||||||
|
Wait for burn proof to be ready.
|
||||||
|
Polls until confirmations are sufficient.
|
||||||
|
"""
|
||||||
|
elapsed = 0.0
|
||||||
|
while elapsed < max_wait:
|
||||||
|
try:
|
||||||
|
return await self.get_burn_proof(burn_receipt_id)
|
||||||
|
except BridgeError as e:
|
||||||
|
if e.code == "CONFIRMATIONS_PENDING":
|
||||||
|
await asyncio.sleep(poll_interval)
|
||||||
|
elapsed += poll_interval
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
|
||||||
|
raise BridgeError(
|
||||||
|
"Timeout waiting for burn proof",
|
||||||
|
code="CONFIRMATIONS_PENDING",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def unlock(
|
||||||
|
self,
|
||||||
|
proof: BurnProof,
|
||||||
|
options: Optional[UnlockOptions] = None,
|
||||||
|
) -> SignedTransaction:
|
||||||
|
"""
|
||||||
|
Unlock original tokens on source chain.
|
||||||
|
Step 3 of burn-unlock flow.
|
||||||
|
"""
|
||||||
|
body: Dict[str, Any] = {"proof": proof.to_dict()}
|
||||||
|
if options:
|
||||||
|
if options.gas_limit:
|
||||||
|
body["gasLimit"] = options.gas_limit
|
||||||
|
if options.gas_price:
|
||||||
|
body["gasPrice"] = options.gas_price
|
||||||
|
|
||||||
|
response = await self._request("POST", "/transfers/unlock", body)
|
||||||
|
return SignedTransaction.from_dict(response)
|
||||||
|
|
||||||
|
# ==================== Transfer Management ====================
|
||||||
|
|
||||||
|
async def get_transfer(self, transfer_id: str) -> Transfer:
|
||||||
|
"""Get transfer by ID."""
|
||||||
|
response = await self._request("GET", f"/transfers/{transfer_id}")
|
||||||
|
return Transfer.from_dict(response)
|
||||||
|
|
||||||
|
async def get_transfer_status(self, transfer_id: str) -> TransferStatus:
|
||||||
|
"""Get transfer status."""
|
||||||
|
transfer = await self.get_transfer(transfer_id)
|
||||||
|
return transfer.status
|
||||||
|
|
||||||
|
async def list_transfers(
|
||||||
|
self, filter: Optional[TransferFilter] = None
|
||||||
|
) -> List[Transfer]:
|
||||||
|
"""List transfers with optional filters."""
|
||||||
|
params: Dict[str, str] = {}
|
||||||
|
if filter:
|
||||||
|
if filter.status:
|
||||||
|
params["status"] = filter.status.value
|
||||||
|
if filter.source_chain:
|
||||||
|
params["sourceChain"] = filter.source_chain
|
||||||
|
if filter.target_chain:
|
||||||
|
params["targetChain"] = filter.target_chain
|
||||||
|
if filter.asset:
|
||||||
|
params["asset"] = filter.asset
|
||||||
|
if filter.sender:
|
||||||
|
params["sender"] = filter.sender
|
||||||
|
if filter.recipient:
|
||||||
|
params["recipient"] = filter.recipient
|
||||||
|
if filter.from_date is not None:
|
||||||
|
params["fromDate"] = str(filter.from_date)
|
||||||
|
if filter.to_date is not None:
|
||||||
|
params["toDate"] = str(filter.to_date)
|
||||||
|
if filter.limit is not None:
|
||||||
|
params["limit"] = str(filter.limit)
|
||||||
|
if filter.offset is not None:
|
||||||
|
params["offset"] = str(filter.offset)
|
||||||
|
|
||||||
|
path = "/transfers"
|
||||||
|
if params:
|
||||||
|
path = f"/transfers?{urlencode(params)}"
|
||||||
|
|
||||||
|
response = await self._request("GET", path)
|
||||||
|
return [Transfer.from_dict(t) for t in response.get("transfers", [])]
|
||||||
|
|
||||||
|
async def wait_for_transfer(
|
||||||
|
self,
|
||||||
|
transfer_id: str,
|
||||||
|
poll_interval: float = 10.0,
|
||||||
|
max_wait: float = 1800.0,
|
||||||
|
) -> Transfer:
|
||||||
|
"""Wait for transfer to complete."""
|
||||||
|
final_statuses = {
|
||||||
|
TransferStatus.COMPLETED,
|
||||||
|
TransferStatus.FAILED,
|
||||||
|
TransferStatus.REFUNDED,
|
||||||
|
}
|
||||||
|
elapsed = 0.0
|
||||||
|
|
||||||
|
while elapsed < max_wait:
|
||||||
|
transfer = await self.get_transfer(transfer_id)
|
||||||
|
if transfer.status in final_statuses:
|
||||||
|
return transfer
|
||||||
|
await asyncio.sleep(poll_interval)
|
||||||
|
elapsed += poll_interval
|
||||||
|
|
||||||
|
raise BridgeError("Timeout waiting for transfer completion")
|
||||||
|
|
||||||
|
# ==================== Convenience Methods ====================
|
||||||
|
|
||||||
|
async def bridge_to(
|
||||||
|
self,
|
||||||
|
asset: str,
|
||||||
|
amount: str,
|
||||||
|
target_chain: ChainId,
|
||||||
|
target_address: str,
|
||||||
|
lock_options: Optional[LockOptions] = None,
|
||||||
|
mint_options: Optional[MintOptions] = None,
|
||||||
|
) -> Transfer:
|
||||||
|
"""
|
||||||
|
Execute complete lock-mint transfer.
|
||||||
|
Combines lock, wait for proof, and mint into single operation.
|
||||||
|
"""
|
||||||
|
# Lock on source chain
|
||||||
|
lock_receipt = await self.lock(asset, amount, target_chain, lock_options)
|
||||||
|
|
||||||
|
if self.config.debug:
|
||||||
|
print(f"Locked: {lock_receipt.id}, waiting for confirmations...")
|
||||||
|
|
||||||
|
# Wait for proof
|
||||||
|
proof = await self.wait_for_lock_proof(lock_receipt.id)
|
||||||
|
|
||||||
|
if self.config.debug:
|
||||||
|
print(f"Proof ready, minting on {target_chain}...")
|
||||||
|
|
||||||
|
# Mint on target chain
|
||||||
|
await self.mint(proof, target_address, mint_options)
|
||||||
|
|
||||||
|
# Return final transfer status
|
||||||
|
return await self.wait_for_transfer(lock_receipt.id)
|
||||||
|
|
||||||
|
async def bridge_back(
|
||||||
|
self,
|
||||||
|
wrapped_asset: str,
|
||||||
|
amount: str,
|
||||||
|
burn_options: Optional[BurnOptions] = None,
|
||||||
|
unlock_options: Optional[UnlockOptions] = None,
|
||||||
|
) -> Transfer:
|
||||||
|
"""
|
||||||
|
Execute complete burn-unlock transfer.
|
||||||
|
Combines burn, wait for proof, and unlock into single operation.
|
||||||
|
"""
|
||||||
|
# Burn wrapped tokens
|
||||||
|
burn_receipt = await self.burn(wrapped_asset, amount, burn_options)
|
||||||
|
|
||||||
|
if self.config.debug:
|
||||||
|
print(f"Burned: {burn_receipt.id}, waiting for confirmations...")
|
||||||
|
|
||||||
|
# Wait for proof
|
||||||
|
proof = await self.wait_for_burn_proof(burn_receipt.id)
|
||||||
|
|
||||||
|
if self.config.debug:
|
||||||
|
print(f"Proof ready, unlocking on {burn_receipt.target_chain}...")
|
||||||
|
|
||||||
|
# Unlock on original chain
|
||||||
|
await self.unlock(proof, unlock_options)
|
||||||
|
|
||||||
|
# Return final transfer status
|
||||||
|
return await self.wait_for_transfer(burn_receipt.id)
|
||||||
|
|
||||||
|
# ==================== Lifecycle ====================
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close the client."""
|
||||||
|
self._closed = True
|
||||||
|
await self._client.aclose()
|
||||||
|
|
||||||
|
def is_closed(self) -> bool:
|
||||||
|
"""Check if client is closed."""
|
||||||
|
return self._closed
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""Health check."""
|
||||||
|
try:
|
||||||
|
response = await self._request("GET", "/health")
|
||||||
|
return response.get("status") == "healthy"
|
||||||
|
except BridgeError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "SynorBridge":
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||||
|
await self.close()
|
||||||
|
|
||||||
|
# ==================== Private Methods ====================
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
body: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Make HTTP request with retries."""
|
||||||
|
if self._closed:
|
||||||
|
raise BridgeError("Client has been closed")
|
||||||
|
|
||||||
|
last_error: Optional[Exception] = None
|
||||||
|
|
||||||
|
for attempt in range(self.config.retries):
|
||||||
|
try:
|
||||||
|
return await self._do_request(method, path, body)
|
||||||
|
except BridgeError as e:
|
||||||
|
if self.config.debug:
|
||||||
|
print(f"Attempt {attempt + 1} failed: {e}")
|
||||||
|
last_error = e
|
||||||
|
if attempt < self.config.retries - 1:
|
||||||
|
await asyncio.sleep(2**attempt)
|
||||||
|
|
||||||
|
if last_error:
|
||||||
|
raise last_error
|
||||||
|
raise BridgeError("Unknown error after retries")
|
||||||
|
|
||||||
|
async def _do_request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
body: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Execute HTTP request."""
|
||||||
|
try:
|
||||||
|
response = await self._client.request(
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
json=body if body else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
error_body = response.json() if response.content else {}
|
||||||
|
message = (
|
||||||
|
error_body.get("message")
|
||||||
|
or error_body.get("error")
|
||||||
|
or f"HTTP {response.status_code}"
|
||||||
|
)
|
||||||
|
raise BridgeError(
|
||||||
|
message,
|
||||||
|
code=error_body.get("code"),
|
||||||
|
status_code=response.status_code,
|
||||||
|
details=error_body,
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
except httpx.TimeoutException as e:
|
||||||
|
raise BridgeError(f"Request timeout: {e}")
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
raise BridgeError(f"Request failed: {e}")
|
||||||
537
sdk/python/src/synor_bridge/types.py
Normal file
537
sdk/python/src/synor_bridge/types.py
Normal file
|
|
@ -0,0 +1,537 @@
|
||||||
|
"""
|
||||||
|
Synor Bridge SDK Types
|
||||||
|
|
||||||
|
Cross-chain asset transfers with lock-mint and burn-unlock patterns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional, List, Dict, Any, Literal
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Chain Types ====================
|
||||||
|
|
||||||
|
ChainId = Literal[
|
||||||
|
"synor",
|
||||||
|
"ethereum",
|
||||||
|
"polygon",
|
||||||
|
"arbitrum",
|
||||||
|
"optimism",
|
||||||
|
"bsc",
|
||||||
|
"avalanche",
|
||||||
|
"solana",
|
||||||
|
"cosmos",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NativeCurrency:
|
||||||
|
"""Native currency info for a chain."""
|
||||||
|
name: str
|
||||||
|
symbol: str
|
||||||
|
decimals: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Chain:
|
||||||
|
"""Blockchain network information."""
|
||||||
|
id: ChainId
|
||||||
|
name: str
|
||||||
|
chain_id: int
|
||||||
|
rpc_url: str
|
||||||
|
explorer_url: str
|
||||||
|
native_currency: NativeCurrency
|
||||||
|
confirmations: int
|
||||||
|
estimated_block_time: int # seconds
|
||||||
|
supported: bool
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "Chain":
|
||||||
|
native = data.get("nativeCurrency", data.get("native_currency", {}))
|
||||||
|
return cls(
|
||||||
|
id=data["id"],
|
||||||
|
name=data["name"],
|
||||||
|
chain_id=data.get("chainId", data.get("chain_id", 0)),
|
||||||
|
rpc_url=data.get("rpcUrl", data.get("rpc_url", "")),
|
||||||
|
explorer_url=data.get("explorerUrl", data.get("explorer_url", "")),
|
||||||
|
native_currency=NativeCurrency(
|
||||||
|
name=native.get("name", ""),
|
||||||
|
symbol=native.get("symbol", ""),
|
||||||
|
decimals=native.get("decimals", 18),
|
||||||
|
),
|
||||||
|
confirmations=data.get("confirmations", 0),
|
||||||
|
estimated_block_time=data.get("estimatedBlockTime", data.get("estimated_block_time", 0)),
|
||||||
|
supported=data.get("supported", True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Asset Types ====================
|
||||||
|
|
||||||
|
class AssetType(str, Enum):
|
||||||
|
"""Asset type enumeration."""
|
||||||
|
NATIVE = "native"
|
||||||
|
ERC20 = "erc20"
|
||||||
|
ERC721 = "erc721"
|
||||||
|
ERC1155 = "erc1155"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Asset:
|
||||||
|
"""Asset information."""
|
||||||
|
id: str
|
||||||
|
symbol: str
|
||||||
|
name: str
|
||||||
|
type: AssetType
|
||||||
|
chain: ChainId
|
||||||
|
decimals: int
|
||||||
|
contract_address: Optional[str] = None
|
||||||
|
logo_url: Optional[str] = None
|
||||||
|
verified: bool = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "Asset":
|
||||||
|
return cls(
|
||||||
|
id=data["id"],
|
||||||
|
symbol=data["symbol"],
|
||||||
|
name=data["name"],
|
||||||
|
type=AssetType(data["type"]),
|
||||||
|
chain=data["chain"],
|
||||||
|
decimals=data.get("decimals", 18),
|
||||||
|
contract_address=data.get("contractAddress", data.get("contract_address")),
|
||||||
|
logo_url=data.get("logoUrl", data.get("logo_url")),
|
||||||
|
verified=data.get("verified", False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WrappedAsset:
|
||||||
|
"""Wrapped asset mapping."""
|
||||||
|
original_asset: Asset
|
||||||
|
wrapped_asset: Asset
|
||||||
|
chain: ChainId
|
||||||
|
bridge_contract: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "WrappedAsset":
|
||||||
|
return cls(
|
||||||
|
original_asset=Asset.from_dict(data.get("originalAsset", data.get("original_asset", {}))),
|
||||||
|
wrapped_asset=Asset.from_dict(data.get("wrappedAsset", data.get("wrapped_asset", {}))),
|
||||||
|
chain=data["chain"],
|
||||||
|
bridge_contract=data.get("bridgeContract", data.get("bridge_contract", "")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Transfer Types ====================
|
||||||
|
|
||||||
|
class TransferStatus(str, Enum):
|
||||||
|
"""Transfer status enumeration."""
|
||||||
|
PENDING = "pending"
|
||||||
|
LOCKED = "locked"
|
||||||
|
CONFIRMING = "confirming"
|
||||||
|
MINTING = "minting"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
REFUNDED = "refunded"
|
||||||
|
|
||||||
|
|
||||||
|
class TransferDirection(str, Enum):
|
||||||
|
"""Transfer direction enumeration."""
|
||||||
|
LOCK_MINT = "lock_mint"
|
||||||
|
BURN_UNLOCK = "burn_unlock"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ValidatorSignature:
|
||||||
|
"""Validator signature for proof."""
|
||||||
|
validator: str
|
||||||
|
signature: str
|
||||||
|
timestamp: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "ValidatorSignature":
|
||||||
|
return cls(
|
||||||
|
validator=data["validator"],
|
||||||
|
signature=data["signature"],
|
||||||
|
timestamp=data["timestamp"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LockReceipt:
|
||||||
|
"""Lock receipt from source chain."""
|
||||||
|
id: str
|
||||||
|
tx_hash: str
|
||||||
|
source_chain: ChainId
|
||||||
|
target_chain: ChainId
|
||||||
|
asset: Asset
|
||||||
|
amount: str
|
||||||
|
sender: str
|
||||||
|
recipient: str
|
||||||
|
lock_timestamp: int
|
||||||
|
confirmations: int
|
||||||
|
required_confirmations: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "LockReceipt":
|
||||||
|
return cls(
|
||||||
|
id=data["id"],
|
||||||
|
tx_hash=data.get("txHash", data.get("tx_hash", "")),
|
||||||
|
source_chain=data.get("sourceChain", data.get("source_chain", "")),
|
||||||
|
target_chain=data.get("targetChain", data.get("target_chain", "")),
|
||||||
|
asset=Asset.from_dict(data["asset"]),
|
||||||
|
amount=data["amount"],
|
||||||
|
sender=data["sender"],
|
||||||
|
recipient=data["recipient"],
|
||||||
|
lock_timestamp=data.get("lockTimestamp", data.get("lock_timestamp", 0)),
|
||||||
|
confirmations=data.get("confirmations", 0),
|
||||||
|
required_confirmations=data.get("requiredConfirmations", data.get("required_confirmations", 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LockProof:
|
||||||
|
"""Proof for minting on target chain."""
|
||||||
|
lock_receipt: LockReceipt
|
||||||
|
merkle_proof: List[str]
|
||||||
|
block_header: str
|
||||||
|
signatures: List[ValidatorSignature]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "LockProof":
|
||||||
|
return cls(
|
||||||
|
lock_receipt=LockReceipt.from_dict(data.get("lockReceipt", data.get("lock_receipt", {}))),
|
||||||
|
merkle_proof=data.get("merkleProof", data.get("merkle_proof", [])),
|
||||||
|
block_header=data.get("blockHeader", data.get("block_header", "")),
|
||||||
|
signatures=[ValidatorSignature.from_dict(s) for s in data.get("signatures", [])],
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"lockReceipt": {
|
||||||
|
"id": self.lock_receipt.id,
|
||||||
|
"txHash": self.lock_receipt.tx_hash,
|
||||||
|
"sourceChain": self.lock_receipt.source_chain,
|
||||||
|
"targetChain": self.lock_receipt.target_chain,
|
||||||
|
"asset": {
|
||||||
|
"id": self.lock_receipt.asset.id,
|
||||||
|
"symbol": self.lock_receipt.asset.symbol,
|
||||||
|
"name": self.lock_receipt.asset.name,
|
||||||
|
"type": self.lock_receipt.asset.type.value,
|
||||||
|
"chain": self.lock_receipt.asset.chain,
|
||||||
|
"decimals": self.lock_receipt.asset.decimals,
|
||||||
|
},
|
||||||
|
"amount": self.lock_receipt.amount,
|
||||||
|
"sender": self.lock_receipt.sender,
|
||||||
|
"recipient": self.lock_receipt.recipient,
|
||||||
|
"lockTimestamp": self.lock_receipt.lock_timestamp,
|
||||||
|
"confirmations": self.lock_receipt.confirmations,
|
||||||
|
"requiredConfirmations": self.lock_receipt.required_confirmations,
|
||||||
|
},
|
||||||
|
"merkleProof": self.merkle_proof,
|
||||||
|
"blockHeader": self.block_header,
|
||||||
|
"signatures": [
|
||||||
|
{"validator": s.validator, "signature": s.signature, "timestamp": s.timestamp}
|
||||||
|
for s in self.signatures
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BurnReceipt:
|
||||||
|
"""Burn receipt for unlocking."""
|
||||||
|
id: str
|
||||||
|
tx_hash: str
|
||||||
|
source_chain: ChainId
|
||||||
|
target_chain: ChainId
|
||||||
|
wrapped_asset: Asset
|
||||||
|
original_asset: Asset
|
||||||
|
amount: str
|
||||||
|
sender: str
|
||||||
|
recipient: str
|
||||||
|
burn_timestamp: int
|
||||||
|
confirmations: int
|
||||||
|
required_confirmations: int
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "BurnReceipt":
|
||||||
|
return cls(
|
||||||
|
id=data["id"],
|
||||||
|
tx_hash=data.get("txHash", data.get("tx_hash", "")),
|
||||||
|
source_chain=data.get("sourceChain", data.get("source_chain", "")),
|
||||||
|
target_chain=data.get("targetChain", data.get("target_chain", "")),
|
||||||
|
wrapped_asset=Asset.from_dict(data.get("wrappedAsset", data.get("wrapped_asset", {}))),
|
||||||
|
original_asset=Asset.from_dict(data.get("originalAsset", data.get("original_asset", {}))),
|
||||||
|
amount=data["amount"],
|
||||||
|
sender=data["sender"],
|
||||||
|
recipient=data["recipient"],
|
||||||
|
burn_timestamp=data.get("burnTimestamp", data.get("burn_timestamp", 0)),
|
||||||
|
confirmations=data.get("confirmations", 0),
|
||||||
|
required_confirmations=data.get("requiredConfirmations", data.get("required_confirmations", 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BurnProof:
|
||||||
|
"""Proof for unlocking on original chain."""
|
||||||
|
burn_receipt: BurnReceipt
|
||||||
|
merkle_proof: List[str]
|
||||||
|
block_header: str
|
||||||
|
signatures: List[ValidatorSignature]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "BurnProof":
|
||||||
|
return cls(
|
||||||
|
burn_receipt=BurnReceipt.from_dict(data.get("burnReceipt", data.get("burn_receipt", {}))),
|
||||||
|
merkle_proof=data.get("merkleProof", data.get("merkle_proof", [])),
|
||||||
|
block_header=data.get("blockHeader", data.get("block_header", "")),
|
||||||
|
signatures=[ValidatorSignature.from_dict(s) for s in data.get("signatures", [])],
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"burnReceipt": {
|
||||||
|
"id": self.burn_receipt.id,
|
||||||
|
"txHash": self.burn_receipt.tx_hash,
|
||||||
|
"sourceChain": self.burn_receipt.source_chain,
|
||||||
|
"targetChain": self.burn_receipt.target_chain,
|
||||||
|
"wrappedAsset": {
|
||||||
|
"id": self.burn_receipt.wrapped_asset.id,
|
||||||
|
"symbol": self.burn_receipt.wrapped_asset.symbol,
|
||||||
|
"name": self.burn_receipt.wrapped_asset.name,
|
||||||
|
"type": self.burn_receipt.wrapped_asset.type.value,
|
||||||
|
"chain": self.burn_receipt.wrapped_asset.chain,
|
||||||
|
"decimals": self.burn_receipt.wrapped_asset.decimals,
|
||||||
|
},
|
||||||
|
"originalAsset": {
|
||||||
|
"id": self.burn_receipt.original_asset.id,
|
||||||
|
"symbol": self.burn_receipt.original_asset.symbol,
|
||||||
|
"name": self.burn_receipt.original_asset.name,
|
||||||
|
"type": self.burn_receipt.original_asset.type.value,
|
||||||
|
"chain": self.burn_receipt.original_asset.chain,
|
||||||
|
"decimals": self.burn_receipt.original_asset.decimals,
|
||||||
|
},
|
||||||
|
"amount": self.burn_receipt.amount,
|
||||||
|
"sender": self.burn_receipt.sender,
|
||||||
|
"recipient": self.burn_receipt.recipient,
|
||||||
|
"burnTimestamp": self.burn_receipt.burn_timestamp,
|
||||||
|
"confirmations": self.burn_receipt.confirmations,
|
||||||
|
"requiredConfirmations": self.burn_receipt.required_confirmations,
|
||||||
|
},
|
||||||
|
"merkleProof": self.merkle_proof,
|
||||||
|
"blockHeader": self.block_header,
|
||||||
|
"signatures": [
|
||||||
|
{"validator": s.validator, "signature": s.signature, "timestamp": s.timestamp}
|
||||||
|
for s in self.signatures
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Transfer:
|
||||||
|
"""Complete transfer record."""
|
||||||
|
id: str
|
||||||
|
direction: TransferDirection
|
||||||
|
status: TransferStatus
|
||||||
|
source_chain: ChainId
|
||||||
|
target_chain: ChainId
|
||||||
|
asset: Asset
|
||||||
|
amount: str
|
||||||
|
sender: str
|
||||||
|
recipient: str
|
||||||
|
fee: str
|
||||||
|
fee_asset: Asset
|
||||||
|
created_at: int
|
||||||
|
updated_at: int
|
||||||
|
source_tx_hash: Optional[str] = None
|
||||||
|
target_tx_hash: Optional[str] = None
|
||||||
|
completed_at: Optional[int] = None
|
||||||
|
error_message: Optional[str] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "Transfer":
|
||||||
|
return cls(
|
||||||
|
id=data["id"],
|
||||||
|
direction=TransferDirection(data["direction"]),
|
||||||
|
status=TransferStatus(data["status"]),
|
||||||
|
source_chain=data.get("sourceChain", data.get("source_chain", "")),
|
||||||
|
target_chain=data.get("targetChain", data.get("target_chain", "")),
|
||||||
|
asset=Asset.from_dict(data["asset"]),
|
||||||
|
amount=data["amount"],
|
||||||
|
sender=data["sender"],
|
||||||
|
recipient=data["recipient"],
|
||||||
|
fee=data["fee"],
|
||||||
|
fee_asset=Asset.from_dict(data.get("feeAsset", data.get("fee_asset", {}))),
|
||||||
|
created_at=data.get("createdAt", data.get("created_at", 0)),
|
||||||
|
updated_at=data.get("updatedAt", data.get("updated_at", 0)),
|
||||||
|
source_tx_hash=data.get("sourceTxHash", data.get("source_tx_hash")),
|
||||||
|
target_tx_hash=data.get("targetTxHash", data.get("target_tx_hash")),
|
||||||
|
completed_at=data.get("completedAt", data.get("completed_at")),
|
||||||
|
error_message=data.get("errorMessage", data.get("error_message")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Fee Types ====================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FeeEstimate:
|
||||||
|
"""Fee estimate for bridge transfer."""
|
||||||
|
bridge_fee: str
|
||||||
|
gas_fee_source: str
|
||||||
|
gas_fee_target: str
|
||||||
|
total_fee: str
|
||||||
|
fee_asset: Asset
|
||||||
|
estimated_time: int # seconds
|
||||||
|
exchange_rate: Optional[str] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "FeeEstimate":
|
||||||
|
return cls(
|
||||||
|
bridge_fee=data.get("bridgeFee", data.get("bridge_fee", "0")),
|
||||||
|
gas_fee_source=data.get("gasFeeSource", data.get("gas_fee_source", "0")),
|
||||||
|
gas_fee_target=data.get("gasFeeTarget", data.get("gas_fee_target", "0")),
|
||||||
|
total_fee=data.get("totalFee", data.get("total_fee", "0")),
|
||||||
|
fee_asset=Asset.from_dict(data.get("feeAsset", data.get("fee_asset", {}))),
|
||||||
|
estimated_time=data.get("estimatedTime", data.get("estimated_time", 0)),
|
||||||
|
exchange_rate=data.get("exchangeRate", data.get("exchange_rate")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExchangeRate:
|
||||||
|
"""Exchange rate between assets."""
|
||||||
|
from_asset: Asset
|
||||||
|
to_asset: Asset
|
||||||
|
rate: str
|
||||||
|
inverse_rate: str
|
||||||
|
last_updated: int
|
||||||
|
source: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "ExchangeRate":
|
||||||
|
return cls(
|
||||||
|
from_asset=Asset.from_dict(data.get("fromAsset", data.get("from_asset", {}))),
|
||||||
|
to_asset=Asset.from_dict(data.get("toAsset", data.get("to_asset", {}))),
|
||||||
|
rate=data["rate"],
|
||||||
|
inverse_rate=data.get("inverseRate", data.get("inverse_rate", "")),
|
||||||
|
last_updated=data.get("lastUpdated", data.get("last_updated", 0)),
|
||||||
|
source=data.get("source", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Transaction Types ====================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SignedTransaction:
|
||||||
|
"""Signed transaction result."""
|
||||||
|
tx_hash: str
|
||||||
|
chain: ChainId
|
||||||
|
from_address: str
|
||||||
|
to_address: str
|
||||||
|
value: str
|
||||||
|
data: str
|
||||||
|
gas_limit: str
|
||||||
|
nonce: int
|
||||||
|
signature: str
|
||||||
|
gas_price: Optional[str] = None
|
||||||
|
max_fee_per_gas: Optional[str] = None
|
||||||
|
max_priority_fee_per_gas: Optional[str] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "SignedTransaction":
|
||||||
|
return cls(
|
||||||
|
tx_hash=data.get("txHash", data.get("tx_hash", "")),
|
||||||
|
chain=data["chain"],
|
||||||
|
from_address=data.get("from", data.get("from_address", "")),
|
||||||
|
to_address=data.get("to", data.get("to_address", "")),
|
||||||
|
value=data["value"],
|
||||||
|
data=data["data"],
|
||||||
|
gas_limit=data.get("gasLimit", data.get("gas_limit", "")),
|
||||||
|
nonce=data["nonce"],
|
||||||
|
signature=data["signature"],
|
||||||
|
gas_price=data.get("gasPrice", data.get("gas_price")),
|
||||||
|
max_fee_per_gas=data.get("maxFeePerGas", data.get("max_fee_per_gas")),
|
||||||
|
max_priority_fee_per_gas=data.get("maxPriorityFeePerGas", data.get("max_priority_fee_per_gas")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Filter Types ====================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TransferFilter:
|
||||||
|
"""Filter for listing transfers."""
|
||||||
|
status: Optional[TransferStatus] = None
|
||||||
|
source_chain: Optional[ChainId] = None
|
||||||
|
target_chain: Optional[ChainId] = None
|
||||||
|
asset: Optional[str] = None
|
||||||
|
sender: Optional[str] = None
|
||||||
|
recipient: Optional[str] = None
|
||||||
|
from_date: Optional[int] = None
|
||||||
|
to_date: Optional[int] = None
|
||||||
|
limit: Optional[int] = None
|
||||||
|
offset: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Options Types ====================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LockOptions:
|
||||||
|
"""Options for lock operation."""
|
||||||
|
recipient: Optional[str] = None
|
||||||
|
deadline: Optional[int] = None
|
||||||
|
slippage: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MintOptions:
|
||||||
|
"""Options for mint operation."""
|
||||||
|
gas_limit: Optional[str] = None
|
||||||
|
max_fee_per_gas: Optional[str] = None
|
||||||
|
max_priority_fee_per_gas: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BurnOptions:
|
||||||
|
"""Options for burn operation."""
|
||||||
|
recipient: Optional[str] = None
|
||||||
|
deadline: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UnlockOptions:
|
||||||
|
"""Options for unlock operation."""
|
||||||
|
gas_limit: Optional[str] = None
|
||||||
|
gas_price: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Config Types ====================
|
||||||
|
|
||||||
|
DEFAULT_BRIDGE_ENDPOINT = "https://bridge.synor.io/v1"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BridgeConfig:
|
||||||
|
"""Bridge configuration."""
|
||||||
|
api_key: str
|
||||||
|
endpoint: str = DEFAULT_BRIDGE_ENDPOINT
|
||||||
|
timeout: float = 60.0
|
||||||
|
retries: int = 3
|
||||||
|
debug: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Error Types ====================
|
||||||
|
|
||||||
|
class BridgeError(Exception):
|
||||||
|
"""Bridge-specific error."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
code: Optional[str] = None,
|
||||||
|
status_code: Optional[int] = None,
|
||||||
|
details: Optional[Dict[str, Any]] = None,
|
||||||
|
):
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.code = code
|
||||||
|
self.status_code = status_code
|
||||||
|
self.details = details or {}
|
||||||
71
sdk/python/src/synor_database/__init__.py
Normal file
71
sdk/python/src/synor_database/__init__.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""
|
||||||
|
Synor Database SDK for Python
|
||||||
|
|
||||||
|
Multi-model database supporting Key-Value, Document, Vector, and Time Series data.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> from synor_database import SynorDatabase
|
||||||
|
>>>
|
||||||
|
>>> db = SynorDatabase(api_key="your-api-key")
|
||||||
|
>>>
|
||||||
|
>>> # Key-Value operations
|
||||||
|
>>> await db.kv.set("user:1", {"name": "Alice"})
|
||||||
|
>>> user = await db.kv.get("user:1")
|
||||||
|
>>>
|
||||||
|
>>> # Document operations
|
||||||
|
>>> doc = await db.documents.create("users", {"name": "Bob", "age": 30})
|
||||||
|
>>> results = await db.documents.query("users", filter={"age": {"$gt": 25}})
|
||||||
|
>>>
|
||||||
|
>>> # Vector operations
|
||||||
|
>>> await db.vectors.upsert("embeddings", [{"id": "1", "vector": [0.1, 0.2, ...]}])
|
||||||
|
>>> similar = await db.vectors.search("embeddings", query_vector, top_k=10)
|
||||||
|
>>>
|
||||||
|
>>> # Time series operations
|
||||||
|
>>> await db.timeseries.write("metrics", [{"timestamp": time.time(), "value": 42.5}])
|
||||||
|
>>> data = await db.timeseries.query("metrics", start="-1h", end="now")
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .types import (
|
||||||
|
DatabaseConfig,
|
||||||
|
KeyValueEntry,
|
||||||
|
SetOptions,
|
||||||
|
ListOptions,
|
||||||
|
ListResult,
|
||||||
|
Document,
|
||||||
|
QueryOptions,
|
||||||
|
QueryResult,
|
||||||
|
VectorEntry,
|
||||||
|
SearchOptions,
|
||||||
|
SearchResult,
|
||||||
|
VectorCollectionConfig,
|
||||||
|
DataPoint,
|
||||||
|
TimeRange,
|
||||||
|
TimeSeriesQueryOptions,
|
||||||
|
TimeSeriesResult,
|
||||||
|
DatabaseStats,
|
||||||
|
DatabaseError,
|
||||||
|
)
|
||||||
|
from .client import SynorDatabase
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
__all__ = [
|
||||||
|
"SynorDatabase",
|
||||||
|
"DatabaseConfig",
|
||||||
|
"KeyValueEntry",
|
||||||
|
"SetOptions",
|
||||||
|
"ListOptions",
|
||||||
|
"ListResult",
|
||||||
|
"Document",
|
||||||
|
"QueryOptions",
|
||||||
|
"QueryResult",
|
||||||
|
"VectorEntry",
|
||||||
|
"SearchOptions",
|
||||||
|
"SearchResult",
|
||||||
|
"VectorCollectionConfig",
|
||||||
|
"DataPoint",
|
||||||
|
"TimeRange",
|
||||||
|
"TimeSeriesQueryOptions",
|
||||||
|
"TimeSeriesResult",
|
||||||
|
"DatabaseStats",
|
||||||
|
"DatabaseError",
|
||||||
|
]
|
||||||
633
sdk/python/src/synor_database/client.py
Normal file
633
sdk/python/src/synor_database/client.py
Normal file
|
|
@ -0,0 +1,633 @@
|
||||||
|
"""
|
||||||
|
Synor Database SDK Client
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
from datetime import datetime
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .types import (
|
||||||
|
DatabaseConfig,
|
||||||
|
Network,
|
||||||
|
KeyValueEntry,
|
||||||
|
SetOptions,
|
||||||
|
ListOptions,
|
||||||
|
ListResult,
|
||||||
|
Document,
|
||||||
|
QueryOptions,
|
||||||
|
QueryResult,
|
||||||
|
VectorEntry,
|
||||||
|
SearchOptions,
|
||||||
|
SearchResult,
|
||||||
|
VectorCollectionConfig,
|
||||||
|
VectorCollectionStats,
|
||||||
|
DataPoint,
|
||||||
|
TimeRange,
|
||||||
|
TimeSeriesQueryOptions,
|
||||||
|
TimeSeriesResult,
|
||||||
|
SeriesInfo,
|
||||||
|
DatabaseStats,
|
||||||
|
DatabaseError,
|
||||||
|
Aggregation,
|
||||||
|
SortOrder,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class KeyValueStore:
|
||||||
|
"""Key-Value store operations."""
|
||||||
|
|
||||||
|
def __init__(self, client: "SynorDatabase"):
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
async def get(self, key: str) -> Optional[Any]:
|
||||||
|
"""Get a value by key."""
|
||||||
|
response = await self._client._request("GET", f"/kv/{key}")
|
||||||
|
return response.get("value")
|
||||||
|
|
||||||
|
async def get_entry(self, key: str) -> Optional[KeyValueEntry]:
|
||||||
|
"""Get a full entry with metadata."""
|
||||||
|
response = await self._client._request("GET", f"/kv/{key}/entry")
|
||||||
|
if response is None:
|
||||||
|
return None
|
||||||
|
return KeyValueEntry(
|
||||||
|
key=response["key"],
|
||||||
|
value=response["value"],
|
||||||
|
created_at=response["created_at"],
|
||||||
|
updated_at=response["updated_at"],
|
||||||
|
metadata=response.get("metadata"),
|
||||||
|
expires_at=response.get("expires_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
value: Any,
|
||||||
|
ttl: Optional[int] = None,
|
||||||
|
metadata: Optional[Dict[str, str]] = None,
|
||||||
|
if_not_exists: bool = False,
|
||||||
|
if_exists: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Set a value."""
|
||||||
|
body = {"value": value}
|
||||||
|
if ttl is not None:
|
||||||
|
body["ttl"] = ttl
|
||||||
|
if metadata is not None:
|
||||||
|
body["metadata"] = metadata
|
||||||
|
if if_not_exists:
|
||||||
|
body["if_not_exists"] = True
|
||||||
|
if if_exists:
|
||||||
|
body["if_exists"] = True
|
||||||
|
|
||||||
|
await self._client._request("PUT", f"/kv/{key}", body=body)
|
||||||
|
|
||||||
|
async def delete(self, key: str) -> bool:
|
||||||
|
"""Delete a key."""
|
||||||
|
response = await self._client._request("DELETE", f"/kv/{key}")
|
||||||
|
return response.get("deleted", False)
|
||||||
|
|
||||||
|
async def exists(self, key: str) -> bool:
|
||||||
|
"""Check if a key exists."""
|
||||||
|
response = await self._client._request("GET", f"/kv/{key}/exists")
|
||||||
|
return response.get("exists", False)
|
||||||
|
|
||||||
|
async def list(
|
||||||
|
self,
|
||||||
|
prefix: Optional[str] = None,
|
||||||
|
cursor: Optional[str] = None,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> ListResult:
|
||||||
|
"""List keys with optional prefix filtering."""
|
||||||
|
params = {"limit": str(limit)}
|
||||||
|
if prefix:
|
||||||
|
params["prefix"] = prefix
|
||||||
|
if cursor:
|
||||||
|
params["cursor"] = cursor
|
||||||
|
|
||||||
|
response = await self._client._request("GET", f"/kv?{urlencode(params)}")
|
||||||
|
return ListResult(
|
||||||
|
entries=[
|
||||||
|
KeyValueEntry(
|
||||||
|
key=e["key"],
|
||||||
|
value=e["value"],
|
||||||
|
created_at=e["created_at"],
|
||||||
|
updated_at=e["updated_at"],
|
||||||
|
metadata=e.get("metadata"),
|
||||||
|
expires_at=e.get("expires_at"),
|
||||||
|
)
|
||||||
|
for e in response.get("entries", [])
|
||||||
|
],
|
||||||
|
cursor=response.get("cursor"),
|
||||||
|
has_more=response.get("has_more", False),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def mget(self, keys: List[str]) -> Dict[str, Optional[Any]]:
|
||||||
|
"""Get multiple values at once."""
|
||||||
|
response = await self._client._request("POST", "/kv/mget", body={"keys": keys})
|
||||||
|
return {e["key"]: e["value"] for e in response.get("entries", [])}
|
||||||
|
|
||||||
|
async def mset(self, entries: Dict[str, Any]) -> None:
|
||||||
|
"""Set multiple values at once."""
|
||||||
|
body = {"entries": [{"key": k, "value": v} for k, v in entries.items()]}
|
||||||
|
await self._client._request("POST", "/kv/mset", body=body)
|
||||||
|
|
||||||
|
async def incr(self, key: str, by: int = 1) -> int:
|
||||||
|
"""Increment a numeric value."""
|
||||||
|
response = await self._client._request("POST", f"/kv/{key}/incr", body={"by": by})
|
||||||
|
return response["value"]
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentStore:
|
||||||
|
"""Document store operations."""
|
||||||
|
|
||||||
|
def __init__(self, client: "SynorDatabase"):
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
collection: str,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
id: Optional[str] = None,
|
||||||
|
metadata: Optional[Dict[str, str]] = None,
|
||||||
|
) -> Document:
|
||||||
|
"""Create a new document."""
|
||||||
|
body: Dict[str, Any] = {"data": data}
|
||||||
|
if id:
|
||||||
|
body["id"] = id
|
||||||
|
if metadata:
|
||||||
|
body["metadata"] = metadata
|
||||||
|
|
||||||
|
response = await self._client._request("POST", f"/documents/{collection}", body=body)
|
||||||
|
return self._parse_document(response)
|
||||||
|
|
||||||
|
async def get(self, collection: str, id: str) -> Optional[Document]:
|
||||||
|
"""Get a document by ID."""
|
||||||
|
try:
|
||||||
|
response = await self._client._request("GET", f"/documents/{collection}/{id}")
|
||||||
|
return self._parse_document(response) if response else None
|
||||||
|
except DatabaseError as e:
|
||||||
|
if e.status_code == 404:
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self,
|
||||||
|
collection: str,
|
||||||
|
id: str,
|
||||||
|
update: Dict[str, Any],
|
||||||
|
upsert: bool = False,
|
||||||
|
) -> Document:
|
||||||
|
"""Update a document."""
|
||||||
|
body = {"update": update, "upsert": upsert}
|
||||||
|
response = await self._client._request("PATCH", f"/documents/{collection}/{id}", body=body)
|
||||||
|
return self._parse_document(response)
|
||||||
|
|
||||||
|
async def replace(self, collection: str, id: str, data: Dict[str, Any]) -> Document:
|
||||||
|
"""Replace a document."""
|
||||||
|
response = await self._client._request("PUT", f"/documents/{collection}/{id}", body={"data": data})
|
||||||
|
return self._parse_document(response)
|
||||||
|
|
||||||
|
async def delete(self, collection: str, id: str) -> bool:
|
||||||
|
"""Delete a document."""
|
||||||
|
response = await self._client._request("DELETE", f"/documents/{collection}/{id}")
|
||||||
|
return response.get("deleted", False)
|
||||||
|
|
||||||
|
async def query(
|
||||||
|
self,
|
||||||
|
collection: str,
|
||||||
|
filter: Optional[Dict[str, Any]] = None,
|
||||||
|
sort: Optional[Dict[str, str]] = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
projection: Optional[List[str]] = None,
|
||||||
|
) -> QueryResult:
|
||||||
|
"""Query documents."""
|
||||||
|
body: Dict[str, Any] = {"skip": skip, "limit": limit}
|
||||||
|
if filter:
|
||||||
|
body["filter"] = filter
|
||||||
|
if sort:
|
||||||
|
body["sort"] = sort
|
||||||
|
if projection:
|
||||||
|
body["projection"] = projection
|
||||||
|
|
||||||
|
response = await self._client._request("POST", f"/documents/{collection}/query", body=body)
|
||||||
|
return QueryResult(
|
||||||
|
documents=[self._parse_document(d) for d in response.get("documents", [])],
|
||||||
|
total=response.get("total", 0),
|
||||||
|
has_more=response.get("has_more", False),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def count(self, collection: str, filter: Optional[Dict[str, Any]] = None) -> int:
|
||||||
|
"""Count documents matching a filter."""
|
||||||
|
body = {"filter": filter} if filter else {}
|
||||||
|
response = await self._client._request("POST", f"/documents/{collection}/count", body=body)
|
||||||
|
return response.get("count", 0)
|
||||||
|
|
||||||
|
async def aggregate(
|
||||||
|
self,
|
||||||
|
collection: str,
|
||||||
|
pipeline: List[Dict[str, Any]],
|
||||||
|
) -> List[Any]:
|
||||||
|
"""Run an aggregation pipeline."""
|
||||||
|
response = await self._client._request(
|
||||||
|
"POST", f"/documents/{collection}/aggregate", body={"pipeline": pipeline}
|
||||||
|
)
|
||||||
|
return response.get("results", [])
|
||||||
|
|
||||||
|
def _parse_document(self, data: Dict[str, Any]) -> Document:
|
||||||
|
return Document(
|
||||||
|
id=data["id"],
|
||||||
|
data=data["data"],
|
||||||
|
created_at=data["created_at"],
|
||||||
|
updated_at=data["updated_at"],
|
||||||
|
version=data["version"],
|
||||||
|
metadata=data.get("metadata"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VectorStore:
|
||||||
|
"""Vector store operations."""
|
||||||
|
|
||||||
|
def __init__(self, client: "SynorDatabase"):
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
async def create_collection(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
dimension: int,
|
||||||
|
metric: str = "cosine",
|
||||||
|
index_type: str = "hnsw",
|
||||||
|
) -> None:
|
||||||
|
"""Create a vector collection."""
|
||||||
|
await self._client._request(
|
||||||
|
"POST",
|
||||||
|
"/vectors/collections",
|
||||||
|
body={"name": name, "dimension": dimension, "metric": metric, "index_type": index_type},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_collection(self, name: str) -> None:
|
||||||
|
"""Delete a vector collection."""
|
||||||
|
await self._client._request("DELETE", f"/vectors/collections/{name}")
|
||||||
|
|
||||||
|
async def get_collection_stats(self, name: str) -> VectorCollectionStats:
|
||||||
|
"""Get collection statistics."""
|
||||||
|
response = await self._client._request("GET", f"/vectors/collections/{name}/stats")
|
||||||
|
return VectorCollectionStats(
|
||||||
|
name=response["name"],
|
||||||
|
vector_count=response["vector_count"],
|
||||||
|
dimension=response["dimension"],
|
||||||
|
index_size=response["index_size"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def upsert(
|
||||||
|
self,
|
||||||
|
collection: str,
|
||||||
|
vectors: List[Dict[str, Any]],
|
||||||
|
namespace: Optional[str] = None,
|
||||||
|
) -> int:
|
||||||
|
"""Upsert vectors."""
|
||||||
|
body: Dict[str, Any] = {"vectors": vectors}
|
||||||
|
if namespace:
|
||||||
|
body["namespace"] = namespace
|
||||||
|
|
||||||
|
response = await self._client._request("POST", f"/vectors/{collection}/upsert", body=body)
|
||||||
|
return response.get("upserted", 0)
|
||||||
|
|
||||||
|
async def search(
|
||||||
|
self,
|
||||||
|
collection: str,
|
||||||
|
vector: List[float],
|
||||||
|
top_k: int = 10,
|
||||||
|
namespace: Optional[str] = None,
|
||||||
|
filter: Optional[Dict[str, Any]] = None,
|
||||||
|
include_metadata: bool = True,
|
||||||
|
include_vectors: bool = False,
|
||||||
|
min_score: Optional[float] = None,
|
||||||
|
) -> List[SearchResult]:
|
||||||
|
"""Search for similar vectors."""
|
||||||
|
body: Dict[str, Any] = {
|
||||||
|
"vector": vector,
|
||||||
|
"top_k": top_k,
|
||||||
|
"include_metadata": include_metadata,
|
||||||
|
"include_vectors": include_vectors,
|
||||||
|
}
|
||||||
|
if namespace:
|
||||||
|
body["namespace"] = namespace
|
||||||
|
if filter:
|
||||||
|
body["filter"] = filter
|
||||||
|
if min_score is not None:
|
||||||
|
body["min_score"] = min_score
|
||||||
|
|
||||||
|
response = await self._client._request("POST", f"/vectors/{collection}/search", body=body)
|
||||||
|
return [
|
||||||
|
SearchResult(
|
||||||
|
id=r["id"],
|
||||||
|
score=r["score"],
|
||||||
|
vector=r.get("vector"),
|
||||||
|
metadata=r.get("metadata"),
|
||||||
|
content=r.get("content"),
|
||||||
|
)
|
||||||
|
for r in response.get("results", [])
|
||||||
|
]
|
||||||
|
|
||||||
|
async def search_text(
|
||||||
|
self,
|
||||||
|
collection: str,
|
||||||
|
text: str,
|
||||||
|
top_k: int = 10,
|
||||||
|
**kwargs,
|
||||||
|
) -> List[SearchResult]:
|
||||||
|
"""Search using text (requires embedding generation)."""
|
||||||
|
body = {"text": text, "top_k": top_k, **kwargs}
|
||||||
|
response = await self._client._request("POST", f"/vectors/{collection}/search/text", body=body)
|
||||||
|
return [
|
||||||
|
SearchResult(
|
||||||
|
id=r["id"],
|
||||||
|
score=r["score"],
|
||||||
|
vector=r.get("vector"),
|
||||||
|
metadata=r.get("metadata"),
|
||||||
|
content=r.get("content"),
|
||||||
|
)
|
||||||
|
for r in response.get("results", [])
|
||||||
|
]
|
||||||
|
|
||||||
|
async def delete(
|
||||||
|
self,
|
||||||
|
collection: str,
|
||||||
|
ids: List[str],
|
||||||
|
namespace: Optional[str] = None,
|
||||||
|
) -> int:
|
||||||
|
"""Delete vectors by ID."""
|
||||||
|
body: Dict[str, Any] = {"ids": ids}
|
||||||
|
if namespace:
|
||||||
|
body["namespace"] = namespace
|
||||||
|
|
||||||
|
response = await self._client._request("POST", f"/vectors/{collection}/delete", body=body)
|
||||||
|
return response.get("deleted", 0)
|
||||||
|
|
||||||
|
async def fetch(
|
||||||
|
self,
|
||||||
|
collection: str,
|
||||||
|
ids: List[str],
|
||||||
|
namespace: Optional[str] = None,
|
||||||
|
) -> List[VectorEntry]:
|
||||||
|
"""Fetch vectors by ID."""
|
||||||
|
body: Dict[str, Any] = {"ids": ids}
|
||||||
|
if namespace:
|
||||||
|
body["namespace"] = namespace
|
||||||
|
|
||||||
|
response = await self._client._request("POST", f"/vectors/{collection}/fetch", body=body)
|
||||||
|
return [
|
||||||
|
VectorEntry(
|
||||||
|
id=v["id"],
|
||||||
|
vector=v["vector"],
|
||||||
|
metadata=v.get("metadata"),
|
||||||
|
content=v.get("content"),
|
||||||
|
)
|
||||||
|
for v in response.get("vectors", [])
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TimeSeriesStore:
|
||||||
|
"""Time series store operations."""
|
||||||
|
|
||||||
|
def __init__(self, client: "SynorDatabase"):
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
async def write(
|
||||||
|
self,
|
||||||
|
series: str,
|
||||||
|
points: List[Dict[str, Any]],
|
||||||
|
precision: str = "ms",
|
||||||
|
) -> int:
|
||||||
|
"""Write data points to a series."""
|
||||||
|
body = {"points": points, "precision": precision}
|
||||||
|
response = await self._client._request("POST", f"/timeseries/{series}/write", body=body)
|
||||||
|
return response.get("written", 0)
|
||||||
|
|
||||||
|
async def query(
|
||||||
|
self,
|
||||||
|
series: str,
|
||||||
|
start: Union[int, datetime, str],
|
||||||
|
end: Union[int, datetime, str],
|
||||||
|
tags: Optional[Dict[str, str]] = None,
|
||||||
|
aggregation: Optional[str] = None,
|
||||||
|
interval: Optional[str] = None,
|
||||||
|
fill: Optional[Union[str, float]] = None,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
) -> TimeSeriesResult:
|
||||||
|
"""Query a time series."""
|
||||||
|
body: Dict[str, Any] = {
|
||||||
|
"range": {
|
||||||
|
"start": self._format_time(start),
|
||||||
|
"end": self._format_time(end),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tags:
|
||||||
|
body["tags"] = tags
|
||||||
|
if aggregation:
|
||||||
|
body["aggregation"] = aggregation
|
||||||
|
if interval:
|
||||||
|
body["interval"] = interval
|
||||||
|
if fill is not None:
|
||||||
|
body["fill"] = fill
|
||||||
|
if limit:
|
||||||
|
body["limit"] = limit
|
||||||
|
|
||||||
|
response = await self._client._request("POST", f"/timeseries/{series}/query", body=body)
|
||||||
|
return TimeSeriesResult(
|
||||||
|
series=response["series"],
|
||||||
|
points=[
|
||||||
|
DataPoint(
|
||||||
|
timestamp=p["timestamp"],
|
||||||
|
value=p["value"],
|
||||||
|
tags=p.get("tags"),
|
||||||
|
)
|
||||||
|
for p in response.get("points", [])
|
||||||
|
],
|
||||||
|
statistics=response.get("statistics"),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete(
|
||||||
|
self,
|
||||||
|
series: str,
|
||||||
|
start: Union[int, datetime, str],
|
||||||
|
end: Union[int, datetime, str],
|
||||||
|
tags: Optional[Dict[str, str]] = None,
|
||||||
|
) -> int:
|
||||||
|
"""Delete data points in a range."""
|
||||||
|
body: Dict[str, Any] = {
|
||||||
|
"range": {
|
||||||
|
"start": self._format_time(start),
|
||||||
|
"end": self._format_time(end),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tags:
|
||||||
|
body["tags"] = tags
|
||||||
|
|
||||||
|
response = await self._client._request("POST", f"/timeseries/{series}/delete", body=body)
|
||||||
|
return response.get("deleted", 0)
|
||||||
|
|
||||||
|
async def get_series_info(self, series: str) -> SeriesInfo:
|
||||||
|
"""Get series information."""
|
||||||
|
response = await self._client._request("GET", f"/timeseries/{series}/info")
|
||||||
|
return SeriesInfo(
|
||||||
|
name=response["name"],
|
||||||
|
tags=response.get("tags", []),
|
||||||
|
retention_days=response.get("retention_days", 0),
|
||||||
|
point_count=response.get("point_count", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_series(self, prefix: Optional[str] = None) -> List[SeriesInfo]:
|
||||||
|
"""List all series."""
|
||||||
|
params = f"?prefix={prefix}" if prefix else ""
|
||||||
|
response = await self._client._request("GET", f"/timeseries{params}")
|
||||||
|
return [
|
||||||
|
SeriesInfo(
|
||||||
|
name=s["name"],
|
||||||
|
tags=s.get("tags", []),
|
||||||
|
retention_days=s.get("retention_days", 0),
|
||||||
|
point_count=s.get("point_count", 0),
|
||||||
|
)
|
||||||
|
for s in response.get("series", [])
|
||||||
|
]
|
||||||
|
|
||||||
|
async def set_retention(self, series: str, retention_days: int) -> None:
|
||||||
|
"""Set retention policy for a series."""
|
||||||
|
await self._client._request(
|
||||||
|
"PUT", f"/timeseries/{series}/retention", body={"retention_days": retention_days}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _format_time(self, t: Union[int, datetime, str]) -> Union[int, str]:
|
||||||
|
if isinstance(t, datetime):
|
||||||
|
return int(t.timestamp() * 1000)
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
class SynorDatabase:
|
||||||
|
"""
|
||||||
|
Synor Database SDK Client.
|
||||||
|
|
||||||
|
Multi-model database supporting Key-Value, Document, Vector, and Time Series data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_key: str,
|
||||||
|
endpoint: str = "https://database.synor.io/v1",
|
||||||
|
network: Network = Network.MAINNET,
|
||||||
|
timeout: float = 30.0,
|
||||||
|
retries: int = 3,
|
||||||
|
debug: bool = False,
|
||||||
|
):
|
||||||
|
self._config = DatabaseConfig(
|
||||||
|
api_key=api_key,
|
||||||
|
endpoint=endpoint,
|
||||||
|
network=network,
|
||||||
|
timeout=timeout,
|
||||||
|
retries=retries,
|
||||||
|
debug=debug,
|
||||||
|
)
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
base_url=endpoint,
|
||||||
|
timeout=timeout,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-SDK-Version": "python/0.1.0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._closed = False
|
||||||
|
|
||||||
|
# Initialize stores
|
||||||
|
self.kv = KeyValueStore(self)
|
||||||
|
self.documents = DocumentStore(self)
|
||||||
|
self.vectors = VectorStore(self)
|
||||||
|
self.timeseries = TimeSeriesStore(self)
|
||||||
|
|
||||||
|
async def get_stats(self) -> DatabaseStats:
|
||||||
|
"""Get database statistics."""
|
||||||
|
response = await self._request("GET", "/stats")
|
||||||
|
return DatabaseStats(
|
||||||
|
kv_entries=response.get("kv_entries", 0),
|
||||||
|
document_count=response.get("document_count", 0),
|
||||||
|
vector_count=response.get("vector_count", 0),
|
||||||
|
timeseries_points=response.get("timeseries_points", 0),
|
||||||
|
storage_used=response.get("storage_used", 0),
|
||||||
|
storage_limit=response.get("storage_limit", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
"""Health check."""
|
||||||
|
try:
|
||||||
|
response = await self._request("GET", "/health")
|
||||||
|
return response.get("status") == "healthy"
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close the client."""
|
||||||
|
if not self._closed:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._closed = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_closed(self) -> bool:
|
||||||
|
"""Check if the client is closed."""
|
||||||
|
return self._closed
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "SynorDatabase":
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||||
|
await self.close()
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
body: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Make an HTTP request with retry logic."""
|
||||||
|
if self._closed:
|
||||||
|
raise DatabaseError("Client has been closed")
|
||||||
|
|
||||||
|
last_error: Optional[Exception] = None
|
||||||
|
|
||||||
|
for attempt in range(self._config.retries):
|
||||||
|
try:
|
||||||
|
if method == "GET":
|
||||||
|
response = await self._client.get(path)
|
||||||
|
elif method == "POST":
|
||||||
|
response = await self._client.post(path, json=body)
|
||||||
|
elif method == "PUT":
|
||||||
|
response = await self._client.put(path, json=body)
|
||||||
|
elif method == "PATCH":
|
||||||
|
response = await self._client.patch(path, json=body)
|
||||||
|
elif method == "DELETE":
|
||||||
|
response = await self._client.delete(path)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown method: {method}")
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
error_data = response.json() if response.content else {}
|
||||||
|
raise DatabaseError(
|
||||||
|
error_data.get("message") or error_data.get("error") or f"HTTP {response.status_code}",
|
||||||
|
error_data.get("code"),
|
||||||
|
response.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.json() if response.content else {}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
if self._config.debug:
|
||||||
|
print(f"Attempt {attempt + 1} failed: {e}")
|
||||||
|
|
||||||
|
if attempt < self._config.retries - 1:
|
||||||
|
import asyncio
|
||||||
|
await asyncio.sleep(attempt + 1)
|
||||||
|
|
||||||
|
if last_error:
|
||||||
|
raise last_error
|
||||||
|
raise DatabaseError("Unknown error after retries")
|
||||||
253
sdk/python/src/synor_database/types.py
Normal file
253
sdk/python/src/synor_database/types.py
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
"""
|
||||||
|
Synor Database SDK Types
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class Network(str, Enum):
|
||||||
|
"""Network environment."""
|
||||||
|
MAINNET = "mainnet"
|
||||||
|
TESTNET = "testnet"
|
||||||
|
DEVNET = "devnet"
|
||||||
|
|
||||||
|
|
||||||
|
class DistanceMetric(str, Enum):
|
||||||
|
"""Vector distance metric."""
|
||||||
|
COSINE = "cosine"
|
||||||
|
EUCLIDEAN = "euclidean"
|
||||||
|
DOT_PRODUCT = "dotProduct"
|
||||||
|
|
||||||
|
|
||||||
|
class Aggregation(str, Enum):
|
||||||
|
"""Time series aggregation function."""
|
||||||
|
MEAN = "mean"
|
||||||
|
SUM = "sum"
|
||||||
|
COUNT = "count"
|
||||||
|
MIN = "min"
|
||||||
|
MAX = "max"
|
||||||
|
FIRST = "first"
|
||||||
|
LAST = "last"
|
||||||
|
STDDEV = "stddev"
|
||||||
|
VARIANCE = "variance"
|
||||||
|
|
||||||
|
|
||||||
|
class SortOrder(str, Enum):
|
||||||
|
"""Sort order."""
|
||||||
|
ASC = "asc"
|
||||||
|
DESC = "desc"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DatabaseConfig:
|
||||||
|
"""Database configuration."""
|
||||||
|
api_key: str
|
||||||
|
endpoint: str = "https://database.synor.io/v1"
|
||||||
|
network: Network = Network.MAINNET
|
||||||
|
timeout: float = 30.0
|
||||||
|
retries: int = 3
|
||||||
|
debug: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Key-Value Types ====================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class KeyValueEntry:
|
||||||
|
"""Key-value entry with metadata."""
|
||||||
|
key: str
|
||||||
|
value: Any
|
||||||
|
created_at: int
|
||||||
|
updated_at: int
|
||||||
|
metadata: Optional[Dict[str, str]] = None
|
||||||
|
expires_at: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SetOptions:
|
||||||
|
"""Options for setting a key-value pair."""
|
||||||
|
ttl: Optional[int] = None # Time-to-live in seconds
|
||||||
|
metadata: Optional[Dict[str, str]] = None
|
||||||
|
if_not_exists: bool = False
|
||||||
|
if_exists: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ListOptions:
|
||||||
|
"""Options for listing keys."""
|
||||||
|
prefix: Optional[str] = None
|
||||||
|
cursor: Optional[str] = None
|
||||||
|
limit: int = 100
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ListResult:
|
||||||
|
"""Result of listing keys."""
|
||||||
|
entries: List[KeyValueEntry]
|
||||||
|
cursor: Optional[str] = None
|
||||||
|
has_more: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Document Store Types ====================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Document:
|
||||||
|
"""A document with metadata."""
|
||||||
|
id: str
|
||||||
|
data: Dict[str, Any]
|
||||||
|
created_at: int
|
||||||
|
updated_at: int
|
||||||
|
version: int
|
||||||
|
metadata: Optional[Dict[str, str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class QueryOptions:
|
||||||
|
"""Query options for document store."""
|
||||||
|
filter: Optional[Dict[str, Any]] = None
|
||||||
|
sort: Optional[Dict[str, SortOrder]] = None
|
||||||
|
skip: int = 0
|
||||||
|
limit: int = 100
|
||||||
|
projection: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class QueryResult:
|
||||||
|
"""Result of a document query."""
|
||||||
|
documents: List[Document]
|
||||||
|
total: int
|
||||||
|
has_more: bool
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Vector Store Types ====================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VectorEntry:
|
||||||
|
"""A vector entry."""
|
||||||
|
id: str
|
||||||
|
vector: List[float]
|
||||||
|
metadata: Optional[Dict[str, Any]] = None
|
||||||
|
content: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SearchOptions:
|
||||||
|
"""Options for vector search."""
|
||||||
|
namespace: Optional[str] = None
|
||||||
|
top_k: int = 10
|
||||||
|
filter: Optional[Dict[str, Any]] = None
|
||||||
|
include_metadata: bool = True
|
||||||
|
include_vectors: bool = False
|
||||||
|
min_score: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SearchResult:
|
||||||
|
"""Result of a vector search."""
|
||||||
|
id: str
|
||||||
|
score: float
|
||||||
|
vector: Optional[List[float]] = None
|
||||||
|
metadata: Optional[Dict[str, Any]] = None
|
||||||
|
content: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VectorCollectionConfig:
|
||||||
|
"""Configuration for a vector collection."""
|
||||||
|
name: str
|
||||||
|
dimension: int
|
||||||
|
metric: DistanceMetric = DistanceMetric.COSINE
|
||||||
|
index_type: str = "hnsw"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VectorCollectionStats:
|
||||||
|
"""Statistics for a vector collection."""
|
||||||
|
name: str
|
||||||
|
vector_count: int
|
||||||
|
dimension: int
|
||||||
|
index_size: int
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Time Series Types ====================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DataPoint:
|
||||||
|
"""A time series data point."""
|
||||||
|
timestamp: int
|
||||||
|
value: float
|
||||||
|
tags: Optional[Dict[str, str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TimeRange:
|
||||||
|
"""Time range for queries."""
|
||||||
|
start: Union[int, datetime, str]
|
||||||
|
end: Union[int, datetime, str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TimeSeriesQueryOptions:
|
||||||
|
"""Options for time series queries."""
|
||||||
|
tags: Optional[Dict[str, str]] = None
|
||||||
|
aggregation: Optional[Aggregation] = None
|
||||||
|
interval: Optional[str] = None # e.g., "1m", "5m", "1h"
|
||||||
|
fill: Optional[Union[str, float]] = None
|
||||||
|
limit: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TimeSeriesResult:
|
||||||
|
"""Result of a time series query."""
|
||||||
|
series: str
|
||||||
|
points: List[DataPoint]
|
||||||
|
statistics: Optional[Dict[str, float]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SeriesInfo:
|
||||||
|
"""Information about a time series."""
|
||||||
|
name: str
|
||||||
|
tags: List[str]
|
||||||
|
retention_days: int
|
||||||
|
point_count: int
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Database Stats ====================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DatabaseStats:
|
||||||
|
"""Overall database statistics."""
|
||||||
|
kv_entries: int
|
||||||
|
document_count: int
|
||||||
|
vector_count: int
|
||||||
|
timeseries_points: int
|
||||||
|
storage_used: int
|
||||||
|
storage_limit: int
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Errors ====================
|
||||||
|
|
||||||
|
class DatabaseError(Exception):
|
||||||
|
"""Base exception for database errors."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, code: Optional[str] = None, status_code: Optional[int] = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentNotFoundError(DatabaseError):
|
||||||
|
"""Raised when a document is not found."""
|
||||||
|
|
||||||
|
def __init__(self, collection: str, id: str):
|
||||||
|
super().__init__(f"Document not found: {collection}/{id}", "DOCUMENT_NOT_FOUND", 404)
|
||||||
|
|
||||||
|
|
||||||
|
class KeyNotFoundError(DatabaseError):
|
||||||
|
"""Raised when a key is not found."""
|
||||||
|
|
||||||
|
def __init__(self, key: str):
|
||||||
|
super().__init__(f"Key not found: {key}", "KEY_NOT_FOUND", 404)
|
||||||
54
sdk/python/src/synor_hosting/__init__.py
Normal file
54
sdk/python/src/synor_hosting/__init__.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
"""
|
||||||
|
Synor Hosting SDK for Python
|
||||||
|
|
||||||
|
Decentralized web hosting with domain management, DNS, and deployments.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> from synor_hosting import SynorHosting
|
||||||
|
>>>
|
||||||
|
>>> hosting = SynorHosting(api_key="your-api-key")
|
||||||
|
>>>
|
||||||
|
>>> # Register a domain
|
||||||
|
>>> domain = await hosting.register_domain("mysite.synor")
|
||||||
|
>>> print(f"Domain registered: {domain.name}")
|
||||||
|
>>>
|
||||||
|
>>> # Deploy from CID
|
||||||
|
>>> deployment = await hosting.deploy("Qm...", domain="mysite.synor")
|
||||||
|
>>> print(f"Live at: {deployment.url}")
|
||||||
|
>>>
|
||||||
|
>>> # Provision SSL
|
||||||
|
>>> cert = await hosting.provision_ssl("mysite.synor")
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .types import (
|
||||||
|
HostingConfig,
|
||||||
|
Domain,
|
||||||
|
DomainRecord,
|
||||||
|
DomainAvailability,
|
||||||
|
DnsRecord,
|
||||||
|
DnsZone,
|
||||||
|
Deployment,
|
||||||
|
DeploymentStats,
|
||||||
|
Certificate,
|
||||||
|
SiteConfig,
|
||||||
|
AnalyticsData,
|
||||||
|
HostingError,
|
||||||
|
)
|
||||||
|
from .client import SynorHosting
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
__all__ = [
|
||||||
|
"SynorHosting",
|
||||||
|
"HostingConfig",
|
||||||
|
"Domain",
|
||||||
|
"DomainRecord",
|
||||||
|
"DomainAvailability",
|
||||||
|
"DnsRecord",
|
||||||
|
"DnsZone",
|
||||||
|
"Deployment",
|
||||||
|
"DeploymentStats",
|
||||||
|
"Certificate",
|
||||||
|
"SiteConfig",
|
||||||
|
"AnalyticsData",
|
||||||
|
"HostingError",
|
||||||
|
]
|
||||||
487
sdk/python/src/synor_hosting/client.py
Normal file
487
sdk/python/src/synor_hosting/client.py
Normal file
|
|
@ -0,0 +1,487 @@
|
||||||
|
"""
|
||||||
|
Synor Hosting SDK Client
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .types import (
|
||||||
|
HostingConfig,
|
||||||
|
Network,
|
||||||
|
Domain,
|
||||||
|
DomainRecord,
|
||||||
|
DomainStatus,
|
||||||
|
DomainAvailability,
|
||||||
|
DnsRecord,
|
||||||
|
DnsRecordType,
|
||||||
|
DnsZone,
|
||||||
|
Deployment,
|
||||||
|
DeploymentStatus,
|
||||||
|
DeploymentStats,
|
||||||
|
RedirectRule,
|
||||||
|
Certificate,
|
||||||
|
CertificateStatus,
|
||||||
|
SiteConfig,
|
||||||
|
AnalyticsData,
|
||||||
|
PageView,
|
||||||
|
Referrer,
|
||||||
|
Country,
|
||||||
|
HostingError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SynorHosting:
|
||||||
|
"""
|
||||||
|
Synor Hosting SDK Client.
|
||||||
|
|
||||||
|
Provides domain registration, DNS management, site deployment, and SSL provisioning.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_key: str,
|
||||||
|
endpoint: str = "https://hosting.synor.io/v1",
|
||||||
|
network: Network = Network.MAINNET,
|
||||||
|
timeout: float = 60.0,
|
||||||
|
retries: int = 3,
|
||||||
|
debug: bool = False,
|
||||||
|
):
|
||||||
|
self._config = HostingConfig(
|
||||||
|
api_key=api_key,
|
||||||
|
endpoint=endpoint,
|
||||||
|
network=network,
|
||||||
|
timeout=timeout,
|
||||||
|
retries=retries,
|
||||||
|
debug=debug,
|
||||||
|
)
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
base_url=endpoint,
|
||||||
|
timeout=timeout,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-SDK-Version": "python/0.1.0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._closed = False
|
||||||
|
|
||||||
|
# ==================== Domain Operations ====================
|
||||||
|
|
||||||
|
async def check_availability(self, name: str) -> DomainAvailability:
|
||||||
|
"""Check domain availability."""
|
||||||
|
response = await self._request("GET", f"/domains/check/{name}")
|
||||||
|
return DomainAvailability(
|
||||||
|
name=response["name"],
|
||||||
|
available=response["available"],
|
||||||
|
price=response.get("price"),
|
||||||
|
premium=response.get("premium", False),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def register_domain(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
years: int = 1,
|
||||||
|
auto_renew: bool = True,
|
||||||
|
records: Optional[DomainRecord] = None,
|
||||||
|
) -> Domain:
|
||||||
|
"""Register a new domain."""
|
||||||
|
body: Dict[str, Any] = {"name": name, "years": years, "auto_renew": auto_renew}
|
||||||
|
if records:
|
||||||
|
body["records"] = self._domain_record_to_dict(records)
|
||||||
|
|
||||||
|
response = await self._request("POST", "/domains", body=body)
|
||||||
|
return self._parse_domain(response)
|
||||||
|
|
||||||
|
async def get_domain(self, name: str) -> Domain:
|
||||||
|
"""Get domain information."""
|
||||||
|
response = await self._request("GET", f"/domains/{name}")
|
||||||
|
return self._parse_domain(response)
|
||||||
|
|
||||||
|
async def list_domains(self) -> List[Domain]:
|
||||||
|
"""List all domains."""
|
||||||
|
response = await self._request("GET", "/domains")
|
||||||
|
return [self._parse_domain(d) for d in response.get("domains", [])]
|
||||||
|
|
||||||
|
async def update_domain_record(self, name: str, record: DomainRecord) -> Domain:
|
||||||
|
"""Update domain record."""
|
||||||
|
response = await self._request(
|
||||||
|
"PUT", f"/domains/{name}/record", body=self._domain_record_to_dict(record)
|
||||||
|
)
|
||||||
|
return self._parse_domain(response)
|
||||||
|
|
||||||
|
async def resolve_domain(self, name: str) -> DomainRecord:
|
||||||
|
"""Resolve a domain to its record."""
|
||||||
|
response = await self._request("GET", f"/domains/{name}/resolve")
|
||||||
|
return self._parse_domain_record(response)
|
||||||
|
|
||||||
|
async def renew_domain(self, name: str, years: int = 1) -> Domain:
|
||||||
|
"""Renew a domain."""
|
||||||
|
response = await self._request("POST", f"/domains/{name}/renew", body={"years": years})
|
||||||
|
return self._parse_domain(response)
|
||||||
|
|
||||||
|
async def transfer_domain(self, name: str, new_owner: str) -> Domain:
|
||||||
|
"""Transfer domain ownership."""
|
||||||
|
response = await self._request("POST", f"/domains/{name}/transfer", body={"new_owner": new_owner})
|
||||||
|
return self._parse_domain(response)
|
||||||
|
|
||||||
|
# ==================== DNS Operations ====================
|
||||||
|
|
||||||
|
async def get_dns_zone(self, domain: str) -> DnsZone:
|
||||||
|
"""Get DNS zone for a domain."""
|
||||||
|
response = await self._request("GET", f"/dns/{domain}")
|
||||||
|
return DnsZone(
|
||||||
|
domain=response["domain"],
|
||||||
|
records=[self._parse_dns_record(r) for r in response.get("records", [])],
|
||||||
|
updated_at=response["updated_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_dns_records(self, domain: str, records: List[DnsRecord]) -> DnsZone:
|
||||||
|
"""Set DNS records for a domain."""
|
||||||
|
body = {"records": [self._dns_record_to_dict(r) for r in records]}
|
||||||
|
response = await self._request("PUT", f"/dns/{domain}", body=body)
|
||||||
|
return DnsZone(
|
||||||
|
domain=response["domain"],
|
||||||
|
records=[self._parse_dns_record(r) for r in response.get("records", [])],
|
||||||
|
updated_at=response["updated_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def add_dns_record(self, domain: str, record: DnsRecord) -> DnsZone:
|
||||||
|
"""Add a DNS record."""
|
||||||
|
body = self._dns_record_to_dict(record)
|
||||||
|
response = await self._request("POST", f"/dns/{domain}/records", body=body)
|
||||||
|
return DnsZone(
|
||||||
|
domain=response["domain"],
|
||||||
|
records=[self._parse_dns_record(r) for r in response.get("records", [])],
|
||||||
|
updated_at=response["updated_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_dns_record(self, domain: str, record_type: str, name: str) -> DnsZone:
|
||||||
|
"""Delete a DNS record."""
|
||||||
|
response = await self._request("DELETE", f"/dns/{domain}/records/{record_type}/{name}")
|
||||||
|
return DnsZone(
|
||||||
|
domain=response["domain"],
|
||||||
|
records=[self._parse_dns_record(r) for r in response.get("records", [])],
|
||||||
|
updated_at=response["updated_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== Deployment Operations ====================
|
||||||
|
|
||||||
|
async def deploy(
|
||||||
|
self,
|
||||||
|
cid: str,
|
||||||
|
domain: Optional[str] = None,
|
||||||
|
subdomain: Optional[str] = None,
|
||||||
|
headers: Optional[Dict[str, str]] = None,
|
||||||
|
redirects: Optional[List[RedirectRule]] = None,
|
||||||
|
spa: bool = False,
|
||||||
|
clean_urls: bool = True,
|
||||||
|
trailing_slash: bool = False,
|
||||||
|
) -> Deployment:
|
||||||
|
"""Deploy a site from CID."""
|
||||||
|
body: Dict[str, Any] = {"cid": cid}
|
||||||
|
if domain:
|
||||||
|
body["domain"] = domain
|
||||||
|
if subdomain:
|
||||||
|
body["subdomain"] = subdomain
|
||||||
|
if headers:
|
||||||
|
body["headers"] = headers
|
||||||
|
if redirects:
|
||||||
|
body["redirects"] = [
|
||||||
|
{"source": r.source, "destination": r.destination, "status_code": r.status_code}
|
||||||
|
for r in redirects
|
||||||
|
]
|
||||||
|
body["spa"] = spa
|
||||||
|
body["clean_urls"] = clean_urls
|
||||||
|
body["trailing_slash"] = trailing_slash
|
||||||
|
|
||||||
|
response = await self._request("POST", "/deployments", body=body)
|
||||||
|
return self._parse_deployment(response)
|
||||||
|
|
||||||
|
async def get_deployment(self, deployment_id: str) -> Deployment:
|
||||||
|
"""Get deployment by ID."""
|
||||||
|
response = await self._request("GET", f"/deployments/{deployment_id}")
|
||||||
|
return self._parse_deployment(response)
|
||||||
|
|
||||||
|
async def list_deployments(self, domain: Optional[str] = None) -> List[Deployment]:
|
||||||
|
"""List deployments."""
|
||||||
|
path = f"/deployments?domain={domain}" if domain else "/deployments"
|
||||||
|
response = await self._request("GET", path)
|
||||||
|
return [self._parse_deployment(d) for d in response.get("deployments", [])]
|
||||||
|
|
||||||
|
async def rollback(self, domain: str, deployment_id: str) -> Deployment:
|
||||||
|
"""Rollback to a previous deployment."""
|
||||||
|
response = await self._request(
|
||||||
|
"POST", f"/deployments/{deployment_id}/rollback", body={"domain": domain}
|
||||||
|
)
|
||||||
|
return self._parse_deployment(response)
|
||||||
|
|
||||||
|
async def delete_deployment(self, deployment_id: str) -> None:
|
||||||
|
"""Delete a deployment."""
|
||||||
|
await self._request("DELETE", f"/deployments/{deployment_id}")
|
||||||
|
|
||||||
|
async def get_deployment_stats(self, deployment_id: str, period: str = "24h") -> DeploymentStats:
|
||||||
|
"""Get deployment stats."""
|
||||||
|
response = await self._request("GET", f"/deployments/{deployment_id}/stats?period={period}")
|
||||||
|
return DeploymentStats(
|
||||||
|
requests=response["requests"],
|
||||||
|
bandwidth=response["bandwidth"],
|
||||||
|
unique_visitors=response["unique_visitors"],
|
||||||
|
period=response["period"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== SSL Operations ====================
|
||||||
|
|
||||||
|
async def provision_ssl(
|
||||||
|
self,
|
||||||
|
domain: str,
|
||||||
|
include_www: bool = True,
|
||||||
|
auto_renew: bool = True,
|
||||||
|
) -> Certificate:
|
||||||
|
"""Provision SSL certificate."""
|
||||||
|
body = {"include_www": include_www, "auto_renew": auto_renew}
|
||||||
|
response = await self._request("POST", f"/ssl/{domain}", body=body)
|
||||||
|
return self._parse_certificate(response)
|
||||||
|
|
||||||
|
async def get_certificate(self, domain: str) -> Certificate:
|
||||||
|
"""Get certificate status."""
|
||||||
|
response = await self._request("GET", f"/ssl/{domain}")
|
||||||
|
return self._parse_certificate(response)
|
||||||
|
|
||||||
|
async def renew_certificate(self, domain: str) -> Certificate:
|
||||||
|
"""Renew SSL certificate."""
|
||||||
|
response = await self._request("POST", f"/ssl/{domain}/renew")
|
||||||
|
return self._parse_certificate(response)
|
||||||
|
|
||||||
|
async def delete_certificate(self, domain: str) -> None:
|
||||||
|
"""Delete/revoke SSL certificate."""
|
||||||
|
await self._request("DELETE", f"/ssl/{domain}")
|
||||||
|
|
||||||
|
# ==================== Site Configuration ====================
|
||||||
|
|
||||||
|
async def get_site_config(self, domain: str) -> SiteConfig:
|
||||||
|
"""Get site configuration."""
|
||||||
|
response = await self._request("GET", f"/sites/{domain}/config")
|
||||||
|
return self._parse_site_config(response)
|
||||||
|
|
||||||
|
async def update_site_config(self, domain: str, **kwargs) -> SiteConfig:
|
||||||
|
"""Update site configuration."""
|
||||||
|
response = await self._request("PATCH", f"/sites/{domain}/config", body=kwargs)
|
||||||
|
return self._parse_site_config(response)
|
||||||
|
|
||||||
|
async def purge_cache(self, domain: str, paths: Optional[List[str]] = None) -> int:
|
||||||
|
"""Purge CDN cache."""
|
||||||
|
body = {"paths": paths} if paths else None
|
||||||
|
response = await self._request("DELETE", f"/sites/{domain}/cache", body=body)
|
||||||
|
return response.get("purged", 0)
|
||||||
|
|
||||||
|
# ==================== Analytics ====================
|
||||||
|
|
||||||
|
async def get_analytics(
|
||||||
|
self,
|
||||||
|
domain: str,
|
||||||
|
period: str = "24h",
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
|
) -> AnalyticsData:
|
||||||
|
"""Get site analytics."""
|
||||||
|
params = {"period": period}
|
||||||
|
if start_date:
|
||||||
|
params["start"] = start_date
|
||||||
|
if end_date:
|
||||||
|
params["end"] = end_date
|
||||||
|
|
||||||
|
response = await self._request("GET", f"/sites/{domain}/analytics?{urlencode(params)}")
|
||||||
|
return AnalyticsData(
|
||||||
|
domain=response["domain"],
|
||||||
|
period=response["period"],
|
||||||
|
page_views=response["page_views"],
|
||||||
|
unique_visitors=response["unique_visitors"],
|
||||||
|
bandwidth=response["bandwidth"],
|
||||||
|
top_pages=[PageView(path=p["path"], views=p["views"]) for p in response.get("top_pages", [])],
|
||||||
|
top_referrers=[Referrer(referrer=r["referrer"], count=r["count"]) for r in response.get("top_referrers", [])],
|
||||||
|
top_countries=[Country(country=c["country"], count=c["count"]) for c in response.get("top_countries", [])],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== Lifecycle ====================
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close the client."""
|
||||||
|
if not self._closed:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._closed = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_closed(self) -> bool:
|
||||||
|
"""Check if the client is closed."""
|
||||||
|
return self._closed
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "SynorHosting":
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||||
|
await self.close()
|
||||||
|
|
||||||
|
# ==================== Internal Methods ====================
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
body: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Make an HTTP request with retry logic."""
|
||||||
|
if self._closed:
|
||||||
|
raise HostingError("Client has been closed")
|
||||||
|
|
||||||
|
last_error: Optional[Exception] = None
|
||||||
|
|
||||||
|
for attempt in range(self._config.retries):
|
||||||
|
try:
|
||||||
|
if method == "GET":
|
||||||
|
response = await self._client.get(path)
|
||||||
|
elif method == "POST":
|
||||||
|
response = await self._client.post(path, json=body)
|
||||||
|
elif method == "PUT":
|
||||||
|
response = await self._client.put(path, json=body)
|
||||||
|
elif method == "PATCH":
|
||||||
|
response = await self._client.patch(path, json=body)
|
||||||
|
elif method == "DELETE":
|
||||||
|
if body:
|
||||||
|
response = await self._client.request("DELETE", path, json=body)
|
||||||
|
else:
|
||||||
|
response = await self._client.delete(path)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown method: {method}")
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
error_data = response.json() if response.content else {}
|
||||||
|
raise HostingError(
|
||||||
|
error_data.get("message") or error_data.get("error") or f"HTTP {response.status_code}",
|
||||||
|
error_data.get("code"),
|
||||||
|
response.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.json() if response.content else {}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
if self._config.debug:
|
||||||
|
print(f"Attempt {attempt + 1} failed: {e}")
|
||||||
|
|
||||||
|
if attempt < self._config.retries - 1:
|
||||||
|
import asyncio
|
||||||
|
await asyncio.sleep(attempt + 1)
|
||||||
|
|
||||||
|
if last_error:
|
||||||
|
raise last_error
|
||||||
|
raise HostingError("Unknown error after retries")
|
||||||
|
|
||||||
|
def _parse_domain(self, data: Dict[str, Any]) -> Domain:
|
||||||
|
records = None
|
||||||
|
if "records" in data and data["records"]:
|
||||||
|
records = self._parse_domain_record(data["records"])
|
||||||
|
|
||||||
|
return Domain(
|
||||||
|
name=data["name"],
|
||||||
|
status=DomainStatus(data["status"]),
|
||||||
|
owner=data["owner"],
|
||||||
|
registered_at=data["registered_at"],
|
||||||
|
expires_at=data["expires_at"],
|
||||||
|
auto_renew=data.get("auto_renew", True),
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_domain_record(self, data: Dict[str, Any]) -> DomainRecord:
|
||||||
|
return DomainRecord(
|
||||||
|
cid=data.get("cid"),
|
||||||
|
ipv4=data.get("ipv4"),
|
||||||
|
ipv6=data.get("ipv6"),
|
||||||
|
cname=data.get("cname"),
|
||||||
|
txt=data.get("txt"),
|
||||||
|
metadata=data.get("metadata"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _domain_record_to_dict(self, record: DomainRecord) -> Dict[str, Any]:
|
||||||
|
result: Dict[str, Any] = {}
|
||||||
|
if record.cid:
|
||||||
|
result["cid"] = record.cid
|
||||||
|
if record.ipv4:
|
||||||
|
result["ipv4"] = record.ipv4
|
||||||
|
if record.ipv6:
|
||||||
|
result["ipv6"] = record.ipv6
|
||||||
|
if record.cname:
|
||||||
|
result["cname"] = record.cname
|
||||||
|
if record.txt:
|
||||||
|
result["txt"] = record.txt
|
||||||
|
if record.metadata:
|
||||||
|
result["metadata"] = record.metadata
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _parse_dns_record(self, data: Dict[str, Any]) -> DnsRecord:
|
||||||
|
return DnsRecord(
|
||||||
|
type=DnsRecordType(data["type"]),
|
||||||
|
name=data["name"],
|
||||||
|
value=data["value"],
|
||||||
|
ttl=data.get("ttl", 3600),
|
||||||
|
priority=data.get("priority"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _dns_record_to_dict(self, record: DnsRecord) -> Dict[str, Any]:
|
||||||
|
result = {
|
||||||
|
"type": record.type.value,
|
||||||
|
"name": record.name,
|
||||||
|
"value": record.value,
|
||||||
|
"ttl": record.ttl,
|
||||||
|
}
|
||||||
|
if record.priority is not None:
|
||||||
|
result["priority"] = record.priority
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _parse_deployment(self, data: Dict[str, Any]) -> Deployment:
|
||||||
|
return Deployment(
|
||||||
|
id=data["id"],
|
||||||
|
domain=data["domain"],
|
||||||
|
cid=data["cid"],
|
||||||
|
status=DeploymentStatus(data["status"]),
|
||||||
|
url=data["url"],
|
||||||
|
created_at=data["created_at"],
|
||||||
|
updated_at=data["updated_at"],
|
||||||
|
build_logs=data.get("build_logs"),
|
||||||
|
error_message=data.get("error_message"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_certificate(self, data: Dict[str, Any]) -> Certificate:
|
||||||
|
return Certificate(
|
||||||
|
domain=data["domain"],
|
||||||
|
status=CertificateStatus(data["status"]),
|
||||||
|
auto_renew=data.get("auto_renew", True),
|
||||||
|
issuer=data.get("issuer", ""),
|
||||||
|
issued_at=data.get("issued_at"),
|
||||||
|
expires_at=data.get("expires_at"),
|
||||||
|
fingerprint=data.get("fingerprint"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_site_config(self, data: Dict[str, Any]) -> SiteConfig:
|
||||||
|
redirects = None
|
||||||
|
if "redirects" in data and data["redirects"]:
|
||||||
|
redirects = [
|
||||||
|
RedirectRule(
|
||||||
|
source=r["source"],
|
||||||
|
destination=r["destination"],
|
||||||
|
status_code=r.get("status_code", 301),
|
||||||
|
permanent=r.get("permanent", True),
|
||||||
|
)
|
||||||
|
for r in data["redirects"]
|
||||||
|
]
|
||||||
|
|
||||||
|
return SiteConfig(
|
||||||
|
domain=data["domain"],
|
||||||
|
cid=data.get("cid"),
|
||||||
|
headers=data.get("headers"),
|
||||||
|
redirects=redirects,
|
||||||
|
error_pages=data.get("error_pages"),
|
||||||
|
spa=data.get("spa", False),
|
||||||
|
clean_urls=data.get("clean_urls", True),
|
||||||
|
trailing_slash=data.get("trailing_slash", False),
|
||||||
|
)
|
||||||
241
sdk/python/src/synor_hosting/types.py
Normal file
241
sdk/python/src/synor_hosting/types.py
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
"""
|
||||||
|
Synor Hosting SDK Types
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class Network(str, Enum):
|
||||||
|
"""Network environment."""
|
||||||
|
MAINNET = "mainnet"
|
||||||
|
TESTNET = "testnet"
|
||||||
|
DEVNET = "devnet"
|
||||||
|
|
||||||
|
|
||||||
|
class DomainStatus(str, Enum):
|
||||||
|
"""Domain status."""
|
||||||
|
PENDING = "pending"
|
||||||
|
ACTIVE = "active"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
SUSPENDED = "suspended"
|
||||||
|
|
||||||
|
|
||||||
|
class DeploymentStatus(str, Enum):
|
||||||
|
"""Deployment status."""
|
||||||
|
PENDING = "pending"
|
||||||
|
BUILDING = "building"
|
||||||
|
DEPLOYING = "deploying"
|
||||||
|
ACTIVE = "active"
|
||||||
|
FAILED = "failed"
|
||||||
|
INACTIVE = "inactive"
|
||||||
|
|
||||||
|
|
||||||
|
class CertificateStatus(str, Enum):
|
||||||
|
"""Certificate status."""
|
||||||
|
PENDING = "pending"
|
||||||
|
ISSUED = "issued"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
REVOKED = "revoked"
|
||||||
|
|
||||||
|
|
||||||
|
class DnsRecordType(str, Enum):
|
||||||
|
"""DNS record type."""
|
||||||
|
A = "A"
|
||||||
|
AAAA = "AAAA"
|
||||||
|
CNAME = "CNAME"
|
||||||
|
TXT = "TXT"
|
||||||
|
MX = "MX"
|
||||||
|
NS = "NS"
|
||||||
|
SRV = "SRV"
|
||||||
|
CAA = "CAA"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HostingConfig:
|
||||||
|
"""Hosting configuration."""
|
||||||
|
api_key: str
|
||||||
|
endpoint: str = "https://hosting.synor.io/v1"
|
||||||
|
network: Network = Network.MAINNET
|
||||||
|
timeout: float = 60.0
|
||||||
|
retries: int = 3
|
||||||
|
debug: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Domain Types ====================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DomainRecord:
|
||||||
|
"""Domain record for resolution."""
|
||||||
|
cid: Optional[str] = None
|
||||||
|
ipv4: Optional[List[str]] = None
|
||||||
|
ipv6: Optional[List[str]] = None
|
||||||
|
cname: Optional[str] = None
|
||||||
|
txt: Optional[List[str]] = None
|
||||||
|
metadata: Optional[Dict[str, str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Domain:
|
||||||
|
"""Domain information."""
|
||||||
|
name: str
|
||||||
|
status: DomainStatus
|
||||||
|
owner: str
|
||||||
|
registered_at: int
|
||||||
|
expires_at: int
|
||||||
|
auto_renew: bool = True
|
||||||
|
records: Optional[DomainRecord] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DomainAvailability:
|
||||||
|
"""Domain availability check result."""
|
||||||
|
name: str
|
||||||
|
available: bool
|
||||||
|
price: Optional[float] = None
|
||||||
|
premium: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== DNS Types ====================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DnsRecord:
|
||||||
|
"""DNS record."""
|
||||||
|
type: DnsRecordType
|
||||||
|
name: str
|
||||||
|
value: str
|
||||||
|
ttl: int = 3600
|
||||||
|
priority: Optional[int] = None # For MX and SRV
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DnsZone:
|
||||||
|
"""DNS zone."""
|
||||||
|
domain: str
|
||||||
|
records: List[DnsRecord]
|
||||||
|
updated_at: int
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Deployment Types ====================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RedirectRule:
|
||||||
|
"""Redirect rule."""
|
||||||
|
source: str
|
||||||
|
destination: str
|
||||||
|
status_code: int = 301
|
||||||
|
permanent: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Deployment:
|
||||||
|
"""Deployment information."""
|
||||||
|
id: str
|
||||||
|
domain: str
|
||||||
|
cid: str
|
||||||
|
status: DeploymentStatus
|
||||||
|
url: str
|
||||||
|
created_at: int
|
||||||
|
updated_at: int
|
||||||
|
build_logs: Optional[str] = None
|
||||||
|
error_message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeploymentStats:
|
||||||
|
"""Deployment statistics."""
|
||||||
|
requests: int
|
||||||
|
bandwidth: int
|
||||||
|
unique_visitors: int
|
||||||
|
period: str
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== SSL Types ====================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Certificate:
|
||||||
|
"""SSL certificate."""
|
||||||
|
domain: str
|
||||||
|
status: CertificateStatus
|
||||||
|
auto_renew: bool
|
||||||
|
issuer: str
|
||||||
|
issued_at: Optional[int] = None
|
||||||
|
expires_at: Optional[int] = None
|
||||||
|
fingerprint: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Site Configuration ====================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SiteConfig:
|
||||||
|
"""Site configuration."""
|
||||||
|
domain: str
|
||||||
|
cid: Optional[str] = None
|
||||||
|
headers: Optional[Dict[str, str]] = None
|
||||||
|
redirects: Optional[List[RedirectRule]] = None
|
||||||
|
error_pages: Optional[Dict[str, str]] = None
|
||||||
|
spa: bool = False
|
||||||
|
clean_urls: bool = True
|
||||||
|
trailing_slash: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Analytics ====================
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PageView:
|
||||||
|
"""Page view data."""
|
||||||
|
path: str
|
||||||
|
views: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Referrer:
|
||||||
|
"""Referrer data."""
|
||||||
|
referrer: str
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Country:
|
||||||
|
"""Country data."""
|
||||||
|
country: str
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AnalyticsData:
|
||||||
|
"""Analytics data."""
|
||||||
|
domain: str
|
||||||
|
period: str
|
||||||
|
page_views: int
|
||||||
|
unique_visitors: int
|
||||||
|
bandwidth: int
|
||||||
|
top_pages: List[PageView]
|
||||||
|
top_referrers: List[Referrer]
|
||||||
|
top_countries: List[Country]
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Errors ====================
|
||||||
|
|
||||||
|
class HostingError(Exception):
|
||||||
|
"""Base exception for hosting errors."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, code: Optional[str] = None, status_code: Optional[int] = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
|
class DomainNotFoundError(HostingError):
|
||||||
|
"""Raised when a domain is not found."""
|
||||||
|
|
||||||
|
def __init__(self, domain: str):
|
||||||
|
super().__init__(f"Domain not found: {domain}", "DOMAIN_NOT_FOUND", 404)
|
||||||
|
|
||||||
|
|
||||||
|
class DomainUnavailableError(HostingError):
|
||||||
|
"""Raised when a domain is not available."""
|
||||||
|
|
||||||
|
def __init__(self, domain: str):
|
||||||
|
super().__init__(f"Domain is not available: {domain}", "DOMAIN_UNAVAILABLE", 409)
|
||||||
41
sdk/ruby/lib/synor_rpc.rb
Normal file
41
sdk/ruby/lib/synor_rpc.rb
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require_relative "synor_rpc/version"
|
||||||
|
require_relative "synor_rpc/types"
|
||||||
|
require_relative "synor_rpc/client"
|
||||||
|
|
||||||
|
# Synor RPC SDK for Ruby
|
||||||
|
#
|
||||||
|
# Blockchain data queries, transaction submission, and real-time subscriptions.
|
||||||
|
#
|
||||||
|
# @example Quick Start
|
||||||
|
# require 'synor_rpc'
|
||||||
|
#
|
||||||
|
# # Create client
|
||||||
|
# client = SynorRpc::Client.new(api_key: 'your-api-key')
|
||||||
|
#
|
||||||
|
# # Get latest block
|
||||||
|
# block = client.get_latest_block
|
||||||
|
# puts "Block height: #{block.height}"
|
||||||
|
#
|
||||||
|
# # Get transaction
|
||||||
|
# tx = client.get_transaction(txid)
|
||||||
|
# puts "Confirmations: #{tx.confirmations}"
|
||||||
|
#
|
||||||
|
# # Subscribe to new blocks
|
||||||
|
# client.subscribe_blocks do |block|
|
||||||
|
# puts "New block: #{block.height}"
|
||||||
|
# end
|
||||||
|
#
|
||||||
|
module SynorRpc
|
||||||
|
class Error < StandardError; end
|
||||||
|
class ApiError < Error
|
||||||
|
attr_reader :status_code
|
||||||
|
|
||||||
|
def initialize(message, status_code: nil)
|
||||||
|
super(message)
|
||||||
|
@status_code = status_code
|
||||||
|
end
|
||||||
|
end
|
||||||
|
class ClientClosedError < Error; end
|
||||||
|
end
|
||||||
384
sdk/ruby/lib/synor_rpc/client.rb
Normal file
384
sdk/ruby/lib/synor_rpc/client.rb
Normal file
|
|
@ -0,0 +1,384 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "faraday"
|
||||||
|
require "json"
|
||||||
|
|
||||||
|
module SynorRpc
|
||||||
|
# Synor RPC SDK Client
|
||||||
|
#
|
||||||
|
# @example
|
||||||
|
# client = SynorRpc::Client.new(api_key: 'your-api-key')
|
||||||
|
#
|
||||||
|
# # Get latest block
|
||||||
|
# block = client.get_latest_block
|
||||||
|
#
|
||||||
|
# # Get transaction
|
||||||
|
# tx = client.get_transaction(txid)
|
||||||
|
#
|
||||||
|
class Client
|
||||||
|
attr_reader :config
|
||||||
|
|
||||||
|
def initialize(api_key: nil, **options)
|
||||||
|
@config = Config.new(api_key: api_key, **options)
|
||||||
|
raise ArgumentError, "API key is required" unless @config.api_key
|
||||||
|
|
||||||
|
@conn = Faraday.new(url: @config.base_url) do |f|
|
||||||
|
f.request :json
|
||||||
|
f.response :json
|
||||||
|
f.options.timeout = @config.timeout
|
||||||
|
f.headers["Authorization"] = "Bearer #{@config.api_key}"
|
||||||
|
f.headers["X-SDK-Version"] = "ruby/#{VERSION}"
|
||||||
|
end
|
||||||
|
@closed = false
|
||||||
|
@subscriptions = {}
|
||||||
|
@ws = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# ==================== Block Operations ====================
|
||||||
|
|
||||||
|
# Get the latest block
|
||||||
|
#
|
||||||
|
# @return [Block]
|
||||||
|
def get_latest_block
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
response = with_retry { @conn.get("/blocks/latest") }
|
||||||
|
Block.from_hash(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get a block by hash or height
|
||||||
|
#
|
||||||
|
# @param hash_or_height [String, Integer]
|
||||||
|
# @return [Block]
|
||||||
|
def get_block(hash_or_height)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
response = with_retry { @conn.get("/blocks/#{hash_or_height}") }
|
||||||
|
Block.from_hash(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get a block header
|
||||||
|
#
|
||||||
|
# @param hash_or_height [String, Integer]
|
||||||
|
# @return [BlockHeader]
|
||||||
|
def get_block_header(hash_or_height)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
response = with_retry { @conn.get("/blocks/#{hash_or_height}/header") }
|
||||||
|
BlockHeader.from_hash(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get a range of blocks
|
||||||
|
#
|
||||||
|
# @param start_height [Integer]
|
||||||
|
# @param end_height [Integer]
|
||||||
|
# @return [Array<Block>]
|
||||||
|
def get_blocks(start_height, end_height)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
response = with_retry do
|
||||||
|
@conn.get("/blocks", start: start_height, end: end_height)
|
||||||
|
end
|
||||||
|
response.body.map { |b| Block.from_hash(b) }
|
||||||
|
end
|
||||||
|
|
||||||
|
# ==================== Transaction Operations ====================
|
||||||
|
|
||||||
|
# Get a transaction by ID
|
||||||
|
#
|
||||||
|
# @param txid [String]
|
||||||
|
# @return [Transaction]
|
||||||
|
def get_transaction(txid)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
response = with_retry { @conn.get("/transactions/#{txid}") }
|
||||||
|
Transaction.from_hash(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get raw transaction hex
|
||||||
|
#
|
||||||
|
# @param txid [String]
|
||||||
|
# @return [String]
|
||||||
|
def get_raw_transaction(txid)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
response = with_retry { @conn.get("/transactions/#{txid}/raw") }
|
||||||
|
response.body["hex"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Send a raw transaction
|
||||||
|
#
|
||||||
|
# @param hex [String]
|
||||||
|
# @return [SubmitResult]
|
||||||
|
def send_raw_transaction(hex)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
response = with_retry { @conn.post("/transactions/send", { hex: hex }) }
|
||||||
|
SubmitResult.from_hash(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Decode a raw transaction
|
||||||
|
#
|
||||||
|
# @param hex [String]
|
||||||
|
# @return [Transaction]
|
||||||
|
def decode_raw_transaction(hex)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
response = with_retry { @conn.post("/transactions/decode", { hex: hex }) }
|
||||||
|
Transaction.from_hash(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get transactions for an address
|
||||||
|
#
|
||||||
|
# @param address [String]
|
||||||
|
# @param limit [Integer]
|
||||||
|
# @param offset [Integer]
|
||||||
|
# @return [Array<Transaction>]
|
||||||
|
def get_address_transactions(address, limit: 20, offset: 0)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
response = with_retry do
|
||||||
|
@conn.get("/addresses/#{address}/transactions", limit: limit, offset: offset)
|
||||||
|
end
|
||||||
|
response.body.map { |t| Transaction.from_hash(t) }
|
||||||
|
end
|
||||||
|
|
||||||
|
# ==================== Fee Estimation ====================
|
||||||
|
|
||||||
|
# Estimate fee
|
||||||
|
#
|
||||||
|
# @param priority [String]
|
||||||
|
# @return [FeeEstimate]
|
||||||
|
def estimate_fee(priority: Priority::MEDIUM)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
response = with_retry { @conn.get("/fees/estimate", priority: priority) }
|
||||||
|
FeeEstimate.from_hash(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get all fee estimates
|
||||||
|
#
|
||||||
|
# @return [Hash<String, FeeEstimate>]
|
||||||
|
def get_all_fee_estimates
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
response = with_retry { @conn.get("/fees/estimates") }
|
||||||
|
response.body.to_h { |e| [e["priority"], FeeEstimate.from_hash(e)] }
|
||||||
|
end
|
||||||
|
|
||||||
|
# ==================== Chain Information ====================
|
||||||
|
|
||||||
|
# Get chain info
|
||||||
|
#
|
||||||
|
# @return [ChainInfo]
|
||||||
|
def get_chain_info
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
response = with_retry { @conn.get("/chain/info") }
|
||||||
|
ChainInfo.from_hash(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get mempool info
|
||||||
|
#
|
||||||
|
# @return [MempoolInfo]
|
||||||
|
def get_mempool_info
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
response = with_retry { @conn.get("/mempool/info") }
|
||||||
|
MempoolInfo.from_hash(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get mempool transaction IDs
|
||||||
|
#
|
||||||
|
# @param limit [Integer]
|
||||||
|
# @return [Array<String>]
|
||||||
|
def get_mempool_transactions(limit: 100)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
response = with_retry { @conn.get("/mempool/transactions", limit: limit) }
|
||||||
|
response.body
|
||||||
|
end
|
||||||
|
|
||||||
|
# ==================== Subscriptions ====================
|
||||||
|
|
||||||
|
# Subscribe to new blocks
|
||||||
|
#
|
||||||
|
# @yield [Block] Called for each new block
|
||||||
|
# @return [Subscription]
|
||||||
|
def subscribe_blocks(&block)
|
||||||
|
check_closed!
|
||||||
|
raise ArgumentError, "Block required" unless block_given?
|
||||||
|
|
||||||
|
ensure_websocket_connected
|
||||||
|
|
||||||
|
subscription_id = SecureRandom.uuid
|
||||||
|
@subscriptions[subscription_id] = {
|
||||||
|
channel: "blocks",
|
||||||
|
callback: ->(data) { block.call(Block.from_hash(data)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
send_ws_message({
|
||||||
|
type: "subscribe",
|
||||||
|
channel: "blocks",
|
||||||
|
subscription_id: subscription_id
|
||||||
|
})
|
||||||
|
|
||||||
|
Subscription.new(id: subscription_id, channel: "blocks") do
|
||||||
|
@subscriptions.delete(subscription_id)
|
||||||
|
send_ws_message({
|
||||||
|
type: "unsubscribe",
|
||||||
|
subscription_id: subscription_id
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Subscribe to address transactions
|
||||||
|
#
|
||||||
|
# @param address [String]
|
||||||
|
# @yield [Transaction] Called for each transaction
|
||||||
|
# @return [Subscription]
|
||||||
|
def subscribe_address(address, &block)
|
||||||
|
check_closed!
|
||||||
|
raise ArgumentError, "Block required" unless block_given?
|
||||||
|
|
||||||
|
ensure_websocket_connected
|
||||||
|
|
||||||
|
subscription_id = SecureRandom.uuid
|
||||||
|
@subscriptions[subscription_id] = {
|
||||||
|
channel: "address:#{address}",
|
||||||
|
callback: ->(data) { block.call(Transaction.from_hash(data)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
send_ws_message({
|
||||||
|
type: "subscribe",
|
||||||
|
channel: "address",
|
||||||
|
address: address,
|
||||||
|
subscription_id: subscription_id
|
||||||
|
})
|
||||||
|
|
||||||
|
Subscription.new(id: subscription_id, channel: "address:#{address}") do
|
||||||
|
@subscriptions.delete(subscription_id)
|
||||||
|
send_ws_message({
|
||||||
|
type: "unsubscribe",
|
||||||
|
subscription_id: subscription_id
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Subscribe to mempool transactions
|
||||||
|
#
|
||||||
|
# @yield [Transaction] Called for each transaction
|
||||||
|
# @return [Subscription]
|
||||||
|
def subscribe_mempool(&block)
|
||||||
|
check_closed!
|
||||||
|
raise ArgumentError, "Block required" unless block_given?
|
||||||
|
|
||||||
|
ensure_websocket_connected
|
||||||
|
|
||||||
|
subscription_id = SecureRandom.uuid
|
||||||
|
@subscriptions[subscription_id] = {
|
||||||
|
channel: "mempool",
|
||||||
|
callback: ->(data) { block.call(Transaction.from_hash(data)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
send_ws_message({
|
||||||
|
type: "subscribe",
|
||||||
|
channel: "mempool",
|
||||||
|
subscription_id: subscription_id
|
||||||
|
})
|
||||||
|
|
||||||
|
Subscription.new(id: subscription_id, channel: "mempool") do
|
||||||
|
@subscriptions.delete(subscription_id)
|
||||||
|
send_ws_message({
|
||||||
|
type: "unsubscribe",
|
||||||
|
subscription_id: subscription_id
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ==================== Lifecycle ====================
|
||||||
|
|
||||||
|
def close
|
||||||
|
@closed = true
|
||||||
|
@ws&.close
|
||||||
|
@conn.close if @conn.respond_to?(:close)
|
||||||
|
end
|
||||||
|
|
||||||
|
def closed?
|
||||||
|
@closed
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def check_closed!
|
||||||
|
raise ClientClosedError, "Client has been closed" if @closed
|
||||||
|
end
|
||||||
|
|
||||||
|
def with_retry
|
||||||
|
attempts = 0
|
||||||
|
begin
|
||||||
|
attempts += 1
|
||||||
|
response = yield
|
||||||
|
handle_response(response)
|
||||||
|
response
|
||||||
|
rescue Faraday::Error, ApiError => e
|
||||||
|
if attempts < @config.retries
|
||||||
|
sleep(attempts)
|
||||||
|
retry
|
||||||
|
end
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_response(response)
|
||||||
|
return if response.success?
|
||||||
|
|
||||||
|
error_message = response.body["error"] || response.body["message"] || "Unknown error"
|
||||||
|
raise ApiError.new(error_message, status_code: response.status)
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_websocket_connected
|
||||||
|
return if @ws && @ws_connected
|
||||||
|
|
||||||
|
require "websocket-client-simple"
|
||||||
|
|
||||||
|
@ws = WebSocket::Client::Simple.connect(@config.ws_url, headers: {
|
||||||
|
"Authorization" => "Bearer #{@config.api_key}"
|
||||||
|
})
|
||||||
|
|
||||||
|
@ws.on :message do |msg|
|
||||||
|
handle_ws_message(msg.data)
|
||||||
|
end
|
||||||
|
|
||||||
|
@ws.on :open do
|
||||||
|
@ws_connected = true
|
||||||
|
end
|
||||||
|
|
||||||
|
@ws.on :close do
|
||||||
|
@ws_connected = false
|
||||||
|
end
|
||||||
|
|
||||||
|
@ws.on :error do |e|
|
||||||
|
puts "WebSocket error: #{e.message}" if @config.debug
|
||||||
|
end
|
||||||
|
|
||||||
|
# Wait for connection
|
||||||
|
sleep(0.1) until @ws_connected || @closed
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_ws_message(message)
|
||||||
|
@ws&.send(JSON.generate(message))
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_ws_message(data)
|
||||||
|
message = JSON.parse(data)
|
||||||
|
subscription_id = message["subscription_id"]
|
||||||
|
|
||||||
|
if subscription_id && @subscriptions[subscription_id]
|
||||||
|
@subscriptions[subscription_id][:callback].call(message["data"])
|
||||||
|
end
|
||||||
|
rescue JSON::ParserError
|
||||||
|
# Skip malformed messages
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
343
sdk/ruby/lib/synor_rpc/types.rb
Normal file
343
sdk/ruby/lib/synor_rpc/types.rb
Normal file
|
|
@ -0,0 +1,343 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module SynorRpc
|
||||||
|
# Network environment
|
||||||
|
module Network
|
||||||
|
MAINNET = "mainnet"
|
||||||
|
TESTNET = "testnet"
|
||||||
|
DEVNET = "devnet"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Transaction priority
|
||||||
|
module Priority
|
||||||
|
LOW = "low"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
HIGH = "high"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Transaction status
|
||||||
|
module TransactionStatus
|
||||||
|
PENDING = "pending"
|
||||||
|
CONFIRMED = "confirmed"
|
||||||
|
FAILED = "failed"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
class Config
|
||||||
|
attr_reader :api_key, :base_url, :ws_url, :network, :timeout, :retries, :debug
|
||||||
|
|
||||||
|
def initialize(
|
||||||
|
api_key:,
|
||||||
|
base_url: "https://rpc.synor.io/v1",
|
||||||
|
ws_url: "wss://rpc.synor.io/v1/ws",
|
||||||
|
network: Network::MAINNET,
|
||||||
|
timeout: 30,
|
||||||
|
retries: 3,
|
||||||
|
debug: false
|
||||||
|
)
|
||||||
|
@api_key = api_key
|
||||||
|
@base_url = base_url
|
||||||
|
@ws_url = ws_url
|
||||||
|
@network = network
|
||||||
|
@timeout = timeout
|
||||||
|
@retries = retries
|
||||||
|
@debug = debug
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Block header
|
||||||
|
class BlockHeader
|
||||||
|
attr_reader :hash, :height, :previous_hash, :timestamp, :version,
|
||||||
|
:merkle_root, :nonce, :difficulty
|
||||||
|
|
||||||
|
def initialize(hash:, height:, previous_hash:, timestamp:, version:,
|
||||||
|
merkle_root:, nonce:, difficulty:)
|
||||||
|
@hash = hash
|
||||||
|
@height = height
|
||||||
|
@previous_hash = previous_hash
|
||||||
|
@timestamp = timestamp
|
||||||
|
@version = version
|
||||||
|
@merkle_root = merkle_root
|
||||||
|
@nonce = nonce
|
||||||
|
@difficulty = difficulty
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_hash(h)
|
||||||
|
new(
|
||||||
|
hash: h["hash"],
|
||||||
|
height: h["height"],
|
||||||
|
previous_hash: h["previous_hash"],
|
||||||
|
timestamp: h["timestamp"],
|
||||||
|
version: h["version"],
|
||||||
|
merkle_root: h["merkle_root"],
|
||||||
|
nonce: h["nonce"],
|
||||||
|
difficulty: h["difficulty"]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Script public key
|
||||||
|
class ScriptPubKey
|
||||||
|
attr_reader :asm, :hex, :type, :address
|
||||||
|
|
||||||
|
def initialize(asm:, hex:, type:, address: nil)
|
||||||
|
@asm = asm
|
||||||
|
@hex = hex
|
||||||
|
@type = type
|
||||||
|
@address = address
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_hash(h)
|
||||||
|
new(
|
||||||
|
asm: h["asm"],
|
||||||
|
hex: h["hex"],
|
||||||
|
type: h["type"],
|
||||||
|
address: h["address"]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Transaction input
|
||||||
|
class TransactionInput
|
||||||
|
attr_reader :txid, :vout, :script_sig, :sequence, :witness
|
||||||
|
|
||||||
|
def initialize(txid:, vout:, script_sig:, sequence:, witness: nil)
|
||||||
|
@txid = txid
|
||||||
|
@vout = vout
|
||||||
|
@script_sig = script_sig
|
||||||
|
@sequence = sequence
|
||||||
|
@witness = witness
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_hash(h)
|
||||||
|
new(
|
||||||
|
txid: h["txid"],
|
||||||
|
vout: h["vout"],
|
||||||
|
script_sig: h["script_sig"],
|
||||||
|
sequence: h["sequence"],
|
||||||
|
witness: h["witness"]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Transaction output
|
||||||
|
class TransactionOutput
|
||||||
|
attr_reader :value, :n, :script_pubkey
|
||||||
|
|
||||||
|
def initialize(value:, n:, script_pubkey:)
|
||||||
|
@value = value
|
||||||
|
@n = n
|
||||||
|
@script_pubkey = script_pubkey
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_hash(h)
|
||||||
|
new(
|
||||||
|
value: h["value"],
|
||||||
|
n: h["n"],
|
||||||
|
script_pubkey: ScriptPubKey.from_hash(h["script_pubkey"])
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Transaction
|
||||||
|
class Transaction
|
||||||
|
attr_reader :txid, :hash, :version, :size, :weight, :lock_time,
|
||||||
|
:inputs, :outputs, :fee, :confirmations,
|
||||||
|
:block_hash, :block_height, :timestamp, :status
|
||||||
|
|
||||||
|
def initialize(txid:, hash:, version:, size:, weight:, lock_time:,
|
||||||
|
inputs:, outputs:, fee:, confirmations:,
|
||||||
|
block_hash: nil, block_height: nil, timestamp: nil,
|
||||||
|
status: TransactionStatus::PENDING)
|
||||||
|
@txid = txid
|
||||||
|
@hash = hash
|
||||||
|
@version = version
|
||||||
|
@size = size
|
||||||
|
@weight = weight
|
||||||
|
@lock_time = lock_time
|
||||||
|
@inputs = inputs
|
||||||
|
@outputs = outputs
|
||||||
|
@fee = fee
|
||||||
|
@confirmations = confirmations
|
||||||
|
@block_hash = block_hash
|
||||||
|
@block_height = block_height
|
||||||
|
@timestamp = timestamp
|
||||||
|
@status = status
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_hash(h)
|
||||||
|
new(
|
||||||
|
txid: h["txid"],
|
||||||
|
hash: h["hash"],
|
||||||
|
version: h["version"],
|
||||||
|
size: h["size"],
|
||||||
|
weight: h["weight"],
|
||||||
|
lock_time: h["lock_time"],
|
||||||
|
inputs: (h["inputs"] || []).map { |i| TransactionInput.from_hash(i) },
|
||||||
|
outputs: (h["outputs"] || []).map { |o| TransactionOutput.from_hash(o) },
|
||||||
|
fee: h["fee"],
|
||||||
|
confirmations: h["confirmations"],
|
||||||
|
block_hash: h["block_hash"],
|
||||||
|
block_height: h["block_height"],
|
||||||
|
timestamp: h["timestamp"],
|
||||||
|
status: h["status"] || TransactionStatus::PENDING
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Block
|
||||||
|
class Block
|
||||||
|
attr_reader :hash, :height, :previous_hash, :timestamp, :version,
|
||||||
|
:merkle_root, :nonce, :difficulty, :transactions, :size, :weight
|
||||||
|
|
||||||
|
def initialize(hash:, height:, previous_hash:, timestamp:, version:,
|
||||||
|
merkle_root:, nonce:, difficulty:, transactions:, size:, weight:)
|
||||||
|
@hash = hash
|
||||||
|
@height = height
|
||||||
|
@previous_hash = previous_hash
|
||||||
|
@timestamp = timestamp
|
||||||
|
@version = version
|
||||||
|
@merkle_root = merkle_root
|
||||||
|
@nonce = nonce
|
||||||
|
@difficulty = difficulty
|
||||||
|
@transactions = transactions
|
||||||
|
@size = size
|
||||||
|
@weight = weight
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_hash(h)
|
||||||
|
new(
|
||||||
|
hash: h["hash"],
|
||||||
|
height: h["height"],
|
||||||
|
previous_hash: h["previous_hash"],
|
||||||
|
timestamp: h["timestamp"],
|
||||||
|
version: h["version"],
|
||||||
|
merkle_root: h["merkle_root"],
|
||||||
|
nonce: h["nonce"],
|
||||||
|
difficulty: h["difficulty"],
|
||||||
|
transactions: (h["transactions"] || []).map { |t| Transaction.from_hash(t) },
|
||||||
|
size: h["size"],
|
||||||
|
weight: h["weight"]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Chain info
|
||||||
|
class ChainInfo
|
||||||
|
attr_reader :chain, :blocks, :headers, :best_block_hash, :difficulty,
|
||||||
|
:median_time, :verification_progress, :chain_work, :pruned
|
||||||
|
|
||||||
|
def initialize(chain:, blocks:, headers:, best_block_hash:, difficulty:,
|
||||||
|
median_time:, verification_progress:, chain_work:, pruned:)
|
||||||
|
@chain = chain
|
||||||
|
@blocks = blocks
|
||||||
|
@headers = headers
|
||||||
|
@best_block_hash = best_block_hash
|
||||||
|
@difficulty = difficulty
|
||||||
|
@median_time = median_time
|
||||||
|
@verification_progress = verification_progress
|
||||||
|
@chain_work = chain_work
|
||||||
|
@pruned = pruned
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_hash(h)
|
||||||
|
new(
|
||||||
|
chain: h["chain"],
|
||||||
|
blocks: h["blocks"],
|
||||||
|
headers: h["headers"],
|
||||||
|
best_block_hash: h["best_block_hash"],
|
||||||
|
difficulty: h["difficulty"],
|
||||||
|
median_time: h["median_time"],
|
||||||
|
verification_progress: h["verification_progress"],
|
||||||
|
chain_work: h["chain_work"],
|
||||||
|
pruned: h["pruned"]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mempool info
|
||||||
|
class MempoolInfo
|
||||||
|
attr_reader :size, :bytes, :usage, :max_mempool, :mempool_min_fee, :min_relay_tx_fee
|
||||||
|
|
||||||
|
def initialize(size:, bytes:, usage:, max_mempool:, mempool_min_fee:, min_relay_tx_fee:)
|
||||||
|
@size = size
|
||||||
|
@bytes = bytes
|
||||||
|
@usage = usage
|
||||||
|
@max_mempool = max_mempool
|
||||||
|
@mempool_min_fee = mempool_min_fee
|
||||||
|
@min_relay_tx_fee = min_relay_tx_fee
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_hash(h)
|
||||||
|
new(
|
||||||
|
size: h["size"],
|
||||||
|
bytes: h["bytes"],
|
||||||
|
usage: h["usage"],
|
||||||
|
max_mempool: h["max_mempool"],
|
||||||
|
mempool_min_fee: h["mempool_min_fee"],
|
||||||
|
min_relay_tx_fee: h["min_relay_tx_fee"]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Fee estimate
|
||||||
|
class FeeEstimate
|
||||||
|
attr_reader :priority, :fee_rate, :estimated_blocks
|
||||||
|
|
||||||
|
def initialize(priority:, fee_rate:, estimated_blocks:)
|
||||||
|
@priority = priority
|
||||||
|
@fee_rate = fee_rate
|
||||||
|
@estimated_blocks = estimated_blocks
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_hash(h)
|
||||||
|
new(
|
||||||
|
priority: h["priority"],
|
||||||
|
fee_rate: h["fee_rate"],
|
||||||
|
estimated_blocks: h["estimated_blocks"]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Submit result
|
||||||
|
class SubmitResult
|
||||||
|
attr_reader :txid, :accepted, :reason
|
||||||
|
|
||||||
|
def initialize(txid:, accepted:, reason: nil)
|
||||||
|
@txid = txid
|
||||||
|
@accepted = accepted
|
||||||
|
@reason = reason
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_hash(h)
|
||||||
|
new(
|
||||||
|
txid: h["txid"],
|
||||||
|
accepted: h["accepted"],
|
||||||
|
reason: h["reason"]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Subscription handle
|
||||||
|
class Subscription
|
||||||
|
attr_reader :id, :channel
|
||||||
|
|
||||||
|
def initialize(id:, channel:, &on_cancel)
|
||||||
|
@id = id
|
||||||
|
@channel = channel
|
||||||
|
@on_cancel = on_cancel
|
||||||
|
@cancelled = false
|
||||||
|
end
|
||||||
|
|
||||||
|
def cancel
|
||||||
|
return if @cancelled
|
||||||
|
|
||||||
|
@on_cancel&.call
|
||||||
|
@cancelled = true
|
||||||
|
end
|
||||||
|
|
||||||
|
def cancelled?
|
||||||
|
@cancelled
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
5
sdk/ruby/lib/synor_rpc/version.rb
Normal file
5
sdk/ruby/lib/synor_rpc/version.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module SynorRpc
|
||||||
|
VERSION = "0.1.0"
|
||||||
|
end
|
||||||
40
sdk/ruby/lib/synor_storage.rb
Normal file
40
sdk/ruby/lib/synor_storage.rb
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require_relative "synor_storage/version"
|
||||||
|
require_relative "synor_storage/types"
|
||||||
|
require_relative "synor_storage/client"
|
||||||
|
|
||||||
|
# Synor Storage SDK for Ruby
|
||||||
|
#
|
||||||
|
# Decentralized storage operations including upload, download, pinning, and directory management.
|
||||||
|
#
|
||||||
|
# @example Quick Start
|
||||||
|
# require 'synor_storage'
|
||||||
|
#
|
||||||
|
# # Create client
|
||||||
|
# client = SynorStorage::Client.new(api_key: 'your-api-key')
|
||||||
|
#
|
||||||
|
# # Upload a file
|
||||||
|
# result = client.upload(File.read('document.pdf'), name: 'document.pdf')
|
||||||
|
# puts "CID: #{result.cid}"
|
||||||
|
#
|
||||||
|
# # Download a file
|
||||||
|
# data = client.download(result.cid)
|
||||||
|
# File.write('downloaded.pdf', data)
|
||||||
|
#
|
||||||
|
# # Get gateway URL
|
||||||
|
# url = client.get_gateway_url(result.cid)
|
||||||
|
# puts "URL: #{url}"
|
||||||
|
#
|
||||||
|
module SynorStorage
|
||||||
|
class Error < StandardError; end
|
||||||
|
class ApiError < Error
|
||||||
|
attr_reader :status_code
|
||||||
|
|
||||||
|
def initialize(message, status_code: nil)
|
||||||
|
super(message)
|
||||||
|
@status_code = status_code
|
||||||
|
end
|
||||||
|
end
|
||||||
|
class ClientClosedError < Error; end
|
||||||
|
end
|
||||||
317
sdk/ruby/lib/synor_storage/client.rb
Normal file
317
sdk/ruby/lib/synor_storage/client.rb
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "faraday"
|
||||||
|
require "faraday/multipart"
|
||||||
|
require "json"
|
||||||
|
require "base64"
|
||||||
|
|
||||||
|
module SynorStorage
|
||||||
|
# Synor Storage SDK Client
|
||||||
|
#
|
||||||
|
# @example
|
||||||
|
# client = SynorStorage::Client.new(api_key: 'your-api-key')
|
||||||
|
#
|
||||||
|
# # Upload file
|
||||||
|
# result = client.upload(data, name: 'file.txt')
|
||||||
|
#
|
||||||
|
# # Download file
|
||||||
|
# data = client.download(cid)
|
||||||
|
#
|
||||||
|
class Client
|
||||||
|
attr_reader :config
|
||||||
|
|
||||||
|
def initialize(api_key: nil, **options)
|
||||||
|
@config = Config.new(api_key: api_key, **options)
|
||||||
|
raise ArgumentError, "API key is required" unless @config.api_key
|
||||||
|
|
||||||
|
@conn = Faraday.new(url: @config.base_url) do |f|
|
||||||
|
f.request :multipart
|
||||||
|
f.request :url_encoded
|
||||||
|
f.response :json
|
||||||
|
f.options.timeout = @config.timeout
|
||||||
|
f.headers["Authorization"] = "Bearer #{@config.api_key}"
|
||||||
|
f.headers["X-SDK-Version"] = "ruby/#{VERSION}"
|
||||||
|
end
|
||||||
|
@closed = false
|
||||||
|
end
|
||||||
|
|
||||||
|
# ==================== Upload Operations ====================
|
||||||
|
|
||||||
|
# Upload data to storage
|
||||||
|
#
|
||||||
|
# @param data [String, IO] Data to upload
|
||||||
|
# @param options [UploadOptions, Hash] Upload options
|
||||||
|
# @return [UploadResponse]
|
||||||
|
def upload(data, name: nil, content_type: nil, pin: true, metadata: nil, wrap_with_directory: false)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
file: Faraday::Multipart::FilePart.new(
|
||||||
|
StringIO.new(data.is_a?(String) ? data : data.read),
|
||||||
|
content_type || "application/octet-stream",
|
||||||
|
name || "file"
|
||||||
|
),
|
||||||
|
pin: pin.to_s,
|
||||||
|
wrap_with_directory: wrap_with_directory.to_s
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata
|
||||||
|
metadata.each do |key, value|
|
||||||
|
payload["metadata[#{key}]"] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
response = with_retry { @conn.post("/upload", payload) }
|
||||||
|
UploadResponse.from_hash(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Upload a directory of files
|
||||||
|
#
|
||||||
|
# @param files [Array<FileEntry>] Files to upload
|
||||||
|
# @param directory_name [String, nil] Optional directory name
|
||||||
|
# @return [UploadResponse]
|
||||||
|
def upload_directory(files, directory_name: nil)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
payload = {}
|
||||||
|
files.each_with_index do |file, i|
|
||||||
|
payload["files[#{i}]"] = Faraday::Multipart::FilePart.new(
|
||||||
|
StringIO.new(file.content),
|
||||||
|
file.content_type || "application/octet-stream",
|
||||||
|
file.path
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
payload["directory_name"] = directory_name if directory_name
|
||||||
|
|
||||||
|
response = with_retry { @conn.post("/upload/directory", payload) }
|
||||||
|
UploadResponse.from_hash(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
# ==================== Download Operations ====================
|
||||||
|
|
||||||
|
# Download content by CID
|
||||||
|
#
|
||||||
|
# @param cid [String]
|
||||||
|
# @return [String] Binary data
|
||||||
|
def download(cid)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
response = with_retry do
|
||||||
|
@conn.get("/download/#{cid}") do |req|
|
||||||
|
req.headers["Accept"] = "*/*"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
response.body
|
||||||
|
end
|
||||||
|
|
||||||
|
# Download content with streaming
|
||||||
|
#
|
||||||
|
# @param cid [String]
|
||||||
|
# @yield [String] Chunks of data
|
||||||
|
def download_stream(cid, &block)
|
||||||
|
check_closed!
|
||||||
|
raise ArgumentError, "Block required for streaming" unless block_given?
|
||||||
|
|
||||||
|
@conn.get("/download/#{cid}") do |req|
|
||||||
|
req.headers["Accept"] = "*/*"
|
||||||
|
req.options.on_data = proc do |chunk, _|
|
||||||
|
block.call(chunk)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get gateway URL for a CID
|
||||||
|
#
|
||||||
|
# @param cid [String]
|
||||||
|
# @param path [String, nil] Optional path within the CID
|
||||||
|
# @return [String]
|
||||||
|
def get_gateway_url(cid, path: nil)
|
||||||
|
url = "#{@config.gateway}/ipfs/#{cid}"
|
||||||
|
url += "/#{path.gsub(%r{^/}, '')}" if path
|
||||||
|
url
|
||||||
|
end
|
||||||
|
|
||||||
|
# ==================== Pinning Operations ====================
|
||||||
|
|
||||||
|
# Pin content by CID
|
||||||
|
#
|
||||||
|
# @param request [PinRequest, Hash] Pin request
|
||||||
|
# @return [Pin]
|
||||||
|
def pin(request)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
body = request.is_a?(PinRequest) ? request.to_h : request
|
||||||
|
response = with_retry { post_json("/pins", body) }
|
||||||
|
Pin.from_hash(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Unpin content
|
||||||
|
#
|
||||||
|
# @param cid [String]
|
||||||
|
def unpin(cid)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
with_retry { @conn.delete("/pins/#{cid}") }
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get pin status
|
||||||
|
#
|
||||||
|
# @param cid [String]
|
||||||
|
# @return [Pin]
|
||||||
|
def get_pin_status(cid)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
response = with_retry { @conn.get("/pins/#{cid}") }
|
||||||
|
Pin.from_hash(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
# List pins
|
||||||
|
#
|
||||||
|
# @param status [String, nil] Filter by status
|
||||||
|
# @param limit [Integer]
|
||||||
|
# @param offset [Integer]
|
||||||
|
# @return [Array<Pin>]
|
||||||
|
def list_pins(status: nil, limit: 20, offset: 0)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
params = { limit: limit, offset: offset }
|
||||||
|
params[:status] = status if status
|
||||||
|
|
||||||
|
response = with_retry { @conn.get("/pins", params) }
|
||||||
|
response.body.map { |p| Pin.from_hash(p) }
|
||||||
|
end
|
||||||
|
|
||||||
|
# ==================== CAR File Operations ====================
|
||||||
|
|
||||||
|
# Create a CAR file from entries
|
||||||
|
#
|
||||||
|
# @param entries [Array<FileEntry>]
|
||||||
|
# @return [CarFile]
|
||||||
|
def create_car(entries)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
body = entries.map do |e|
|
||||||
|
{
|
||||||
|
path: e.path,
|
||||||
|
content: Base64.strict_encode64(e.content),
|
||||||
|
content_type: e.content_type
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
response = with_retry { post_json("/car/create", body) }
|
||||||
|
CarFile.from_hash(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Import a CAR file
|
||||||
|
#
|
||||||
|
# @param car_data [String] Binary CAR data
|
||||||
|
# @return [Array<String>] CIDs
|
||||||
|
def import_car(car_data)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
response = with_retry do
|
||||||
|
@conn.post("/car/import") do |req|
|
||||||
|
req.headers["Content-Type"] = "application/vnd.ipld.car"
|
||||||
|
req.body = car_data
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
response.body["cids"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Export content as a CAR file
|
||||||
|
#
|
||||||
|
# @param cid [String]
|
||||||
|
# @return [String] Binary CAR data
|
||||||
|
def export_car(cid)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
response = with_retry do
|
||||||
|
@conn.get("/car/export/#{cid}") do |req|
|
||||||
|
req.headers["Accept"] = "application/vnd.ipld.car"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
response.body
|
||||||
|
end
|
||||||
|
|
||||||
|
# ==================== Directory Operations ====================
|
||||||
|
|
||||||
|
# List directory contents
|
||||||
|
#
|
||||||
|
# @param cid [String]
|
||||||
|
# @return [Array<DirectoryEntry>]
|
||||||
|
def list_directory(cid)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
response = with_retry { @conn.get("/directory/#{cid}") }
|
||||||
|
response.body.map { |e| DirectoryEntry.from_hash(e) }
|
||||||
|
end
|
||||||
|
|
||||||
|
# ==================== Statistics ====================
|
||||||
|
|
||||||
|
# Get storage statistics
|
||||||
|
#
|
||||||
|
# @return [StorageStats]
|
||||||
|
def get_stats
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
response = with_retry { @conn.get("/stats") }
|
||||||
|
StorageStats.from_hash(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
# ==================== Lifecycle ====================
|
||||||
|
|
||||||
|
def close
|
||||||
|
@closed = true
|
||||||
|
@conn.close if @conn.respond_to?(:close)
|
||||||
|
end
|
||||||
|
|
||||||
|
def closed?
|
||||||
|
@closed
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def check_closed!
|
||||||
|
raise ClientClosedError, "Client has been closed" if @closed
|
||||||
|
end
|
||||||
|
|
||||||
|
def post_json(path, body)
|
||||||
|
@conn.post(path) do |req|
|
||||||
|
req.headers["Content-Type"] = "application/json"
|
||||||
|
req.body = JSON.generate(body)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def with_retry
|
||||||
|
attempts = 0
|
||||||
|
begin
|
||||||
|
attempts += 1
|
||||||
|
response = yield
|
||||||
|
handle_response(response)
|
||||||
|
response
|
||||||
|
rescue Faraday::Error, ApiError => e
|
||||||
|
if attempts < @config.retries
|
||||||
|
sleep(attempts)
|
||||||
|
retry
|
||||||
|
end
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_response(response)
|
||||||
|
return if response.success?
|
||||||
|
|
||||||
|
error_message = if response.body.is_a?(Hash)
|
||||||
|
response.body["error"] || response.body["message"] || "Unknown error"
|
||||||
|
else
|
||||||
|
"HTTP #{response.status}"
|
||||||
|
end
|
||||||
|
raise ApiError.new(error_message, status_code: response.status)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
209
sdk/ruby/lib/synor_storage/types.rb
Normal file
209
sdk/ruby/lib/synor_storage/types.rb
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module SynorStorage
|
||||||
|
# Network environment
|
||||||
|
module Network
|
||||||
|
MAINNET = "mainnet"
|
||||||
|
TESTNET = "testnet"
|
||||||
|
DEVNET = "devnet"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Pin status
|
||||||
|
module PinStatus
|
||||||
|
QUEUED = "queued"
|
||||||
|
PINNING = "pinning"
|
||||||
|
PINNED = "pinned"
|
||||||
|
FAILED = "failed"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
class Config
|
||||||
|
attr_reader :api_key, :base_url, :gateway, :network, :timeout, :retries, :debug
|
||||||
|
|
||||||
|
def initialize(
|
||||||
|
api_key:,
|
||||||
|
base_url: "https://storage.synor.io/v1",
|
||||||
|
gateway: "https://gateway.synor.io",
|
||||||
|
network: Network::MAINNET,
|
||||||
|
timeout: 300,
|
||||||
|
retries: 3,
|
||||||
|
debug: false
|
||||||
|
)
|
||||||
|
@api_key = api_key
|
||||||
|
@base_url = base_url
|
||||||
|
@gateway = gateway
|
||||||
|
@network = network
|
||||||
|
@timeout = timeout
|
||||||
|
@retries = retries
|
||||||
|
@debug = debug
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Upload options
|
||||||
|
class UploadOptions
|
||||||
|
attr_reader :name, :content_type, :pin, :metadata, :wrap_with_directory
|
||||||
|
|
||||||
|
def initialize(
|
||||||
|
name: nil,
|
||||||
|
content_type: nil,
|
||||||
|
pin: true,
|
||||||
|
metadata: nil,
|
||||||
|
wrap_with_directory: false
|
||||||
|
)
|
||||||
|
@name = name
|
||||||
|
@content_type = content_type
|
||||||
|
@pin = pin
|
||||||
|
@metadata = metadata
|
||||||
|
@wrap_with_directory = wrap_with_directory
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Upload response
|
||||||
|
class UploadResponse
|
||||||
|
attr_reader :cid, :size, :name, :pinned
|
||||||
|
|
||||||
|
def initialize(cid:, size:, name: nil, pinned: true)
|
||||||
|
@cid = cid
|
||||||
|
@size = size
|
||||||
|
@name = name
|
||||||
|
@pinned = pinned
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_hash(h)
|
||||||
|
new(
|
||||||
|
cid: h["cid"],
|
||||||
|
size: h["size"],
|
||||||
|
name: h["name"],
|
||||||
|
pinned: h["pinned"] || true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Pin request
|
||||||
|
class PinRequest
|
||||||
|
attr_reader :cid, :name, :metadata, :origins
|
||||||
|
|
||||||
|
def initialize(cid:, name: nil, metadata: nil, origins: nil)
|
||||||
|
@cid = cid
|
||||||
|
@name = name
|
||||||
|
@metadata = metadata
|
||||||
|
@origins = origins
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_h
|
||||||
|
{
|
||||||
|
cid: @cid,
|
||||||
|
name: @name,
|
||||||
|
metadata: @metadata,
|
||||||
|
origins: @origins
|
||||||
|
}.compact
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Pin information
|
||||||
|
class Pin
|
||||||
|
attr_reader :cid, :name, :status, :size, :created_at, :metadata
|
||||||
|
|
||||||
|
def initialize(cid:, name:, status:, size:, created_at:, metadata: nil)
|
||||||
|
@cid = cid
|
||||||
|
@name = name
|
||||||
|
@status = status
|
||||||
|
@size = size
|
||||||
|
@created_at = created_at
|
||||||
|
@metadata = metadata
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_hash(h)
|
||||||
|
new(
|
||||||
|
cid: h["cid"],
|
||||||
|
name: h["name"],
|
||||||
|
status: h["status"],
|
||||||
|
size: h["size"],
|
||||||
|
created_at: h["created_at"],
|
||||||
|
metadata: h["metadata"]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# File entry for directory operations
|
||||||
|
class FileEntry
|
||||||
|
attr_reader :path, :content, :content_type
|
||||||
|
|
||||||
|
def initialize(path:, content:, content_type: nil)
|
||||||
|
@path = path
|
||||||
|
@content = content
|
||||||
|
@content_type = content_type
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Directory entry
|
||||||
|
class DirectoryEntry
|
||||||
|
attr_reader :name, :cid, :size, :type
|
||||||
|
|
||||||
|
def initialize(name:, cid:, size:, type:)
|
||||||
|
@name = name
|
||||||
|
@cid = cid
|
||||||
|
@size = size
|
||||||
|
@type = type
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_hash(h)
|
||||||
|
new(
|
||||||
|
name: h["name"],
|
||||||
|
cid: h["cid"],
|
||||||
|
size: h["size"],
|
||||||
|
type: h["type"]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def file?
|
||||||
|
@type == "file"
|
||||||
|
end
|
||||||
|
|
||||||
|
def directory?
|
||||||
|
@type == "directory"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# CAR file
|
||||||
|
class CarFile
|
||||||
|
attr_reader :root_cid, :data, :size
|
||||||
|
|
||||||
|
def initialize(root_cid:, data:, size:)
|
||||||
|
@root_cid = root_cid
|
||||||
|
@data = data
|
||||||
|
@size = size
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_hash(h)
|
||||||
|
new(
|
||||||
|
root_cid: h["root_cid"],
|
||||||
|
data: h["data"],
|
||||||
|
size: h["size"]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Storage statistics
|
||||||
|
class StorageStats
|
||||||
|
attr_reader :total_size, :pin_count, :bandwidth_used, :storage_limit, :bandwidth_limit
|
||||||
|
|
||||||
|
def initialize(total_size:, pin_count:, bandwidth_used:, storage_limit:, bandwidth_limit:)
|
||||||
|
@total_size = total_size
|
||||||
|
@pin_count = pin_count
|
||||||
|
@bandwidth_used = bandwidth_used
|
||||||
|
@storage_limit = storage_limit
|
||||||
|
@bandwidth_limit = bandwidth_limit
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_hash(h)
|
||||||
|
new(
|
||||||
|
total_size: h["total_size"],
|
||||||
|
pin_count: h["pin_count"],
|
||||||
|
bandwidth_used: h["bandwidth_used"],
|
||||||
|
storage_limit: h["storage_limit"],
|
||||||
|
bandwidth_limit: h["bandwidth_limit"]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
5
sdk/ruby/lib/synor_storage/version.rb
Normal file
5
sdk/ruby/lib/synor_storage/version.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module SynorStorage
|
||||||
|
VERSION = "0.1.0"
|
||||||
|
end
|
||||||
41
sdk/ruby/lib/synor_wallet.rb
Normal file
41
sdk/ruby/lib/synor_wallet.rb
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require_relative "synor_wallet/version"
|
||||||
|
require_relative "synor_wallet/types"
|
||||||
|
require_relative "synor_wallet/client"
|
||||||
|
|
||||||
|
# Synor Wallet SDK for Ruby
|
||||||
|
#
|
||||||
|
# Key management, transaction signing, and balance queries for the Synor blockchain.
|
||||||
|
#
|
||||||
|
# @example Quick Start
|
||||||
|
# require 'synor_wallet'
|
||||||
|
#
|
||||||
|
# # Create client
|
||||||
|
# client = SynorWallet::Client.new(api_key: 'your-api-key')
|
||||||
|
#
|
||||||
|
# # Create a new wallet
|
||||||
|
# result = client.create_wallet
|
||||||
|
# puts "Wallet ID: #{result.wallet.id}"
|
||||||
|
# puts "Mnemonic: #{result.mnemonic}"
|
||||||
|
#
|
||||||
|
# # Get balance
|
||||||
|
# balance = client.get_balance(result.wallet.addresses.first.address)
|
||||||
|
# puts "Balance: #{balance.total}"
|
||||||
|
#
|
||||||
|
# # Sign a message
|
||||||
|
# signature = client.sign_message(result.wallet.id, "Hello, Synor!")
|
||||||
|
# puts "Signature: #{signature.signature}"
|
||||||
|
#
|
||||||
|
module SynorWallet
|
||||||
|
class Error < StandardError; end
|
||||||
|
class ApiError < Error
|
||||||
|
attr_reader :status_code
|
||||||
|
|
||||||
|
def initialize(message, status_code: nil)
|
||||||
|
super(message)
|
||||||
|
@status_code = status_code
|
||||||
|
end
|
||||||
|
end
|
||||||
|
class ClientClosedError < Error; end
|
||||||
|
end
|
||||||
244
sdk/ruby/lib/synor_wallet/client.rb
Normal file
244
sdk/ruby/lib/synor_wallet/client.rb
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "faraday"
|
||||||
|
require "json"
|
||||||
|
|
||||||
|
module SynorWallet
|
||||||
|
# Synor Wallet SDK Client
|
||||||
|
#
|
||||||
|
# @example
|
||||||
|
# client = SynorWallet::Client.new(api_key: 'your-api-key')
|
||||||
|
#
|
||||||
|
# # Create wallet
|
||||||
|
# result = client.create_wallet
|
||||||
|
# puts result.mnemonic
|
||||||
|
#
|
||||||
|
# # Get balance
|
||||||
|
# balance = client.get_balance(address)
|
||||||
|
#
|
||||||
|
class Client
|
||||||
|
attr_reader :config
|
||||||
|
|
||||||
|
def initialize(api_key: nil, **options)
|
||||||
|
@config = Config.new(api_key: api_key, **options)
|
||||||
|
raise ArgumentError, "API key is required" unless @config.api_key
|
||||||
|
|
||||||
|
@conn = Faraday.new(url: @config.base_url) do |f|
|
||||||
|
f.request :json
|
||||||
|
f.response :json
|
||||||
|
f.options.timeout = @config.timeout
|
||||||
|
f.headers["Authorization"] = "Bearer #{@config.api_key}"
|
||||||
|
f.headers["X-SDK-Version"] = "ruby/#{VERSION}"
|
||||||
|
end
|
||||||
|
@closed = false
|
||||||
|
end
|
||||||
|
|
||||||
|
# ==================== Wallet Operations ====================
|
||||||
|
|
||||||
|
# Create a new wallet
|
||||||
|
#
|
||||||
|
# @param type [String] Wallet type (standard, multisig, hardware)
|
||||||
|
# @return [CreateWalletResult]
|
||||||
|
def create_wallet(type: WalletType::STANDARD)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
body = {
|
||||||
|
type: type,
|
||||||
|
network: @config.network
|
||||||
|
}
|
||||||
|
|
||||||
|
response = with_retry { @conn.post("/wallets", body) }
|
||||||
|
CreateWalletResult.from_hash(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Import wallet from mnemonic
|
||||||
|
#
|
||||||
|
# @param mnemonic [String] 12 or 24 word mnemonic phrase
|
||||||
|
# @param passphrase [String, nil] Optional passphrase
|
||||||
|
# @return [Wallet]
|
||||||
|
def import_wallet(mnemonic, passphrase: nil)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
body = {
|
||||||
|
mnemonic: mnemonic,
|
||||||
|
passphrase: passphrase,
|
||||||
|
network: @config.network
|
||||||
|
}
|
||||||
|
|
||||||
|
response = with_retry { @conn.post("/wallets/import", body) }
|
||||||
|
Wallet.from_hash(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get wallet by ID
|
||||||
|
#
|
||||||
|
# @param wallet_id [String]
|
||||||
|
# @return [Wallet]
|
||||||
|
def get_wallet(wallet_id)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
response = with_retry { @conn.get("/wallets/#{wallet_id}") }
|
||||||
|
Wallet.from_hash(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate a new address
|
||||||
|
#
|
||||||
|
# @param wallet_id [String]
|
||||||
|
# @param is_change [Boolean]
|
||||||
|
# @return [Address]
|
||||||
|
def generate_address(wallet_id, is_change: false)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
body = { is_change: is_change }
|
||||||
|
response = with_retry { @conn.post("/wallets/#{wallet_id}/addresses", body) }
|
||||||
|
Address.from_hash(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get stealth address
|
||||||
|
#
|
||||||
|
# @param wallet_id [String]
|
||||||
|
# @return [StealthAddress]
|
||||||
|
def get_stealth_address(wallet_id)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
response = with_retry { @conn.get("/wallets/#{wallet_id}/stealth-address") }
|
||||||
|
StealthAddress.from_hash(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
# ==================== Signing Operations ====================
|
||||||
|
|
||||||
|
# Sign a transaction
|
||||||
|
#
|
||||||
|
# @param wallet_id [String]
|
||||||
|
# @param transaction [Transaction]
|
||||||
|
# @return [SignedTransaction]
|
||||||
|
def sign_transaction(wallet_id, transaction)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
body = {
|
||||||
|
wallet_id: wallet_id,
|
||||||
|
transaction: transaction.to_h
|
||||||
|
}
|
||||||
|
|
||||||
|
response = with_retry { @conn.post("/transactions/sign", body) }
|
||||||
|
SignedTransaction.from_hash(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sign a message
|
||||||
|
#
|
||||||
|
# @param wallet_id [String]
|
||||||
|
# @param message [String]
|
||||||
|
# @param address_index [Integer]
|
||||||
|
# @return [Signature]
|
||||||
|
def sign_message(wallet_id, message, address_index: 0)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
body = {
|
||||||
|
wallet_id: wallet_id,
|
||||||
|
message: message,
|
||||||
|
address_index: address_index
|
||||||
|
}
|
||||||
|
|
||||||
|
response = with_retry { @conn.post("/messages/sign", body) }
|
||||||
|
Signature.from_hash(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Verify a message signature
|
||||||
|
#
|
||||||
|
# @param message [String]
|
||||||
|
# @param signature [String]
|
||||||
|
# @param address [String]
|
||||||
|
# @return [Boolean]
|
||||||
|
def verify_message(message, signature, address)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
body = {
|
||||||
|
message: message,
|
||||||
|
signature: signature,
|
||||||
|
address: address
|
||||||
|
}
|
||||||
|
|
||||||
|
response = with_retry { @conn.post("/messages/verify", body) }
|
||||||
|
response.body["valid"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# ==================== Balance & UTXOs ====================
|
||||||
|
|
||||||
|
# Get balance for an address
|
||||||
|
#
|
||||||
|
# @param address [String]
|
||||||
|
# @return [Balance]
|
||||||
|
def get_balance(address)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
response = with_retry { @conn.get("/addresses/#{address}/balance") }
|
||||||
|
Balance.from_hash(response.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get UTXOs for an address
|
||||||
|
#
|
||||||
|
# @param address [String]
|
||||||
|
# @param min_confirmations [Integer]
|
||||||
|
# @return [Array<UTXO>]
|
||||||
|
def get_utxos(address, min_confirmations: 1)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
response = with_retry do
|
||||||
|
@conn.get("/addresses/#{address}/utxos", min_confirmations: min_confirmations)
|
||||||
|
end
|
||||||
|
response.body.map { |u| UTXO.from_hash(u) }
|
||||||
|
end
|
||||||
|
|
||||||
|
# ==================== Fee Estimation ====================
|
||||||
|
|
||||||
|
# Estimate fee
|
||||||
|
#
|
||||||
|
# @param priority [String]
|
||||||
|
# @return [Integer] Fee per byte in satoshis
|
||||||
|
def estimate_fee(priority: Priority::MEDIUM)
|
||||||
|
check_closed!
|
||||||
|
|
||||||
|
response = with_retry { @conn.get("/fees/estimate", priority: priority) }
|
||||||
|
response.body["fee_per_byte"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# ==================== Lifecycle ====================
|
||||||
|
|
||||||
|
def close
|
||||||
|
@closed = true
|
||||||
|
@conn.close if @conn.respond_to?(:close)
|
||||||
|
end
|
||||||
|
|
||||||
|
def closed?
|
||||||
|
@closed
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def check_closed!
|
||||||
|
raise ClientClosedError, "Client has been closed" if @closed
|
||||||
|
end
|
||||||
|
|
||||||
|
def with_retry
|
||||||
|
attempts = 0
|
||||||
|
begin
|
||||||
|
attempts += 1
|
||||||
|
response = yield
|
||||||
|
handle_response(response)
|
||||||
|
response
|
||||||
|
rescue Faraday::Error, ApiError => e
|
||||||
|
if attempts < @config.retries
|
||||||
|
sleep(attempts)
|
||||||
|
retry
|
||||||
|
end
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_response(response)
|
||||||
|
return if response.success?
|
||||||
|
|
||||||
|
error_message = response.body["error"] || response.body["message"] || "Unknown error"
|
||||||
|
raise ApiError.new(error_message, status_code: response.status)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
269
sdk/ruby/lib/synor_wallet/types.rb
Normal file
269
sdk/ruby/lib/synor_wallet/types.rb
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module SynorWallet
|
||||||
|
# Network environment
|
||||||
|
module Network
|
||||||
|
MAINNET = "mainnet"
|
||||||
|
TESTNET = "testnet"
|
||||||
|
DEVNET = "devnet"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Wallet types
|
||||||
|
module WalletType
|
||||||
|
STANDARD = "standard"
|
||||||
|
MULTISIG = "multisig"
|
||||||
|
HARDWARE = "hardware"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Address types
|
||||||
|
module AddressType
|
||||||
|
P2PKH = "p2pkh"
|
||||||
|
P2SH = "p2sh"
|
||||||
|
P2WPKH = "p2wpkh"
|
||||||
|
P2WSH = "p2wsh"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Transaction priority
|
||||||
|
module Priority
|
||||||
|
LOW = "low"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
HIGH = "high"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
class Config
|
||||||
|
attr_reader :api_key, :base_url, :network, :timeout, :retries, :debug
|
||||||
|
|
||||||
|
def initialize(
|
||||||
|
api_key:,
|
||||||
|
base_url: "https://wallet.synor.io/v1",
|
||||||
|
network: Network::MAINNET,
|
||||||
|
timeout: 30,
|
||||||
|
retries: 3,
|
||||||
|
debug: false
|
||||||
|
)
|
||||||
|
@api_key = api_key
|
||||||
|
@base_url = base_url
|
||||||
|
@network = network
|
||||||
|
@timeout = timeout
|
||||||
|
@retries = retries
|
||||||
|
@debug = debug
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Address information
|
||||||
|
class Address
|
||||||
|
attr_reader :address, :type, :index, :is_change, :public_key
|
||||||
|
|
||||||
|
def initialize(address:, type:, index:, is_change: false, public_key: nil)
|
||||||
|
@address = address
|
||||||
|
@type = type
|
||||||
|
@index = index
|
||||||
|
@is_change = is_change
|
||||||
|
@public_key = public_key
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_hash(h)
|
||||||
|
new(
|
||||||
|
address: h["address"],
|
||||||
|
type: h["type"],
|
||||||
|
index: h["index"],
|
||||||
|
is_change: h["is_change"] || false,
|
||||||
|
public_key: h["public_key"]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Stealth address
|
||||||
|
class StealthAddress
|
||||||
|
attr_reader :scan_public_key, :spend_public_key, :address
|
||||||
|
|
||||||
|
def initialize(scan_public_key:, spend_public_key:, address:)
|
||||||
|
@scan_public_key = scan_public_key
|
||||||
|
@spend_public_key = spend_public_key
|
||||||
|
@address = address
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_hash(h)
|
||||||
|
new(
|
||||||
|
scan_public_key: h["scan_public_key"],
|
||||||
|
spend_public_key: h["spend_public_key"],
|
||||||
|
address: h["address"]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Wallet information
|
||||||
|
class Wallet
|
||||||
|
attr_reader :id, :name, :type, :network, :addresses, :created_at
|
||||||
|
|
||||||
|
def initialize(id:, name:, type:, network:, addresses:, created_at:)
|
||||||
|
@id = id
|
||||||
|
@name = name
|
||||||
|
@type = type
|
||||||
|
@network = network
|
||||||
|
@addresses = addresses
|
||||||
|
@created_at = created_at
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_hash(h)
|
||||||
|
new(
|
||||||
|
id: h["id"],
|
||||||
|
name: h["name"],
|
||||||
|
type: h["type"],
|
||||||
|
network: h["network"],
|
||||||
|
addresses: (h["addresses"] || []).map { |a| Address.from_hash(a) },
|
||||||
|
created_at: h["created_at"]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create wallet result
|
||||||
|
class CreateWalletResult
|
||||||
|
attr_reader :wallet, :mnemonic
|
||||||
|
|
||||||
|
def initialize(wallet:, mnemonic:)
|
||||||
|
@wallet = wallet
|
||||||
|
@mnemonic = mnemonic
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_hash(h)
|
||||||
|
new(
|
||||||
|
wallet: Wallet.from_hash(h["wallet"]),
|
||||||
|
mnemonic: h["mnemonic"]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Balance information
|
||||||
|
class Balance
|
||||||
|
attr_reader :confirmed, :unconfirmed, :total
|
||||||
|
|
||||||
|
def initialize(confirmed:, unconfirmed:, total:)
|
||||||
|
@confirmed = confirmed
|
||||||
|
@unconfirmed = unconfirmed
|
||||||
|
@total = total
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_hash(h)
|
||||||
|
new(
|
||||||
|
confirmed: h["confirmed"],
|
||||||
|
unconfirmed: h["unconfirmed"],
|
||||||
|
total: h["total"]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# UTXO
|
||||||
|
class UTXO
|
||||||
|
attr_reader :txid, :vout, :value, :confirmations, :script_pubkey
|
||||||
|
|
||||||
|
def initialize(txid:, vout:, value:, confirmations:, script_pubkey:)
|
||||||
|
@txid = txid
|
||||||
|
@vout = vout
|
||||||
|
@value = value
|
||||||
|
@confirmations = confirmations
|
||||||
|
@script_pubkey = script_pubkey
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_hash(h)
|
||||||
|
new(
|
||||||
|
txid: h["txid"],
|
||||||
|
vout: h["vout"],
|
||||||
|
value: h["value"],
|
||||||
|
confirmations: h["confirmations"],
|
||||||
|
script_pubkey: h["script_pubkey"]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Transaction input
|
||||||
|
class TransactionInput
|
||||||
|
attr_reader :txid, :vout, :sequence
|
||||||
|
|
||||||
|
def initialize(txid:, vout:, sequence: 0xFFFFFFFF)
|
||||||
|
@txid = txid
|
||||||
|
@vout = vout
|
||||||
|
@sequence = sequence
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_h
|
||||||
|
{ txid: @txid, vout: @vout, sequence: @sequence }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Transaction output
|
||||||
|
class TransactionOutput
|
||||||
|
attr_reader :address, :value
|
||||||
|
|
||||||
|
def initialize(address:, value:)
|
||||||
|
@address = address
|
||||||
|
@value = value
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_h
|
||||||
|
{ address: @address, value: @value }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Transaction
|
||||||
|
class Transaction
|
||||||
|
attr_reader :inputs, :outputs, :lock_time
|
||||||
|
|
||||||
|
def initialize(inputs:, outputs:, lock_time: 0)
|
||||||
|
@inputs = inputs
|
||||||
|
@outputs = outputs
|
||||||
|
@lock_time = lock_time
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_h
|
||||||
|
{
|
||||||
|
inputs: @inputs.map(&:to_h),
|
||||||
|
outputs: @outputs.map(&:to_h),
|
||||||
|
lock_time: @lock_time
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Signed transaction
|
||||||
|
class SignedTransaction
|
||||||
|
attr_reader :txid, :raw, :size, :weight, :fee
|
||||||
|
|
||||||
|
def initialize(txid:, raw:, size:, weight:, fee:)
|
||||||
|
@txid = txid
|
||||||
|
@raw = raw
|
||||||
|
@size = size
|
||||||
|
@weight = weight
|
||||||
|
@fee = fee
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_hash(h)
|
||||||
|
new(
|
||||||
|
txid: h["txid"],
|
||||||
|
raw: h["raw"],
|
||||||
|
size: h["size"],
|
||||||
|
weight: h["weight"],
|
||||||
|
fee: h["fee"]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Signature
|
||||||
|
class Signature
|
||||||
|
attr_reader :signature, :recovery_id, :address
|
||||||
|
|
||||||
|
def initialize(signature:, recovery_id:, address:)
|
||||||
|
@signature = signature
|
||||||
|
@recovery_id = recovery_id
|
||||||
|
@address = address
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.from_hash(h)
|
||||||
|
new(
|
||||||
|
signature: h["signature"],
|
||||||
|
recovery_id: h["recovery_id"],
|
||||||
|
address: h["address"]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
5
sdk/ruby/lib/synor_wallet/version.rb
Normal file
5
sdk/ruby/lib/synor_wallet/version.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module SynorWallet
|
||||||
|
VERSION = "0.1.0"
|
||||||
|
end
|
||||||
23
sdk/ruby/synor_rpc.gemspec
Normal file
23
sdk/ruby/synor_rpc.gemspec
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
Gem::Specification.new do |spec|
|
||||||
|
spec.name = "synor_rpc"
|
||||||
|
spec.version = "0.1.0"
|
||||||
|
spec.authors = ["Synor"]
|
||||||
|
spec.email = ["sdk@synor.io"]
|
||||||
|
|
||||||
|
spec.summary = "Ruby SDK for Synor RPC - Blockchain Data Queries"
|
||||||
|
spec.description = "Blockchain data queries, transaction submission, and real-time subscriptions via WebSocket."
|
||||||
|
spec.homepage = "https://github.com/synor/synor-rpc-ruby"
|
||||||
|
spec.license = "MIT"
|
||||||
|
spec.required_ruby_version = ">= 3.0"
|
||||||
|
|
||||||
|
spec.files = Dir["lib/synor_rpc.rb", "lib/synor_rpc/**/*", "LICENSE", "README.md"]
|
||||||
|
spec.require_paths = ["lib"]
|
||||||
|
|
||||||
|
spec.add_dependency "faraday", "~> 2.0"
|
||||||
|
spec.add_dependency "websocket-client-simple", "~> 0.6"
|
||||||
|
|
||||||
|
spec.add_development_dependency "bundler", "~> 2.0"
|
||||||
|
spec.add_development_dependency "rake", "~> 13.0"
|
||||||
|
spec.add_development_dependency "rspec", "~> 3.0"
|
||||||
|
spec.add_development_dependency "webmock", "~> 3.0"
|
||||||
|
end
|
||||||
23
sdk/ruby/synor_storage.gemspec
Normal file
23
sdk/ruby/synor_storage.gemspec
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
Gem::Specification.new do |spec|
|
||||||
|
spec.name = "synor_storage"
|
||||||
|
spec.version = "0.1.0"
|
||||||
|
spec.authors = ["Synor"]
|
||||||
|
spec.email = ["sdk@synor.io"]
|
||||||
|
|
||||||
|
spec.summary = "Ruby SDK for Synor Storage - Decentralized Storage"
|
||||||
|
spec.description = "Decentralized storage operations including upload, download, pinning, and directory management."
|
||||||
|
spec.homepage = "https://github.com/synor/synor-storage-ruby"
|
||||||
|
spec.license = "MIT"
|
||||||
|
spec.required_ruby_version = ">= 3.0"
|
||||||
|
|
||||||
|
spec.files = Dir["lib/synor_storage.rb", "lib/synor_storage/**/*", "LICENSE", "README.md"]
|
||||||
|
spec.require_paths = ["lib"]
|
||||||
|
|
||||||
|
spec.add_dependency "faraday", "~> 2.0"
|
||||||
|
spec.add_dependency "faraday-multipart", "~> 1.0"
|
||||||
|
|
||||||
|
spec.add_development_dependency "bundler", "~> 2.0"
|
||||||
|
spec.add_development_dependency "rake", "~> 13.0"
|
||||||
|
spec.add_development_dependency "rspec", "~> 3.0"
|
||||||
|
spec.add_development_dependency "webmock", "~> 3.0"
|
||||||
|
end
|
||||||
23
sdk/ruby/synor_wallet.gemspec
Normal file
23
sdk/ruby/synor_wallet.gemspec
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
Gem::Specification.new do |spec|
|
||||||
|
spec.name = "synor_wallet"
|
||||||
|
spec.version = "0.1.0"
|
||||||
|
spec.authors = ["Synor"]
|
||||||
|
spec.email = ["sdk@synor.io"]
|
||||||
|
|
||||||
|
spec.summary = "Ruby SDK for Synor Wallet - Blockchain Key Management"
|
||||||
|
spec.description = "Key management, transaction signing, and balance queries for the Synor blockchain."
|
||||||
|
spec.homepage = "https://github.com/synor/synor-wallet-ruby"
|
||||||
|
spec.license = "MIT"
|
||||||
|
spec.required_ruby_version = ">= 3.0"
|
||||||
|
|
||||||
|
spec.files = Dir["lib/synor_wallet.rb", "lib/synor_wallet/**/*", "LICENSE", "README.md"]
|
||||||
|
spec.require_paths = ["lib"]
|
||||||
|
|
||||||
|
spec.add_dependency "faraday", "~> 2.0"
|
||||||
|
spec.add_dependency "faraday-multipart", "~> 1.0"
|
||||||
|
|
||||||
|
spec.add_development_dependency "bundler", "~> 2.0"
|
||||||
|
spec.add_development_dependency "rake", "~> 13.0"
|
||||||
|
spec.add_development_dependency "rspec", "~> 3.0"
|
||||||
|
spec.add_development_dependency "webmock", "~> 3.0"
|
||||||
|
end
|
||||||
554
sdk/rust/src/bridge/client.rs
Normal file
554
sdk/rust/src/bridge/client.rs
Normal file
|
|
@ -0,0 +1,554 @@
|
||||||
|
//! Bridge client implementation
|
||||||
|
|
||||||
|
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
|
||||||
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use super::error::{BridgeError, Result};
|
||||||
|
use super::types::*;
|
||||||
|
|
||||||
|
/// Synor Bridge Client
|
||||||
|
pub struct BridgeClient {
|
||||||
|
config: BridgeConfig,
|
||||||
|
http_client: reqwest::Client,
|
||||||
|
closed: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BridgeClient {
|
||||||
|
/// Create a new bridge client
|
||||||
|
pub fn new(config: BridgeConfig) -> Self {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
AUTHORIZATION,
|
||||||
|
HeaderValue::from_str(&format!("Bearer {}", config.api_key)).unwrap(),
|
||||||
|
);
|
||||||
|
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||||
|
headers.insert("X-SDK-Version", HeaderValue::from_static("rust/0.1.0"));
|
||||||
|
|
||||||
|
let http_client = reqwest::Client::builder()
|
||||||
|
.default_headers(headers)
|
||||||
|
.timeout(Duration::from_secs(config.timeout_secs))
|
||||||
|
.build()
|
||||||
|
.expect("Failed to create HTTP client");
|
||||||
|
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
http_client,
|
||||||
|
closed: Arc::new(AtomicBool::new(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Chain Operations ====================
|
||||||
|
|
||||||
|
/// Get all supported chains
|
||||||
|
pub async fn get_supported_chains(&self) -> Result<Vec<Chain>> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Response {
|
||||||
|
chains: Vec<Chain>,
|
||||||
|
}
|
||||||
|
let resp: Response = self.request("GET", "/chains", Option::<()>::None).await?;
|
||||||
|
Ok(resp.chains)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get chain by ID
|
||||||
|
pub async fn get_chain(&self, chain_id: ChainId) -> Result<Chain> {
|
||||||
|
self.request("GET", &format!("/chains/{:?}", chain_id).to_lowercase(), Option::<()>::None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if chain is supported
|
||||||
|
pub async fn is_chain_supported(&self, chain_id: ChainId) -> bool {
|
||||||
|
match self.get_chain(chain_id).await {
|
||||||
|
Ok(chain) => chain.supported,
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Asset Operations ====================
|
||||||
|
|
||||||
|
/// Get supported assets for a chain
|
||||||
|
pub async fn get_supported_assets(&self, chain_id: ChainId) -> Result<Vec<Asset>> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Response {
|
||||||
|
assets: Vec<Asset>,
|
||||||
|
}
|
||||||
|
let resp: Response = self.request(
|
||||||
|
"GET",
|
||||||
|
&format!("/chains/{:?}/assets", chain_id).to_lowercase(),
|
||||||
|
Option::<()>::None,
|
||||||
|
).await?;
|
||||||
|
Ok(resp.assets)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get asset by ID
|
||||||
|
pub async fn get_asset(&self, asset_id: &str) -> Result<Asset> {
|
||||||
|
self.request("GET", &format!("/assets/{}", urlencoding::encode(asset_id)), Option::<()>::None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get wrapped asset mapping
|
||||||
|
pub async fn get_wrapped_asset(&self, original_asset_id: &str, target_chain: ChainId) -> Result<WrappedAsset> {
|
||||||
|
self.request(
|
||||||
|
"GET",
|
||||||
|
&format!("/assets/{}/wrapped/{:?}", urlencoding::encode(original_asset_id), target_chain).to_lowercase(),
|
||||||
|
Option::<()>::None,
|
||||||
|
).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all wrapped assets for a chain
|
||||||
|
pub async fn get_wrapped_assets(&self, chain_id: ChainId) -> Result<Vec<WrappedAsset>> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Response {
|
||||||
|
assets: Vec<WrappedAsset>,
|
||||||
|
}
|
||||||
|
let resp: Response = self.request(
|
||||||
|
"GET",
|
||||||
|
&format!("/chains/{:?}/wrapped", chain_id).to_lowercase(),
|
||||||
|
Option::<()>::None,
|
||||||
|
).await?;
|
||||||
|
Ok(resp.assets)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Fee & Rate Operations ====================
|
||||||
|
|
||||||
|
/// Estimate bridge fee
|
||||||
|
pub async fn estimate_fee(
|
||||||
|
&self,
|
||||||
|
asset: &str,
|
||||||
|
amount: &str,
|
||||||
|
source_chain: ChainId,
|
||||||
|
target_chain: ChainId,
|
||||||
|
) -> Result<FeeEstimate> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Request {
|
||||||
|
asset: String,
|
||||||
|
amount: String,
|
||||||
|
#[serde(rename = "sourceChain")]
|
||||||
|
source_chain: ChainId,
|
||||||
|
#[serde(rename = "targetChain")]
|
||||||
|
target_chain: ChainId,
|
||||||
|
}
|
||||||
|
self.request("POST", "/fees/estimate", Some(Request {
|
||||||
|
asset: asset.to_string(),
|
||||||
|
amount: amount.to_string(),
|
||||||
|
source_chain,
|
||||||
|
target_chain,
|
||||||
|
})).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get exchange rate between assets
|
||||||
|
pub async fn get_exchange_rate(&self, from_asset: &str, to_asset: &str) -> Result<ExchangeRate> {
|
||||||
|
self.request(
|
||||||
|
"GET",
|
||||||
|
&format!("/rates/{}/{}", urlencoding::encode(from_asset), urlencoding::encode(to_asset)),
|
||||||
|
Option::<()>::None,
|
||||||
|
).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Lock-Mint Flow ====================
|
||||||
|
|
||||||
|
/// Lock assets on source chain
|
||||||
|
pub async fn lock(
|
||||||
|
&self,
|
||||||
|
asset: &str,
|
||||||
|
amount: &str,
|
||||||
|
target_chain: ChainId,
|
||||||
|
options: Option<LockOptions>,
|
||||||
|
) -> Result<LockReceipt> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Request {
|
||||||
|
asset: String,
|
||||||
|
amount: String,
|
||||||
|
#[serde(rename = "targetChain")]
|
||||||
|
target_chain: ChainId,
|
||||||
|
#[serde(flatten)]
|
||||||
|
options: Option<LockOptions>,
|
||||||
|
}
|
||||||
|
self.request("POST", "/transfers/lock", Some(Request {
|
||||||
|
asset: asset.to_string(),
|
||||||
|
amount: amount.to_string(),
|
||||||
|
target_chain,
|
||||||
|
options,
|
||||||
|
})).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get lock proof for minting
|
||||||
|
pub async fn get_lock_proof(&self, lock_receipt_id: &str) -> Result<LockProof> {
|
||||||
|
self.request(
|
||||||
|
"GET",
|
||||||
|
&format!("/transfers/lock/{}/proof", urlencoding::encode(lock_receipt_id)),
|
||||||
|
Option::<()>::None,
|
||||||
|
).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait for lock proof to be ready
|
||||||
|
pub async fn wait_for_lock_proof(
|
||||||
|
&self,
|
||||||
|
lock_receipt_id: &str,
|
||||||
|
poll_interval: Option<Duration>,
|
||||||
|
max_wait: Option<Duration>,
|
||||||
|
) -> Result<LockProof> {
|
||||||
|
let poll_interval = poll_interval.unwrap_or(Duration::from_secs(5));
|
||||||
|
let max_wait = max_wait.unwrap_or(Duration::from_secs(600));
|
||||||
|
let deadline = std::time::Instant::now() + max_wait;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
return Err(BridgeError::new("Timeout waiting for lock proof")
|
||||||
|
.with_code("CONFIRMATIONS_PENDING"));
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.get_lock_proof(lock_receipt_id).await {
|
||||||
|
Ok(proof) => return Ok(proof),
|
||||||
|
Err(e) if e.is_confirmations_pending() => {
|
||||||
|
tokio::time::sleep(poll_interval).await;
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint wrapped tokens on target chain
|
||||||
|
pub async fn mint(
|
||||||
|
&self,
|
||||||
|
proof: &LockProof,
|
||||||
|
target_address: &str,
|
||||||
|
options: Option<MintOptions>,
|
||||||
|
) -> Result<SignedTransaction> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Request<'a> {
|
||||||
|
proof: &'a LockProof,
|
||||||
|
#[serde(rename = "targetAddress")]
|
||||||
|
target_address: String,
|
||||||
|
#[serde(flatten)]
|
||||||
|
options: Option<MintOptions>,
|
||||||
|
}
|
||||||
|
self.request("POST", "/transfers/mint", Some(Request {
|
||||||
|
proof,
|
||||||
|
target_address: target_address.to_string(),
|
||||||
|
options,
|
||||||
|
})).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Burn-Unlock Flow ====================
|
||||||
|
|
||||||
|
/// Burn wrapped tokens
|
||||||
|
pub async fn burn(
|
||||||
|
&self,
|
||||||
|
wrapped_asset: &str,
|
||||||
|
amount: &str,
|
||||||
|
options: Option<BurnOptions>,
|
||||||
|
) -> Result<BurnReceipt> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Request {
|
||||||
|
#[serde(rename = "wrappedAsset")]
|
||||||
|
wrapped_asset: String,
|
||||||
|
amount: String,
|
||||||
|
#[serde(flatten)]
|
||||||
|
options: Option<BurnOptions>,
|
||||||
|
}
|
||||||
|
self.request("POST", "/transfers/burn", Some(Request {
|
||||||
|
wrapped_asset: wrapped_asset.to_string(),
|
||||||
|
amount: amount.to_string(),
|
||||||
|
options,
|
||||||
|
})).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get burn proof for unlocking
|
||||||
|
pub async fn get_burn_proof(&self, burn_receipt_id: &str) -> Result<BurnProof> {
|
||||||
|
self.request(
|
||||||
|
"GET",
|
||||||
|
&format!("/transfers/burn/{}/proof", urlencoding::encode(burn_receipt_id)),
|
||||||
|
Option::<()>::None,
|
||||||
|
).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait for burn proof to be ready
|
||||||
|
pub async fn wait_for_burn_proof(
|
||||||
|
&self,
|
||||||
|
burn_receipt_id: &str,
|
||||||
|
poll_interval: Option<Duration>,
|
||||||
|
max_wait: Option<Duration>,
|
||||||
|
) -> Result<BurnProof> {
|
||||||
|
let poll_interval = poll_interval.unwrap_or(Duration::from_secs(5));
|
||||||
|
let max_wait = max_wait.unwrap_or(Duration::from_secs(600));
|
||||||
|
let deadline = std::time::Instant::now() + max_wait;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
return Err(BridgeError::new("Timeout waiting for burn proof")
|
||||||
|
.with_code("CONFIRMATIONS_PENDING"));
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.get_burn_proof(burn_receipt_id).await {
|
||||||
|
Ok(proof) => return Ok(proof),
|
||||||
|
Err(e) if e.is_confirmations_pending() => {
|
||||||
|
tokio::time::sleep(poll_interval).await;
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unlock original tokens
|
||||||
|
pub async fn unlock(&self, proof: &BurnProof, options: Option<UnlockOptions>) -> Result<SignedTransaction> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Request<'a> {
|
||||||
|
proof: &'a BurnProof,
|
||||||
|
#[serde(flatten)]
|
||||||
|
options: Option<UnlockOptions>,
|
||||||
|
}
|
||||||
|
self.request("POST", "/transfers/unlock", Some(Request { proof, options })).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Transfer Management ====================
|
||||||
|
|
||||||
|
/// Get transfer by ID
|
||||||
|
pub async fn get_transfer(&self, transfer_id: &str) -> Result<Transfer> {
|
||||||
|
self.request(
|
||||||
|
"GET",
|
||||||
|
&format!("/transfers/{}", urlencoding::encode(transfer_id)),
|
||||||
|
Option::<()>::None,
|
||||||
|
).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get transfer status
|
||||||
|
pub async fn get_transfer_status(&self, transfer_id: &str) -> Result<TransferStatus> {
|
||||||
|
let transfer = self.get_transfer(transfer_id).await?;
|
||||||
|
Ok(transfer.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List transfers
|
||||||
|
pub async fn list_transfers(&self, filter: Option<TransferFilter>) -> Result<Vec<Transfer>> {
|
||||||
|
let mut params = vec![];
|
||||||
|
if let Some(f) = &filter {
|
||||||
|
if let Some(status) = f.status {
|
||||||
|
params.push(format!("status={:?}", status).to_lowercase());
|
||||||
|
}
|
||||||
|
if let Some(chain) = f.source_chain {
|
||||||
|
params.push(format!("sourceChain={:?}", chain).to_lowercase());
|
||||||
|
}
|
||||||
|
if let Some(chain) = f.target_chain {
|
||||||
|
params.push(format!("targetChain={:?}", chain).to_lowercase());
|
||||||
|
}
|
||||||
|
if let Some(ref asset) = f.asset {
|
||||||
|
params.push(format!("asset={}", urlencoding::encode(asset)));
|
||||||
|
}
|
||||||
|
if let Some(ref sender) = f.sender {
|
||||||
|
params.push(format!("sender={}", urlencoding::encode(sender)));
|
||||||
|
}
|
||||||
|
if let Some(ref recipient) = f.recipient {
|
||||||
|
params.push(format!("recipient={}", urlencoding::encode(recipient)));
|
||||||
|
}
|
||||||
|
if let Some(from_date) = f.from_date {
|
||||||
|
params.push(format!("fromDate={}", from_date));
|
||||||
|
}
|
||||||
|
if let Some(to_date) = f.to_date {
|
||||||
|
params.push(format!("toDate={}", to_date));
|
||||||
|
}
|
||||||
|
if let Some(limit) = f.limit {
|
||||||
|
params.push(format!("limit={}", limit));
|
||||||
|
}
|
||||||
|
if let Some(offset) = f.offset {
|
||||||
|
params.push(format!("offset={}", offset));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = if params.is_empty() {
|
||||||
|
"/transfers".to_string()
|
||||||
|
} else {
|
||||||
|
format!("/transfers?{}", params.join("&"))
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Response {
|
||||||
|
transfers: Vec<Transfer>,
|
||||||
|
}
|
||||||
|
let resp: Response = self.request("GET", &path, Option::<()>::None).await?;
|
||||||
|
Ok(resp.transfers)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait for transfer to complete
|
||||||
|
pub async fn wait_for_transfer(
|
||||||
|
&self,
|
||||||
|
transfer_id: &str,
|
||||||
|
poll_interval: Option<Duration>,
|
||||||
|
max_wait: Option<Duration>,
|
||||||
|
) -> Result<Transfer> {
|
||||||
|
let poll_interval = poll_interval.unwrap_or(Duration::from_secs(10));
|
||||||
|
let max_wait = max_wait.unwrap_or(Duration::from_secs(1800));
|
||||||
|
let deadline = std::time::Instant::now() + max_wait;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
return Err(BridgeError::new("Timeout waiting for transfer completion"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let transfer = self.get_transfer(transfer_id).await?;
|
||||||
|
match transfer.status {
|
||||||
|
TransferStatus::Completed | TransferStatus::Failed | TransferStatus::Refunded => {
|
||||||
|
return Ok(transfer);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
tokio::time::sleep(poll_interval).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Convenience Methods ====================
|
||||||
|
|
||||||
|
/// Execute complete lock-mint transfer
|
||||||
|
pub async fn bridge_to(
|
||||||
|
&self,
|
||||||
|
asset: &str,
|
||||||
|
amount: &str,
|
||||||
|
target_chain: ChainId,
|
||||||
|
target_address: &str,
|
||||||
|
lock_options: Option<LockOptions>,
|
||||||
|
mint_options: Option<MintOptions>,
|
||||||
|
) -> Result<Transfer> {
|
||||||
|
// Lock on source chain
|
||||||
|
let lock_receipt = self.lock(asset, amount, target_chain, lock_options).await?;
|
||||||
|
|
||||||
|
if self.config.debug {
|
||||||
|
eprintln!("Locked: {}, waiting for confirmations...", lock_receipt.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for proof
|
||||||
|
let proof = self.wait_for_lock_proof(&lock_receipt.id, None, None).await?;
|
||||||
|
|
||||||
|
if self.config.debug {
|
||||||
|
eprintln!("Proof ready, minting on {:?}...", target_chain);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint on target chain
|
||||||
|
self.mint(&proof, target_address, mint_options).await?;
|
||||||
|
|
||||||
|
// Return final transfer status
|
||||||
|
self.wait_for_transfer(&lock_receipt.id, None, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute complete burn-unlock transfer
|
||||||
|
pub async fn bridge_back(
|
||||||
|
&self,
|
||||||
|
wrapped_asset: &str,
|
||||||
|
amount: &str,
|
||||||
|
burn_options: Option<BurnOptions>,
|
||||||
|
unlock_options: Option<UnlockOptions>,
|
||||||
|
) -> Result<Transfer> {
|
||||||
|
// Burn wrapped tokens
|
||||||
|
let burn_receipt = self.burn(wrapped_asset, amount, burn_options).await?;
|
||||||
|
|
||||||
|
if self.config.debug {
|
||||||
|
eprintln!("Burned: {}, waiting for confirmations...", burn_receipt.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for proof
|
||||||
|
let proof = self.wait_for_burn_proof(&burn_receipt.id, None, None).await?;
|
||||||
|
|
||||||
|
if self.config.debug {
|
||||||
|
eprintln!("Proof ready, unlocking on {:?}...", burn_receipt.target_chain);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock on original chain
|
||||||
|
self.unlock(&proof, unlock_options).await?;
|
||||||
|
|
||||||
|
// Return final transfer status
|
||||||
|
self.wait_for_transfer(&burn_receipt.id, None, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Lifecycle ====================
|
||||||
|
|
||||||
|
/// Close the client
|
||||||
|
pub fn close(&self) {
|
||||||
|
self.closed.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if client is closed
|
||||||
|
pub fn is_closed(&self) -> bool {
|
||||||
|
self.closed.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Health check
|
||||||
|
pub async fn health_check(&self) -> bool {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Response {
|
||||||
|
status: String,
|
||||||
|
}
|
||||||
|
match self.request::<_, Response>("GET", "/health", Option::<()>::None).await {
|
||||||
|
Ok(resp) => resp.status == "healthy",
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request<B: Serialize, R: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
body: Option<B>,
|
||||||
|
) -> Result<R> {
|
||||||
|
if self.is_closed() {
|
||||||
|
return Err(BridgeError::new("Client has been closed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut last_error = None;
|
||||||
|
|
||||||
|
for attempt in 0..self.config.retries {
|
||||||
|
match self.do_request(method, path, &body).await {
|
||||||
|
Ok(result) => return Ok(result),
|
||||||
|
Err(e) => {
|
||||||
|
if self.config.debug {
|
||||||
|
eprintln!("Attempt {} failed: {}", attempt + 1, e);
|
||||||
|
}
|
||||||
|
last_error = Some(e);
|
||||||
|
if attempt < self.config.retries - 1 {
|
||||||
|
tokio::time::sleep(Duration::from_secs((attempt + 1) as u64)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(last_error.unwrap_or_else(|| BridgeError::new("Unknown error after retries")))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn do_request<B: Serialize, R: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
body: &Option<B>,
|
||||||
|
) -> Result<R> {
|
||||||
|
let url = format!("{}{}", self.config.endpoint, path);
|
||||||
|
|
||||||
|
let mut request = match method {
|
||||||
|
"GET" => self.http_client.get(&url),
|
||||||
|
"POST" => self.http_client.post(&url),
|
||||||
|
"PUT" => self.http_client.put(&url),
|
||||||
|
"PATCH" => self.http_client.patch(&url),
|
||||||
|
"DELETE" => self.http_client.delete(&url),
|
||||||
|
_ => return Err(BridgeError::new(format!("Unknown method: {}", method))),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(b) = body {
|
||||||
|
request = request.json(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request.send().await?;
|
||||||
|
let status = response.status();
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let error_body: serde_json::Value = response.json().await.unwrap_or_default();
|
||||||
|
let message = error_body["message"]
|
||||||
|
.as_str()
|
||||||
|
.or_else(|| error_body["error"].as_str())
|
||||||
|
.unwrap_or(&format!("HTTP {}", status.as_u16()));
|
||||||
|
|
||||||
|
return Err(BridgeError::new(message)
|
||||||
|
.with_status(status.as_u16())
|
||||||
|
.with_code(error_body["code"].as_str().unwrap_or("").to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = response.json().await?;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
58
sdk/rust/src/bridge/error.rs
Normal file
58
sdk/rust/src/bridge/error.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
//! Bridge error types
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// Bridge error
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct BridgeError {
|
||||||
|
pub message: String,
|
||||||
|
pub code: Option<String>,
|
||||||
|
pub status_code: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BridgeError {
|
||||||
|
pub fn new(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
message: message.into(),
|
||||||
|
code: None,
|
||||||
|
status_code: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_code(mut self, code: impl Into<String>) -> Self {
|
||||||
|
self.code = Some(code.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_status(mut self, status: u16) -> Self {
|
||||||
|
self.status_code = Some(status);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_confirmations_pending(&self) -> bool {
|
||||||
|
self.code.as_deref() == Some("CONFIRMATIONS_PENDING")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for BridgeError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for BridgeError {}
|
||||||
|
|
||||||
|
impl From<reqwest::Error> for BridgeError {
|
||||||
|
fn from(err: reqwest::Error) -> Self {
|
||||||
|
Self::new(err.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<serde_json::Error> for BridgeError {
|
||||||
|
fn from(err: serde_json::Error) -> Self {
|
||||||
|
Self::new(err.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result type alias
|
||||||
|
pub type Result<T> = std::result::Result<T, BridgeError>;
|
||||||
11
sdk/rust/src/bridge/mod.rs
Normal file
11
sdk/rust/src/bridge/mod.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
//! Synor Bridge SDK for Rust
|
||||||
|
//!
|
||||||
|
//! Cross-chain asset transfers with lock-mint and burn-unlock patterns.
|
||||||
|
|
||||||
|
mod types;
|
||||||
|
mod error;
|
||||||
|
mod client;
|
||||||
|
|
||||||
|
pub use types::*;
|
||||||
|
pub use error::*;
|
||||||
|
pub use client::*;
|
||||||
368
sdk/rust/src/bridge/types.rs
Normal file
368
sdk/rust/src/bridge/types.rs
Normal file
|
|
@ -0,0 +1,368 @@
|
||||||
|
//! Bridge types
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
// ==================== Chain Types ====================
|
||||||
|
|
||||||
|
/// Supported blockchain networks
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ChainId {
|
||||||
|
Synor,
|
||||||
|
Ethereum,
|
||||||
|
Polygon,
|
||||||
|
Arbitrum,
|
||||||
|
Optimism,
|
||||||
|
#[serde(rename = "bsc")]
|
||||||
|
BSC,
|
||||||
|
Avalanche,
|
||||||
|
Solana,
|
||||||
|
Cosmos,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Native currency info
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct NativeCurrency {
|
||||||
|
pub name: String,
|
||||||
|
pub symbol: String,
|
||||||
|
pub decimals: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chain information
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Chain {
|
||||||
|
pub id: ChainId,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "chainId")]
|
||||||
|
pub chain_id: u64,
|
||||||
|
#[serde(rename = "rpcUrl")]
|
||||||
|
pub rpc_url: String,
|
||||||
|
#[serde(rename = "explorerUrl")]
|
||||||
|
pub explorer_url: String,
|
||||||
|
#[serde(rename = "nativeCurrency")]
|
||||||
|
pub native_currency: NativeCurrency,
|
||||||
|
pub confirmations: u32,
|
||||||
|
#[serde(rename = "estimatedBlockTime")]
|
||||||
|
pub estimated_block_time: u32,
|
||||||
|
pub supported: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Asset Types ====================
|
||||||
|
|
||||||
|
/// Asset type
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum AssetType {
|
||||||
|
Native,
|
||||||
|
#[serde(rename = "erc20")]
|
||||||
|
ERC20,
|
||||||
|
#[serde(rename = "erc721")]
|
||||||
|
ERC721,
|
||||||
|
#[serde(rename = "erc1155")]
|
||||||
|
ERC1155,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asset information
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Asset {
|
||||||
|
pub id: String,
|
||||||
|
pub symbol: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub asset_type: AssetType,
|
||||||
|
pub chain: ChainId,
|
||||||
|
#[serde(rename = "contractAddress", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub contract_address: Option<String>,
|
||||||
|
pub decimals: u8,
|
||||||
|
#[serde(rename = "logoUrl", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub logo_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub verified: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrapped asset mapping
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WrappedAsset {
|
||||||
|
#[serde(rename = "originalAsset")]
|
||||||
|
pub original_asset: Asset,
|
||||||
|
#[serde(rename = "wrappedAsset")]
|
||||||
|
pub wrapped_asset: Asset,
|
||||||
|
pub chain: ChainId,
|
||||||
|
#[serde(rename = "bridgeContract")]
|
||||||
|
pub bridge_contract: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Transfer Types ====================
|
||||||
|
|
||||||
|
/// Transfer status
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum TransferStatus {
|
||||||
|
Pending,
|
||||||
|
Locked,
|
||||||
|
Confirming,
|
||||||
|
Minting,
|
||||||
|
Completed,
|
||||||
|
Failed,
|
||||||
|
Refunded,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transfer direction
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TransferDirection {
|
||||||
|
LockMint,
|
||||||
|
BurnUnlock,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validator signature
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ValidatorSignature {
|
||||||
|
pub validator: String,
|
||||||
|
pub signature: String,
|
||||||
|
pub timestamp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lock receipt
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LockReceipt {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "txHash")]
|
||||||
|
pub tx_hash: String,
|
||||||
|
#[serde(rename = "sourceChain")]
|
||||||
|
pub source_chain: ChainId,
|
||||||
|
#[serde(rename = "targetChain")]
|
||||||
|
pub target_chain: ChainId,
|
||||||
|
pub asset: Asset,
|
||||||
|
pub amount: String,
|
||||||
|
pub sender: String,
|
||||||
|
pub recipient: String,
|
||||||
|
#[serde(rename = "lockTimestamp")]
|
||||||
|
pub lock_timestamp: i64,
|
||||||
|
pub confirmations: u32,
|
||||||
|
#[serde(rename = "requiredConfirmations")]
|
||||||
|
pub required_confirmations: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lock proof for minting
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LockProof {
|
||||||
|
#[serde(rename = "lockReceipt")]
|
||||||
|
pub lock_receipt: LockReceipt,
|
||||||
|
#[serde(rename = "merkleProof")]
|
||||||
|
pub merkle_proof: Vec<String>,
|
||||||
|
#[serde(rename = "blockHeader")]
|
||||||
|
pub block_header: String,
|
||||||
|
pub signatures: Vec<ValidatorSignature>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Burn receipt
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BurnReceipt {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "txHash")]
|
||||||
|
pub tx_hash: String,
|
||||||
|
#[serde(rename = "sourceChain")]
|
||||||
|
pub source_chain: ChainId,
|
||||||
|
#[serde(rename = "targetChain")]
|
||||||
|
pub target_chain: ChainId,
|
||||||
|
#[serde(rename = "wrappedAsset")]
|
||||||
|
pub wrapped_asset: Asset,
|
||||||
|
#[serde(rename = "originalAsset")]
|
||||||
|
pub original_asset: Asset,
|
||||||
|
pub amount: String,
|
||||||
|
pub sender: String,
|
||||||
|
pub recipient: String,
|
||||||
|
#[serde(rename = "burnTimestamp")]
|
||||||
|
pub burn_timestamp: i64,
|
||||||
|
pub confirmations: u32,
|
||||||
|
#[serde(rename = "requiredConfirmations")]
|
||||||
|
pub required_confirmations: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Burn proof for unlocking
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BurnProof {
|
||||||
|
#[serde(rename = "burnReceipt")]
|
||||||
|
pub burn_receipt: BurnReceipt,
|
||||||
|
#[serde(rename = "merkleProof")]
|
||||||
|
pub merkle_proof: Vec<String>,
|
||||||
|
#[serde(rename = "blockHeader")]
|
||||||
|
pub block_header: String,
|
||||||
|
pub signatures: Vec<ValidatorSignature>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete transfer record
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Transfer {
|
||||||
|
pub id: String,
|
||||||
|
pub direction: TransferDirection,
|
||||||
|
pub status: TransferStatus,
|
||||||
|
#[serde(rename = "sourceChain")]
|
||||||
|
pub source_chain: ChainId,
|
||||||
|
#[serde(rename = "targetChain")]
|
||||||
|
pub target_chain: ChainId,
|
||||||
|
pub asset: Asset,
|
||||||
|
pub amount: String,
|
||||||
|
pub sender: String,
|
||||||
|
pub recipient: String,
|
||||||
|
#[serde(rename = "sourceTxHash", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub source_tx_hash: Option<String>,
|
||||||
|
#[serde(rename = "targetTxHash", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub target_tx_hash: Option<String>,
|
||||||
|
pub fee: String,
|
||||||
|
#[serde(rename = "feeAsset")]
|
||||||
|
pub fee_asset: Asset,
|
||||||
|
#[serde(rename = "createdAt")]
|
||||||
|
pub created_at: i64,
|
||||||
|
#[serde(rename = "updatedAt")]
|
||||||
|
pub updated_at: i64,
|
||||||
|
#[serde(rename = "completedAt", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub completed_at: Option<i64>,
|
||||||
|
#[serde(rename = "errorMessage", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Fee Types ====================
|
||||||
|
|
||||||
|
/// Fee estimate
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct FeeEstimate {
|
||||||
|
#[serde(rename = "bridgeFee")]
|
||||||
|
pub bridge_fee: String,
|
||||||
|
#[serde(rename = "gasFeeSource")]
|
||||||
|
pub gas_fee_source: String,
|
||||||
|
#[serde(rename = "gasFeeTarget")]
|
||||||
|
pub gas_fee_target: String,
|
||||||
|
#[serde(rename = "totalFee")]
|
||||||
|
pub total_fee: String,
|
||||||
|
#[serde(rename = "feeAsset")]
|
||||||
|
pub fee_asset: Asset,
|
||||||
|
#[serde(rename = "estimatedTime")]
|
||||||
|
pub estimated_time: u32,
|
||||||
|
#[serde(rename = "exchangeRate", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub exchange_rate: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exchange rate
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ExchangeRate {
|
||||||
|
#[serde(rename = "fromAsset")]
|
||||||
|
pub from_asset: Asset,
|
||||||
|
#[serde(rename = "toAsset")]
|
||||||
|
pub to_asset: Asset,
|
||||||
|
pub rate: String,
|
||||||
|
#[serde(rename = "inverseRate")]
|
||||||
|
pub inverse_rate: String,
|
||||||
|
#[serde(rename = "lastUpdated")]
|
||||||
|
pub last_updated: i64,
|
||||||
|
pub source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Transaction Types ====================
|
||||||
|
|
||||||
|
/// Signed transaction
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SignedTransaction {
|
||||||
|
#[serde(rename = "txHash")]
|
||||||
|
pub tx_hash: String,
|
||||||
|
pub chain: ChainId,
|
||||||
|
pub from: String,
|
||||||
|
pub to: String,
|
||||||
|
pub value: String,
|
||||||
|
pub data: String,
|
||||||
|
#[serde(rename = "gasLimit")]
|
||||||
|
pub gas_limit: String,
|
||||||
|
#[serde(rename = "gasPrice", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub gas_price: Option<String>,
|
||||||
|
#[serde(rename = "maxFeePerGas", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_fee_per_gas: Option<String>,
|
||||||
|
#[serde(rename = "maxPriorityFeePerGas", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_priority_fee_per_gas: Option<String>,
|
||||||
|
pub nonce: u64,
|
||||||
|
pub signature: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Filter Types ====================
|
||||||
|
|
||||||
|
/// Transfer filter
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct TransferFilter {
|
||||||
|
pub status: Option<TransferStatus>,
|
||||||
|
pub source_chain: Option<ChainId>,
|
||||||
|
pub target_chain: Option<ChainId>,
|
||||||
|
pub asset: Option<String>,
|
||||||
|
pub sender: Option<String>,
|
||||||
|
pub recipient: Option<String>,
|
||||||
|
pub from_date: Option<i64>,
|
||||||
|
pub to_date: Option<i64>,
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
pub offset: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Options Types ====================
|
||||||
|
|
||||||
|
/// Lock options
|
||||||
|
#[derive(Debug, Clone, Default, Serialize)]
|
||||||
|
pub struct LockOptions {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub recipient: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deadline: Option<i64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub slippage: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint options
|
||||||
|
#[derive(Debug, Clone, Default, Serialize)]
|
||||||
|
pub struct MintOptions {
|
||||||
|
#[serde(rename = "gasLimit", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub gas_limit: Option<String>,
|
||||||
|
#[serde(rename = "maxFeePerGas", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_fee_per_gas: Option<String>,
|
||||||
|
#[serde(rename = "maxPriorityFeePerGas", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_priority_fee_per_gas: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Burn options
|
||||||
|
#[derive(Debug, Clone, Default, Serialize)]
|
||||||
|
pub struct BurnOptions {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub recipient: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deadline: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unlock options
|
||||||
|
#[derive(Debug, Clone, Default, Serialize)]
|
||||||
|
pub struct UnlockOptions {
|
||||||
|
#[serde(rename = "gasLimit", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub gas_limit: Option<String>,
|
||||||
|
#[serde(rename = "gasPrice", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub gas_price: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Config Types ====================
|
||||||
|
|
||||||
|
/// Bridge configuration
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BridgeConfig {
|
||||||
|
pub api_key: String,
|
||||||
|
pub endpoint: String,
|
||||||
|
pub timeout_secs: u64,
|
||||||
|
pub retries: u32,
|
||||||
|
pub debug: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BridgeConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
api_key: String::new(),
|
||||||
|
endpoint: "https://bridge.synor.io/v1".to_string(),
|
||||||
|
timeout_secs: 60,
|
||||||
|
retries: 3,
|
||||||
|
debug: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
543
sdk/rust/src/database/client.rs
Normal file
543
sdk/rust/src/database/client.rs
Normal file
|
|
@ -0,0 +1,543 @@
|
||||||
|
//! Database client implementation
|
||||||
|
|
||||||
|
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
|
||||||
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use super::error::{DatabaseError, Result};
|
||||||
|
use super::types::*;
|
||||||
|
|
||||||
|
/// Synor Database Client
|
||||||
|
pub struct Client {
|
||||||
|
config: Config,
|
||||||
|
http_client: reqwest::Client,
|
||||||
|
closed: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
/// Create a new database client
|
||||||
|
pub fn new(config: Config) -> Self {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
AUTHORIZATION,
|
||||||
|
HeaderValue::from_str(&format!("Bearer {}", config.api_key)).unwrap(),
|
||||||
|
);
|
||||||
|
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||||
|
headers.insert("X-SDK-Version", HeaderValue::from_static("rust/0.1.0"));
|
||||||
|
|
||||||
|
let http_client = reqwest::Client::builder()
|
||||||
|
.default_headers(headers)
|
||||||
|
.timeout(Duration::from_secs(config.timeout_secs))
|
||||||
|
.build()
|
||||||
|
.expect("Failed to create HTTP client");
|
||||||
|
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
http_client,
|
||||||
|
closed: Arc::new(AtomicBool::new(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the key-value store
|
||||||
|
pub fn kv(&self) -> KeyValueStore<'_> {
|
||||||
|
KeyValueStore { client: self }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the document store
|
||||||
|
pub fn documents(&self) -> DocumentStore<'_> {
|
||||||
|
DocumentStore { client: self }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the vector store
|
||||||
|
pub fn vectors(&self) -> VectorStore<'_> {
|
||||||
|
VectorStore { client: self }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the time series store
|
||||||
|
pub fn timeseries(&self) -> TimeSeriesStore<'_> {
|
||||||
|
TimeSeriesStore { client: self }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get database statistics
|
||||||
|
pub async fn get_stats(&self) -> Result<DatabaseStats> {
|
||||||
|
self.request("GET", "/stats", Option::<()>::None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Health check
|
||||||
|
pub async fn health_check(&self) -> bool {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct HealthResponse {
|
||||||
|
status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.request::<_, HealthResponse>("GET", "/health", Option::<()>::None).await {
|
||||||
|
Ok(resp) => resp.status == "healthy",
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the client
|
||||||
|
pub fn close(&self) {
|
||||||
|
self.closed.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if client is closed
|
||||||
|
pub fn is_closed(&self) -> bool {
|
||||||
|
self.closed.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request<B: Serialize, R: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
body: Option<B>,
|
||||||
|
) -> Result<R> {
|
||||||
|
if self.is_closed() {
|
||||||
|
return Err(DatabaseError::new("Client has been closed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut last_error = None;
|
||||||
|
|
||||||
|
for attempt in 0..self.config.retries {
|
||||||
|
match self.do_request(method, path, &body).await {
|
||||||
|
Ok(result) => return Ok(result),
|
||||||
|
Err(e) => {
|
||||||
|
if self.config.debug {
|
||||||
|
eprintln!("Attempt {} failed: {}", attempt + 1, e);
|
||||||
|
}
|
||||||
|
last_error = Some(e);
|
||||||
|
if attempt < self.config.retries - 1 {
|
||||||
|
tokio::time::sleep(Duration::from_secs((attempt + 1) as u64)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(last_error.unwrap_or_else(|| DatabaseError::new("Unknown error after retries")))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn do_request<B: Serialize, R: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
body: &Option<B>,
|
||||||
|
) -> Result<R> {
|
||||||
|
let url = format!("{}{}", self.config.endpoint, path);
|
||||||
|
|
||||||
|
let mut request = match method {
|
||||||
|
"GET" => self.http_client.get(&url),
|
||||||
|
"POST" => self.http_client.post(&url),
|
||||||
|
"PUT" => self.http_client.put(&url),
|
||||||
|
"PATCH" => self.http_client.patch(&url),
|
||||||
|
"DELETE" => self.http_client.delete(&url),
|
||||||
|
_ => return Err(DatabaseError::new(format!("Unknown method: {}", method))),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(b) = body {
|
||||||
|
request = request.json(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request.send().await?;
|
||||||
|
let status = response.status();
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let error_body: serde_json::Value = response.json().await.unwrap_or_default();
|
||||||
|
let message = error_body["message"]
|
||||||
|
.as_str()
|
||||||
|
.or_else(|| error_body["error"].as_str())
|
||||||
|
.unwrap_or(&format!("HTTP {}", status.as_u16()));
|
||||||
|
|
||||||
|
return Err(DatabaseError::new(message)
|
||||||
|
.with_status(status.as_u16())
|
||||||
|
.with_code(error_body["code"].as_str().unwrap_or("").to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = response.json().await?;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Key-Value store operations
|
||||||
|
pub struct KeyValueStore<'a> {
|
||||||
|
client: &'a Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> KeyValueStore<'a> {
|
||||||
|
/// Get a value by key
|
||||||
|
pub async fn get<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>> {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Response<T> {
|
||||||
|
value: Option<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = format!("/kv/{}", urlencoding::encode(key));
|
||||||
|
let resp: Response<T> = self.client.request("GET", &path, Option::<()>::None).await?;
|
||||||
|
Ok(resp.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a full entry with metadata
|
||||||
|
pub async fn get_entry<T: DeserializeOwned>(&self, key: &str) -> Result<Option<KeyValueEntry<T>>> {
|
||||||
|
let path = format!("/kv/{}/entry", urlencoding::encode(key));
|
||||||
|
self.client.request("GET", &path, Option::<()>::None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a value
|
||||||
|
pub async fn set<T: Serialize>(&self, key: &str, value: T, options: Option<SetOptions>) -> Result<()> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SetRequest<T> {
|
||||||
|
value: T,
|
||||||
|
#[serde(flatten)]
|
||||||
|
options: Option<SetOptions>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = format!("/kv/{}", urlencoding::encode(key));
|
||||||
|
let body = SetRequest { value, options };
|
||||||
|
self.client.request::<_, serde_json::Value>("PUT", &path, Some(body)).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a key
|
||||||
|
pub async fn delete(&self, key: &str) -> Result<bool> {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Response {
|
||||||
|
deleted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = format!("/kv/{}", urlencoding::encode(key));
|
||||||
|
let resp: Response = self.client.request("DELETE", &path, Option::<()>::None).await?;
|
||||||
|
Ok(resp.deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a key exists
|
||||||
|
pub async fn exists(&self, key: &str) -> Result<bool> {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Response {
|
||||||
|
exists: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = format!("/kv/{}/exists", urlencoding::encode(key));
|
||||||
|
let resp: Response = self.client.request("GET", &path, Option::<()>::None).await?;
|
||||||
|
Ok(resp.exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List keys
|
||||||
|
pub async fn list<T: DeserializeOwned>(&self, options: Option<ListOptions>) -> Result<ListResult<T>> {
|
||||||
|
let mut path = "/kv".to_string();
|
||||||
|
if let Some(opts) = &options {
|
||||||
|
let mut params = vec![];
|
||||||
|
if let Some(prefix) = &opts.prefix {
|
||||||
|
params.push(format!("prefix={}", urlencoding::encode(prefix)));
|
||||||
|
}
|
||||||
|
if let Some(cursor) = &opts.cursor {
|
||||||
|
params.push(format!("cursor={}", urlencoding::encode(cursor)));
|
||||||
|
}
|
||||||
|
if let Some(limit) = opts.limit {
|
||||||
|
params.push(format!("limit={}", limit));
|
||||||
|
}
|
||||||
|
if !params.is_empty() {
|
||||||
|
path = format!("{}?{}", path, params.join("&"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.client.request("GET", &path, Option::<()>::None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Increment a numeric value
|
||||||
|
pub async fn incr(&self, key: &str, by: i64) -> Result<i64> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct IncrRequest {
|
||||||
|
by: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Response {
|
||||||
|
value: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = format!("/kv/{}/incr", urlencoding::encode(key));
|
||||||
|
let resp: Response = self.client.request("POST", &path, Some(IncrRequest { by })).await?;
|
||||||
|
Ok(resp.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Document store operations
|
||||||
|
pub struct DocumentStore<'a> {
|
||||||
|
client: &'a Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> DocumentStore<'a> {
|
||||||
|
/// Create a new document
|
||||||
|
pub async fn create<T: Serialize + DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
collection: &str,
|
||||||
|
data: T,
|
||||||
|
options: Option<CreateDocumentOptions>,
|
||||||
|
) -> Result<Document<T>> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CreateRequest<T> {
|
||||||
|
data: T,
|
||||||
|
#[serde(flatten)]
|
||||||
|
options: Option<CreateDocumentOptions>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = format!("/documents/{}", urlencoding::encode(collection));
|
||||||
|
let body = CreateRequest { data, options };
|
||||||
|
self.client.request("POST", &path, Some(body)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a document by ID
|
||||||
|
pub async fn get<T: DeserializeOwned>(&self, collection: &str, id: &str) -> Result<Option<Document<T>>> {
|
||||||
|
let path = format!("/documents/{}/{}", urlencoding::encode(collection), urlencoding::encode(id));
|
||||||
|
self.client.request("GET", &path, Option::<()>::None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a document
|
||||||
|
pub async fn update<T: Serialize + DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
collection: &str,
|
||||||
|
id: &str,
|
||||||
|
update: T,
|
||||||
|
options: Option<UpdateDocumentOptions>,
|
||||||
|
) -> Result<Document<T>> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct UpdateRequest<T> {
|
||||||
|
update: T,
|
||||||
|
#[serde(flatten)]
|
||||||
|
options: Option<UpdateDocumentOptions>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = format!("/documents/{}/{}", urlencoding::encode(collection), urlencoding::encode(id));
|
||||||
|
let body = UpdateRequest { update, options };
|
||||||
|
self.client.request("PATCH", &path, Some(body)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a document
|
||||||
|
pub async fn delete(&self, collection: &str, id: &str) -> Result<bool> {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Response {
|
||||||
|
deleted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = format!("/documents/{}/{}", urlencoding::encode(collection), urlencoding::encode(id));
|
||||||
|
let resp: Response = self.client.request("DELETE", &path, Option::<()>::None).await?;
|
||||||
|
Ok(resp.deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query documents
|
||||||
|
pub async fn query<T: DeserializeOwned>(&self, collection: &str, options: Option<QueryOptions>) -> Result<QueryResult<T>> {
|
||||||
|
let path = format!("/documents/{}/query", urlencoding::encode(collection));
|
||||||
|
self.client.request("POST", &path, options.or(Some(QueryOptions::default()))).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count documents
|
||||||
|
pub async fn count(&self, collection: &str, filter: Option<serde_json::Value>) -> Result<u64> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CountRequest {
|
||||||
|
filter: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Response {
|
||||||
|
count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = format!("/documents/{}/count", urlencoding::encode(collection));
|
||||||
|
let resp: Response = self.client.request("POST", &path, Some(CountRequest { filter })).await?;
|
||||||
|
Ok(resp.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vector store operations
|
||||||
|
pub struct VectorStore<'a> {
|
||||||
|
client: &'a Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> VectorStore<'a> {
|
||||||
|
/// Create a vector collection
|
||||||
|
pub async fn create_collection(&self, config: VectorCollectionConfig) -> Result<()> {
|
||||||
|
self.client.request::<_, serde_json::Value>("POST", "/vectors/collections", Some(config)).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a vector collection
|
||||||
|
pub async fn delete_collection(&self, name: &str) -> Result<()> {
|
||||||
|
let path = format!("/vectors/collections/{}", urlencoding::encode(name));
|
||||||
|
self.client.request::<_, serde_json::Value>("DELETE", &path, Option::<()>::None).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upsert vectors
|
||||||
|
pub async fn upsert(
|
||||||
|
&self,
|
||||||
|
collection: &str,
|
||||||
|
vectors: Vec<VectorEntry>,
|
||||||
|
options: Option<UpsertVectorOptions>,
|
||||||
|
) -> Result<u64> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct UpsertRequest {
|
||||||
|
vectors: Vec<VectorEntry>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
options: Option<UpsertVectorOptions>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Response {
|
||||||
|
upserted: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = format!("/vectors/{}/upsert", urlencoding::encode(collection));
|
||||||
|
let resp: Response = self.client.request("POST", &path, Some(UpsertRequest { vectors, options })).await?;
|
||||||
|
Ok(resp.upserted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search for similar vectors
|
||||||
|
pub async fn search(
|
||||||
|
&self,
|
||||||
|
collection: &str,
|
||||||
|
vector: Vec<f64>,
|
||||||
|
options: Option<SearchOptions>,
|
||||||
|
) -> Result<Vec<SearchResult>> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SearchRequest {
|
||||||
|
vector: Vec<f64>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
options: Option<SearchOptions>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Response {
|
||||||
|
results: Vec<SearchResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = format!("/vectors/{}/search", urlencoding::encode(collection));
|
||||||
|
let resp: Response = self.client.request("POST", &path, Some(SearchRequest { vector, options })).await?;
|
||||||
|
Ok(resp.results)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete vectors by ID
|
||||||
|
pub async fn delete(&self, collection: &str, ids: Vec<String>, namespace: Option<String>) -> Result<u64> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct DeleteRequest {
|
||||||
|
ids: Vec<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
namespace: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Response {
|
||||||
|
deleted: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = format!("/vectors/{}/delete", urlencoding::encode(collection));
|
||||||
|
let resp: Response = self.client.request("POST", &path, Some(DeleteRequest { ids, namespace })).await?;
|
||||||
|
Ok(resp.deleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Time series store operations
|
||||||
|
pub struct TimeSeriesStore<'a> {
|
||||||
|
client: &'a Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> TimeSeriesStore<'a> {
|
||||||
|
/// Write data points
|
||||||
|
pub async fn write(
|
||||||
|
&self,
|
||||||
|
series: &str,
|
||||||
|
points: Vec<DataPoint>,
|
||||||
|
options: Option<WritePointsOptions>,
|
||||||
|
) -> Result<u64> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct WriteRequest {
|
||||||
|
points: Vec<DataPoint>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
options: Option<WritePointsOptions>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Response {
|
||||||
|
written: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = format!("/timeseries/{}/write", urlencoding::encode(series));
|
||||||
|
let resp: Response = self.client.request("POST", &path, Some(WriteRequest { points, options })).await?;
|
||||||
|
Ok(resp.written)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query a time series
|
||||||
|
pub async fn query(
|
||||||
|
&self,
|
||||||
|
series: &str,
|
||||||
|
range: TimeRange,
|
||||||
|
options: Option<TimeSeriesQueryOptions>,
|
||||||
|
) -> Result<TimeSeriesResult> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct QueryRequest {
|
||||||
|
range: TimeRange,
|
||||||
|
#[serde(flatten)]
|
||||||
|
options: Option<TimeSeriesQueryOptions>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = format!("/timeseries/{}/query", urlencoding::encode(series));
|
||||||
|
self.client.request("POST", &path, Some(QueryRequest { range, options })).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete data points
|
||||||
|
pub async fn delete(
|
||||||
|
&self,
|
||||||
|
series: &str,
|
||||||
|
range: TimeRange,
|
||||||
|
tags: Option<std::collections::HashMap<String, String>>,
|
||||||
|
) -> Result<u64> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct DeleteRequest {
|
||||||
|
range: TimeRange,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
tags: Option<std::collections::HashMap<String, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Response {
|
||||||
|
deleted: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = format!("/timeseries/{}/delete", urlencoding::encode(series));
|
||||||
|
let resp: Response = self.client.request("POST", &path, Some(DeleteRequest { range, tags })).await?;
|
||||||
|
Ok(resp.deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get series info
|
||||||
|
pub async fn get_series_info(&self, series: &str) -> Result<SeriesInfo> {
|
||||||
|
let path = format!("/timeseries/{}/info", urlencoding::encode(series));
|
||||||
|
self.client.request("GET", &path, Option::<()>::None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all series
|
||||||
|
pub async fn list_series(&self, prefix: Option<&str>) -> Result<Vec<SeriesInfo>> {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Response {
|
||||||
|
series: Vec<SeriesInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = match prefix {
|
||||||
|
Some(p) => format!("/timeseries?prefix={}", urlencoding::encode(p)),
|
||||||
|
None => "/timeseries".to_string(),
|
||||||
|
};
|
||||||
|
let resp: Response = self.client.request("GET", &path, Option::<()>::None).await?;
|
||||||
|
Ok(resp.series)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set retention policy
|
||||||
|
pub async fn set_retention(&self, series: &str, retention_days: i32) -> Result<()> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Request {
|
||||||
|
retention_days: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = format!("/timeseries/{}/retention", urlencoding::encode(series));
|
||||||
|
self.client.request::<_, serde_json::Value>("PUT", &path, Some(Request { retention_days })).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
54
sdk/rust/src/database/error.rs
Normal file
54
sdk/rust/src/database/error.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
//! Database error types
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// Database error
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct DatabaseError {
|
||||||
|
pub message: String,
|
||||||
|
pub code: Option<String>,
|
||||||
|
pub status_code: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DatabaseError {
|
||||||
|
pub fn new(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
message: message.into(),
|
||||||
|
code: None,
|
||||||
|
status_code: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_code(mut self, code: impl Into<String>) -> Self {
|
||||||
|
self.code = Some(code.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_status(mut self, status: u16) -> Self {
|
||||||
|
self.status_code = Some(status);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for DatabaseError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}", self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for DatabaseError {}
|
||||||
|
|
||||||
|
impl From<reqwest::Error> for DatabaseError {
|
||||||
|
fn from(err: reqwest::Error) -> Self {
|
||||||
|
Self::new(err.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<serde_json::Error> for DatabaseError {
|
||||||
|
fn from(err: serde_json::Error) -> Self {
|
||||||
|
Self::new(err.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result type alias
|
||||||
|
pub type Result<T> = std::result::Result<T, DatabaseError>;
|
||||||
37
sdk/rust/src/database/mod.rs
Normal file
37
sdk/rust/src/database/mod.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
//! Synor Database SDK for Rust
|
||||||
|
//!
|
||||||
|
//! Multi-model database: Key-Value, Document, Vector, and Time Series.
|
||||||
|
//!
|
||||||
|
//! # Example
|
||||||
|
//!
|
||||||
|
//! ```rust,no_run
|
||||||
|
//! use synor_sdk::database::{Client, Config};
|
||||||
|
//!
|
||||||
|
//! #[tokio::main]
|
||||||
|
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
//! let client = Client::new(Config {
|
||||||
|
//! api_key: "your-api-key".to_string(),
|
||||||
|
//! ..Default::default()
|
||||||
|
//! });
|
||||||
|
//!
|
||||||
|
//! // Key-Value operations
|
||||||
|
//! client.kv().set("user:1", serde_json::json!({"name": "Alice"}), None).await?;
|
||||||
|
//! let user = client.kv().get::<serde_json::Value>("user:1").await?;
|
||||||
|
//!
|
||||||
|
//! // Document operations
|
||||||
|
//! let doc = client.documents().create("users", serde_json::json!({"name": "Bob"}), None).await?;
|
||||||
|
//!
|
||||||
|
//! // Vector operations
|
||||||
|
//! client.vectors().upsert("embeddings", vec![VectorEntry { id: "1".into(), vector: vec![0.1, 0.2], ..Default::default() }], None).await?;
|
||||||
|
//!
|
||||||
|
//! Ok(())
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
mod types;
|
||||||
|
mod error;
|
||||||
|
mod client;
|
||||||
|
|
||||||
|
pub use types::*;
|
||||||
|
pub use error::*;
|
||||||
|
pub use client::*;
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue