diff --git a/sdk/c/CMakeLists.txt b/sdk/c/CMakeLists.txt index 02a2cfc..c0cdffe 100644 --- a/sdk/c/CMakeLists.txt +++ b/sdk/c/CMakeLists.txt @@ -1,5 +1,5 @@ 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_REQUIRED ON) @@ -11,51 +11,90 @@ option(BUILD_EXAMPLES "Build examples" ON) # Find dependencies 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 set(SYNOR_SOURCES src/synor_compute.c + src/common.c + src/wallet/wallet.c + src/rpc/rpc.c + src/storage/storage.c ) # 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 $ $ + PRIVATE + ${JANSSON_INCLUDE_DIRS} ) -target_link_libraries(synor_compute +target_link_libraries(synor_sdk PRIVATE CURL::libcurl 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_target_properties(synor_compute PROPERTIES +set_target_properties(synor_sdk PROPERTIES VERSION ${PROJECT_VERSION} SOVERSION ${PROJECT_VERSION_MAJOR} - PUBLIC_HEADER include/synor_compute.h ) # Installation include(GNUInstallDirs) -install(TARGETS synor_compute - EXPORT synor_compute-targets +install(TARGETS synor_sdk + EXPORT synor_sdk-targets LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} - PUBLIC_HEADER DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} ) -install(EXPORT synor_compute-targets - FILE synor_compute-targets.cmake - NAMESPACE synor:: - DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/synor_compute +install(DIRECTORY include/ + DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} ) +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 if(BUILD_TESTS) enable_testing() diff --git a/sdk/c/include/synor/rpc.h b/sdk/c/include/synor/rpc.h new file mode 100644 index 0000000..718bd8f --- /dev/null +++ b/sdk/c/include/synor/rpc.h @@ -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 +#include +#include +#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 */ diff --git a/sdk/c/include/synor/storage.h b/sdk/c/include/synor/storage.h new file mode 100644 index 0000000..c774a06 --- /dev/null +++ b/sdk/c/include/synor/storage.h @@ -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 +#include +#include +#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 */ diff --git a/sdk/c/include/synor/wallet.h b/sdk/c/include/synor/wallet.h new file mode 100644 index 0000000..c1a551d --- /dev/null +++ b/sdk/c/include/synor/wallet.h @@ -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 + * + * 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 +#include +#include + +#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 */ diff --git a/sdk/c/src/common.c b/sdk/c/src/common.c new file mode 100644 index 0000000..14abb4e --- /dev/null +++ b/sdk/c/src/common.c @@ -0,0 +1,249 @@ +/** + * @file common.c + * @brief Common utilities for Synor C SDK + */ + +#include +#include +#include +#include +#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); +} diff --git a/sdk/c/src/rpc/rpc.c b/sdk/c/src/rpc/rpc.c new file mode 100644 index 0000000..d8e4dff --- /dev/null +++ b/sdk/c/src/rpc/rpc.c @@ -0,0 +1,221 @@ +/** + * @file rpc.c + * @brief Synor RPC SDK implementation + */ + +#include +#include +#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)); +} diff --git a/sdk/c/src/storage/storage.c b/sdk/c/src/storage/storage.c new file mode 100644 index 0000000..a90cc11 --- /dev/null +++ b/sdk/c/src/storage/storage.c @@ -0,0 +1,234 @@ +/** + * @file storage.c + * @brief Synor Storage SDK implementation + */ + +#include +#include +#include +#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)); +} diff --git a/sdk/c/src/wallet/wallet.c b/sdk/c/src/wallet/wallet.c new file mode 100644 index 0000000..ad6351f --- /dev/null +++ b/sdk/c/src/wallet/wallet.c @@ -0,0 +1,355 @@ +/** + * @file wallet.c + * @brief Synor Wallet SDK implementation + */ + +#include +#include +#include +#include "synor/wallet.h" + +#ifdef HAVE_JANSSON +#include +#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)); +} diff --git a/sdk/cpp/CMakeLists.txt b/sdk/cpp/CMakeLists.txt index 137ad5d..5a7a5e8 100644 --- a/sdk/cpp/CMakeLists.txt +++ b/sdk/cpp/CMakeLists.txt @@ -1,5 +1,5 @@ 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_REQUIRED ON) @@ -28,25 +28,31 @@ set(SYNOR_SOURCES src/synor_compute.cpp src/tensor.cpp src/client.cpp + src/wallet/wallet.cpp + src/rpc/rpc.cpp + src/storage/storage.cpp ) # 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 $ $ ) -target_link_libraries(synor_compute +target_link_libraries(synor_sdk PRIVATE CURL::libcurl nlohmann_json::nlohmann_json ) # Set library properties -set_target_properties(synor_compute PROPERTIES +set_target_properties(synor_sdk PROPERTIES VERSION ${PROJECT_VERSION} SOVERSION ${PROJECT_VERSION_MAJOR} ) @@ -54,8 +60,8 @@ set_target_properties(synor_compute PROPERTIES # Installation include(GNUInstallDirs) -install(TARGETS synor_compute - EXPORT synor_compute-targets +install(TARGETS synor_sdk + EXPORT synor_sdk-targets LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} @@ -65,8 +71,8 @@ install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} ) -install(EXPORT synor_compute-targets - FILE synor_compute-targets.cmake +install(EXPORT synor_sdk-targets + FILE synor_sdk-targets.cmake NAMESPACE synor:: - DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/synor_compute + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/synor_sdk ) diff --git a/sdk/cpp/include/synor/rpc.hpp b/sdk/cpp/include/synor/rpc.hpp new file mode 100644 index 0000000..f68cadf --- /dev/null +++ b/sdk/cpp/include/synor/rpc.hpp @@ -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 +#include +#include +#include +#include +#include +#include +#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 address; +}; + +/// Transaction input +struct TransactionInput { + std::string txid; + int32_t vout; + std::optional script_sig; + int64_t sequence; + std::vector 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 inputs; + std::vector outputs; + int64_t fee; + int32_t confirmations; + std::optional block_hash; + std::optional block_height; + std::optional 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 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 reason; +}; + +/// Subscription handle +class Subscription { +public: + Subscription(const std::string& id, const std::string& channel, + std::function 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 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 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 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 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 get_all_fee_estimates(); + + // Chain information + ChainInfo get_chain_info(); + MempoolInfo get_mempool_info(); + std::vector get_mempool_transactions(int32_t limit = 100); + + // Subscriptions + std::unique_ptr subscribe_blocks( + std::function callback); + std::unique_ptr subscribe_address( + const std::string& address, + std::function callback); + std::unique_ptr subscribe_mempool( + std::function callback); + +private: + class Impl; + std::unique_ptr impl_; +}; + +} // namespace rpc +} // namespace synor diff --git a/sdk/cpp/include/synor/storage.hpp b/sdk/cpp/include/synor/storage.hpp new file mode 100644 index 0000000..b1fb8af --- /dev/null +++ b/sdk/cpp/include/synor/storage.hpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#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 name; + bool wrap_with_directory = false; + HashAlgorithm hash_algorithm = HashAlgorithm::Sha2_256; + std::optional chunk_size; + std::optional pin_duration_days; +}; + +/// Upload response +struct UploadResponse { + std::string cid; + int64_t size; + std::optional name; + std::string created_at; +}; + +/// Pin information +struct Pin { + std::string cid; + std::optional name; + PinStatus status; + int64_t size; + std::string created_at; + std::optional expires_at; + std::vector delegates; +}; + +/// Pin request +struct PinRequest { + std::string cid; + std::optional name; + std::optional duration_days; + std::vector origins; + std::map meta; +}; + +/// CAR file information +struct CarFile { + std::string cid; + int64_t size; + std::vector roots; + std::string created_at; +}; + +/// File entry for upload +struct FileEntry { + std::string path; + std::vector 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 data, + const UploadOptions& options = {}); + std::future upload_async(std::vector data, + UploadOptions options = {}); + UploadResponse upload_directory(const std::vector& files, + const std::optional& dir_name = std::nullopt); + + // Download operations + std::vector download(const std::string& cid); + std::future> download_async(const std::string& cid); + void download_stream(const std::string& cid, + std::function)> callback); + + /// Get gateway URL for content + std::string get_gateway_url(const std::string& cid, + const std::optional& 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 list_pins(std::optional status = std::nullopt, + int32_t limit = 50, int32_t offset = 0); + + // CAR file operations + CarFile create_car(const std::vector& entries); + std::vector import_car(std::span car_data); + std::vector export_car(const std::string& cid); + + // Directory operations + std::vector list_directory(const std::string& cid); + + // Statistics + StorageStats get_stats(); + +private: + class Impl; + std::unique_ptr impl_; +}; + +} // namespace storage +} // namespace synor diff --git a/sdk/cpp/include/synor/synor.hpp b/sdk/cpp/include/synor/synor.hpp new file mode 100644 index 0000000..61729a7 --- /dev/null +++ b/sdk/cpp/include/synor/synor.hpp @@ -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 + * + * 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 diff --git a/sdk/cpp/include/synor/wallet.hpp b/sdk/cpp/include/synor/wallet.hpp new file mode 100644 index 0000000..3636aa0 --- /dev/null +++ b/sdk/cpp/include/synor/wallet.hpp @@ -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 + * + * 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 +#include +#include +#include +#include +#include +#include + +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
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 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 inputs; + std::vector outputs; + std::optional 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 recovery_id; +}; + +/// Unspent transaction output +struct UTXO { + std::string txid; + int32_t vout; + int64_t amount; + std::string address; + int32_t confirmations; + std::optional 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 create_wallet_async(WalletType type = WalletType::Standard); + + /// Import a wallet from mnemonic + Wallet import_wallet(const std::string& mnemonic, + const std::optional& 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 get_balance_async(const std::string& address); + + /// Get UTXOs for an address + std::vector 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_; +}; + +} // namespace wallet +} // namespace synor diff --git a/sdk/cpp/src/rpc/rpc.cpp b/sdk/cpp/src/rpc/rpc.cpp new file mode 100644 index 0000000..bf17749 --- /dev/null +++ b/sdk/cpp/src/rpc/rpc.cpp @@ -0,0 +1,145 @@ +/** + * @file rpc.cpp + * @brief Synor RPC SDK implementation for C++ + */ + +#include "synor/rpc.hpp" +#include + +namespace synor { +namespace rpc { + +// Subscription implementation +Subscription::Subscription(const std::string& id, const std::string& channel, + std::function 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(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 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 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 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 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 Client::get_mempool_transactions(int32_t limit) { + (void)limit; + throw SynorException("Not implemented"); +} + +std::unique_ptr Client::subscribe_blocks( + std::function callback) { + (void)callback; + throw SynorException("Not implemented"); +} + +std::unique_ptr Client::subscribe_address( + const std::string& address, + std::function callback) { + (void)address; + (void)callback; + throw SynorException("Not implemented"); +} + +std::unique_ptr Client::subscribe_mempool( + std::function callback) { + (void)callback; + throw SynorException("Not implemented"); +} + +} // namespace rpc +} // namespace synor diff --git a/sdk/cpp/src/storage/storage.cpp b/sdk/cpp/src/storage/storage.cpp new file mode 100644 index 0000000..23a6424 --- /dev/null +++ b/sdk/cpp/src/storage/storage.cpp @@ -0,0 +1,126 @@ +/** + * @file storage.cpp + * @brief Synor Storage SDK implementation for C++ + */ + +#include "synor/storage.hpp" +#include + +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(config)) {} + +Client::~Client() = default; + +Client::Client(Client&&) noexcept = default; +Client& Client::operator=(Client&&) noexcept = default; + +UploadResponse Client::upload(std::span data, + const UploadOptions& options) { + (void)data; + (void)options; + throw SynorException("Not implemented"); +} + +std::future Client::upload_async(std::vector 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& files, + const std::optional& dir_name) { + (void)files; + (void)dir_name; + throw SynorException("Not implemented"); +} + +std::vector Client::download(const std::string& cid) { + (void)cid; + throw SynorException("Not implemented"); +} + +std::future> 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)> callback) { + (void)cid; + (void)callback; + throw SynorException("Not implemented"); +} + +std::string Client::get_gateway_url(const std::string& cid, + const std::optional& 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 Client::list_pins(std::optional status, + int32_t limit, int32_t offset) { + (void)status; + (void)limit; + (void)offset; + throw SynorException("Not implemented"); +} + +CarFile Client::create_car(const std::vector& entries) { + (void)entries; + throw SynorException("Not implemented"); +} + +std::vector Client::import_car(std::span car_data) { + (void)car_data; + throw SynorException("Not implemented"); +} + +std::vector Client::export_car(const std::string& cid) { + (void)cid; + throw SynorException("Not implemented"); +} + +std::vector 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 diff --git a/sdk/cpp/src/wallet/wallet.cpp b/sdk/cpp/src/wallet/wallet.cpp new file mode 100644 index 0000000..409549c --- /dev/null +++ b/sdk/cpp/src/wallet/wallet.cpp @@ -0,0 +1,113 @@ +/** + * @file wallet.cpp + * @brief Synor Wallet SDK implementation for C++ + */ + +#include "synor/wallet.hpp" +#include + +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(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 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& 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 Client::get_balance_async(const std::string& address) { + return std::async(std::launch::async, [this, address]() { + return this->get_balance(address); + }); +} + +std::vector 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 diff --git a/sdk/csharp/Synor.Sdk/Rpc/SynorRpc.cs b/sdk/csharp/Synor.Sdk/Rpc/SynorRpc.cs new file mode 100644 index 0000000..7cd9295 --- /dev/null +++ b/sdk/csharp/Synor.Sdk/Rpc/SynorRpc.cs @@ -0,0 +1,477 @@ +using System.Net.Http.Json; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; + +namespace Synor.Sdk.Rpc; + +/// +/// Synor RPC SDK client for C#. +/// +/// Provides blockchain data queries, transaction submission, +/// and real-time subscriptions via WebSocket. +/// +/// +/// +/// 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(); +/// +/// +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> _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 + }; + } + + /// + /// Get the latest block. + /// + public async Task GetLatestBlockAsync(CancellationToken cancellationToken = default) + { + return await GetAsync("/blocks/latest", cancellationToken); + } + + /// + /// Get a block by hash or height. + /// + public async Task GetBlockAsync( + string hashOrHeight, + CancellationToken cancellationToken = default) + { + return await GetAsync($"/blocks/{hashOrHeight}", cancellationToken); + } + + /// + /// Get a block header by hash or height. + /// + public async Task GetBlockHeaderAsync( + string hashOrHeight, + CancellationToken cancellationToken = default) + { + return await GetAsync($"/blocks/{hashOrHeight}/header", cancellationToken); + } + + /// + /// Get a range of blocks. + /// + public async Task> GetBlocksAsync( + long startHeight, + long endHeight, + CancellationToken cancellationToken = default) + { + return await GetAsync>( + $"/blocks?start={startHeight}&end={endHeight}", + cancellationToken); + } + + /// + /// Get a transaction by ID. + /// + public async Task GetTransactionAsync( + string txid, + CancellationToken cancellationToken = default) + { + return await GetAsync($"/transactions/{txid}", cancellationToken); + } + + /// + /// Get raw transaction hex. + /// + public async Task GetRawTransactionAsync( + string txid, + CancellationToken cancellationToken = default) + { + var response = await GetAsync($"/transactions/{txid}/raw", cancellationToken); + return response.GetProperty("hex").GetString() ?? throw new RpcException("Invalid response"); + } + + /// + /// Send a raw transaction. + /// + public async Task SendRawTransactionAsync( + string hex, + CancellationToken cancellationToken = default) + { + var request = new { hex }; + return await PostAsync("/transactions/send", request, cancellationToken); + } + + /// + /// Decode a raw transaction. + /// + public async Task DecodeRawTransactionAsync( + string hex, + CancellationToken cancellationToken = default) + { + var request = new { hex }; + return await PostAsync("/transactions/decode", request, cancellationToken); + } + + /// + /// Get transactions for an address. + /// + public async Task> GetAddressTransactionsAsync( + string address, + int limit = 20, + int offset = 0, + CancellationToken cancellationToken = default) + { + return await GetAsync>( + $"/addresses/{address}/transactions?limit={limit}&offset={offset}", + cancellationToken); + } + + /// + /// Estimate transaction fee. + /// + public async Task EstimateFeeAsync( + RpcPriority priority = RpcPriority.Medium, + CancellationToken cancellationToken = default) + { + return await GetAsync( + $"/fees/estimate?priority={priority.ToString().ToLower()}", + cancellationToken); + } + + /// + /// Get all fee estimates. + /// + public async Task> GetAllFeeEstimatesAsync( + CancellationToken cancellationToken = default) + { + var estimates = await GetAsync>("/fees/estimates", cancellationToken); + return estimates.ToDictionary(e => e.Priority, e => e); + } + + /// + /// Get chain information. + /// + public async Task GetChainInfoAsync(CancellationToken cancellationToken = default) + { + return await GetAsync("/chain/info", cancellationToken); + } + + /// + /// Get mempool information. + /// + public async Task GetMempoolInfoAsync(CancellationToken cancellationToken = default) + { + return await GetAsync("/mempool/info", cancellationToken); + } + + /// + /// Get mempool transaction IDs. + /// + public async Task> GetMempoolTransactionsAsync( + int limit = 100, + CancellationToken cancellationToken = default) + { + return await GetAsync>($"/mempool/transactions?limit={limit}", cancellationToken); + } + + /// + /// Subscribe to new blocks. + /// + public async Task SubscribeBlocksAsync( + Action callback, + CancellationToken cancellationToken = default) + { + await EnsureWebSocketConnectedAsync(cancellationToken); + + var subscriptionId = Guid.NewGuid().ToString(); + _subscriptionCallbacks[subscriptionId] = element => + { + var block = element.Deserialize(_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); + }); + } + + /// + /// Subscribe to address transactions. + /// + public async Task SubscribeAddressAsync( + string address, + Action callback, + CancellationToken cancellationToken = default) + { + await EnsureWebSocketConnectedAsync(cancellationToken); + + var subscriptionId = Guid.NewGuid().ToString(); + _subscriptionCallbacks[subscriptionId] = element => + { + var tx = element.Deserialize(_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); + }); + } + + /// + /// Subscribe to mempool transactions. + /// + public async Task SubscribeMempoolAsync( + Action callback, + CancellationToken cancellationToken = default) + { + await EnsureWebSocketConnectedAsync(cancellationToken); + + var subscriptionId = Guid.NewGuid().ToString(); + _subscriptionCallbacks[subscriptionId] = element => + { + var tx = element.Deserialize(_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 GetAsync(string path, CancellationToken cancellationToken) + { + return await ExecuteWithRetryAsync(async () => + { + var response = await _httpClient.GetAsync(path, cancellationToken); + await EnsureSuccessAsync(response); + return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken) + ?? throw new RpcException("Invalid response"); + }); + } + + private async Task PostAsync(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(_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 ExecuteWithRetryAsync(Func> 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); + } +} diff --git a/sdk/csharp/Synor.Sdk/Rpc/Types.cs b/sdk/csharp/Synor.Sdk/Rpc/Types.cs new file mode 100644 index 0000000..f249417 --- /dev/null +++ b/sdk/csharp/Synor.Sdk/Rpc/Types.cs @@ -0,0 +1,222 @@ +using System.Text.Json.Serialization; + +namespace Synor.Sdk.Rpc; + +/// +/// Network environment. +/// +public enum RpcNetwork +{ + [JsonPropertyName("mainnet")] Mainnet, + [JsonPropertyName("testnet")] Testnet, + [JsonPropertyName("devnet")] Devnet +} + +/// +/// Transaction priority. +/// +public enum RpcPriority +{ + [JsonPropertyName("low")] Low, + [JsonPropertyName("medium")] Medium, + [JsonPropertyName("high")] High +} + +/// +/// Transaction status. +/// +public enum TransactionStatus +{ + [JsonPropertyName("pending")] Pending, + [JsonPropertyName("confirmed")] Confirmed, + [JsonPropertyName("failed")] Failed +} + +/// +/// RPC configuration. +/// +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; +} + +/// +/// Block header information. +/// +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 +); + +/// +/// Script public key. +/// +public record ScriptPubKey( + [property: JsonPropertyName("asm")] string Asm, + [property: JsonPropertyName("hex")] string Hex, + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("address")] string? Address = null +); + +/// +/// Transaction input. +/// +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? Witness = null +); + +/// +/// Transaction output. +/// +public record RpcTransactionOutput( + [property: JsonPropertyName("value")] long Value, + [property: JsonPropertyName("n")] int N, + [property: JsonPropertyName("script_pubkey")] ScriptPubKey ScriptPubKey +); + +/// +/// A blockchain transaction. +/// +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 Inputs, + [property: JsonPropertyName("outputs")] List 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 +); + +/// +/// A blockchain block. +/// +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 Transactions, + [property: JsonPropertyName("size")] int Size, + [property: JsonPropertyName("weight")] int Weight +); + +/// +/// Chain information. +/// +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 +); + +/// +/// Mempool information. +/// +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 +); + +/// +/// Fee estimate. +/// +public record FeeEstimate( + [property: JsonPropertyName("priority")] RpcPriority Priority, + [property: JsonPropertyName("fee_rate")] long FeeRate, + [property: JsonPropertyName("estimated_blocks")] int EstimatedBlocks +); + +/// +/// Submit result. +/// +public record SubmitResult( + [property: JsonPropertyName("txid")] string Txid, + [property: JsonPropertyName("accepted")] bool Accepted, + [property: JsonPropertyName("reason")] string? Reason = null +); + +/// +/// Subscription handle. +/// +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); + } +} + +/// +/// RPC exception. +/// +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; + } +} diff --git a/sdk/csharp/Synor.Sdk/Storage/SynorStorage.cs b/sdk/csharp/Synor.Sdk/Storage/SynorStorage.cs new file mode 100644 index 0000000..76b6ba6 --- /dev/null +++ b/sdk/csharp/Synor.Sdk/Storage/SynorStorage.cs @@ -0,0 +1,392 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; + +namespace Synor.Sdk.Storage; + +/// +/// Synor Storage SDK client for C#. +/// +/// Provides decentralized storage operations including upload, download, +/// pinning, and directory management. +/// +/// +/// +/// 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(); +/// +/// +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 + }; + } + + /// + /// Upload data to storage. + /// + public async Task 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(_jsonOptions, cancellationToken) + ?? throw new StorageException("Invalid response"); + }); + } + + /// + /// Upload a stream to storage. + /// + public async Task 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(_jsonOptions, cancellationToken) + ?? throw new StorageException("Invalid response"); + }); + } + + /// + /// Upload a directory of files. + /// + public async Task UploadDirectoryAsync( + IEnumerable 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(_jsonOptions, cancellationToken) + ?? throw new StorageException("Invalid response"); + }); + } + + /// + /// Download content by CID. + /// + public async Task 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); + }); + } + + /// + /// Download content as a stream. + /// + public async Task 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); + } + + /// + /// Get gateway URL for a CID. + /// + public string GetGatewayUrl(string cid, string? path = null) + { + var url = $"{_config.Gateway}/ipfs/{cid}"; + if (!string.IsNullOrEmpty(path)) + { + url += $"/{path.TrimStart('/')}"; + } + return url; + } + + /// + /// Pin content by CID. + /// + public async Task PinAsync( + PinRequest request, + CancellationToken cancellationToken = default) + { + return await PostAsync("/pins", request, cancellationToken); + } + + /// + /// Unpin content. + /// + 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; + }); + } + + /// + /// Get pin status. + /// + public async Task GetPinStatusAsync( + string cid, + CancellationToken cancellationToken = default) + { + return await GetAsync($"/pins/{cid}", cancellationToken); + } + + /// + /// List pins. + /// + public async Task> 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>(query, cancellationToken); + } + + /// + /// Create a CAR file from entries. + /// + public async Task CreateCarAsync( + IEnumerable 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("/car/create", request, cancellationToken); + } + + /// + /// Import a CAR file. + /// + public async Task> 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(_jsonOptions, cancellationToken); + return result.GetProperty("cids").EnumerateArray() + .Select(e => e.GetString()!) + .ToList(); + }); + } + + /// + /// Export content as a CAR file. + /// + public async Task 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); + }); + } + + /// + /// List directory contents. + /// + public async Task> ListDirectoryAsync( + string cid, + CancellationToken cancellationToken = default) + { + return await GetAsync>($"/directory/{cid}", cancellationToken); + } + + /// + /// Get storage statistics. + /// + public async Task GetStatsAsync(CancellationToken cancellationToken = default) + { + return await GetAsync("/stats", cancellationToken); + } + + private async Task GetAsync(string path, CancellationToken cancellationToken) + { + return await ExecuteWithRetryAsync(async () => + { + var response = await _httpClient.GetAsync(path, cancellationToken); + await EnsureSuccessAsync(response); + return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken) + ?? throw new StorageException("Invalid response"); + }); + } + + private async Task PostAsync(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(_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 ExecuteWithRetryAsync(Func> 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); + } +} diff --git a/sdk/csharp/Synor.Sdk/Storage/Types.cs b/sdk/csharp/Synor.Sdk/Storage/Types.cs new file mode 100644 index 0000000..89aea75 --- /dev/null +++ b/sdk/csharp/Synor.Sdk/Storage/Types.cs @@ -0,0 +1,139 @@ +using System.Text.Json.Serialization; + +namespace Synor.Sdk.Storage; + +/// +/// Storage network. +/// +public enum StorageNetwork +{ + [JsonPropertyName("mainnet")] Mainnet, + [JsonPropertyName("testnet")] Testnet, + [JsonPropertyName("devnet")] Devnet +} + +/// +/// Pin status. +/// +public enum PinStatus +{ + [JsonPropertyName("queued")] Queued, + [JsonPropertyName("pinning")] Pinning, + [JsonPropertyName("pinned")] Pinned, + [JsonPropertyName("failed")] Failed +} + +/// +/// Storage configuration. +/// +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; +} + +/// +/// Upload options. +/// +public class UploadOptions +{ + public string? Name { get; init; } + public string? ContentType { get; init; } + public bool Pin { get; init; } = true; + public Dictionary? Metadata { get; init; } + public bool WrapWithDirectory { get; init; } = false; +} + +/// +/// Upload response. +/// +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 +); + +/// +/// Pin request. +/// +public class PinRequest +{ + public required string Cid { get; init; } + public string? Name { get; init; } + public Dictionary? Metadata { get; init; } + public string[]? Origins { get; init; } +} + +/// +/// Pin information. +/// +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? Metadata = null +); + +/// +/// File entry for directory operations. +/// +public class FileEntry +{ + public required string Path { get; init; } + public required byte[] Content { get; init; } + public string? ContentType { get; init; } +} + +/// +/// Directory entry. +/// +public record DirectoryEntry( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("cid")] string Cid, + [property: JsonPropertyName("size")] long Size, + [property: JsonPropertyName("type")] string Type +); + +/// +/// CAR file. +/// +public record CarFile( + [property: JsonPropertyName("root_cid")] string RootCid, + [property: JsonPropertyName("data")] byte[] Data, + [property: JsonPropertyName("size")] long Size +); + +/// +/// Storage statistics. +/// +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 +); + +/// +/// Storage exception. +/// +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; + } +} diff --git a/sdk/csharp/Synor.Sdk/Synor.Sdk.csproj b/sdk/csharp/Synor.Sdk/Synor.Sdk.csproj new file mode 100644 index 0000000..8bdf3ba --- /dev/null +++ b/sdk/csharp/Synor.Sdk/Synor.Sdk.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + 0.1.0 + Synor + Synor + C# SDK for Synor - Compute, Wallet, RPC, and Storage + Synor.Sdk + MIT + https://synor.cc + https://github.com/synor/synor + synor;blockchain;wallet;rpc;storage;ipfs + + + + + + + diff --git a/sdk/csharp/Synor.Sdk/Wallet/SynorWallet.cs b/sdk/csharp/Synor.Sdk/Wallet/SynorWallet.cs new file mode 100644 index 0000000..c448c57 --- /dev/null +++ b/sdk/csharp/Synor.Sdk/Wallet/SynorWallet.cs @@ -0,0 +1,256 @@ +using System.Net.Http.Json; +using System.Text.Json; + +namespace Synor.Sdk.Wallet; + +/// +/// Synor Wallet SDK client for C#. +/// +/// Provides key management, transaction signing, and balance queries +/// for the Synor blockchain. +/// +/// +/// +/// 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(); +/// +/// +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 + }; + } + + /// + /// Create a new wallet. + /// + public async Task CreateWalletAsync( + WalletType type = WalletType.Standard, + CancellationToken cancellationToken = default) + { + var request = new { type = type.ToString().ToLower(), network = _config.Network.ToString().ToLower() }; + return await PostAsync("/wallets", request, cancellationToken); + } + + /// + /// Import a wallet from mnemonic. + /// + public async Task ImportWalletAsync( + string mnemonic, + string? passphrase = null, + CancellationToken cancellationToken = default) + { + var request = new + { + mnemonic, + passphrase, + network = _config.Network.ToString().ToLower() + }; + return await PostAsync("/wallets/import", request, cancellationToken); + } + + /// + /// Get a wallet by ID. + /// + public async Task GetWalletAsync( + string walletId, + CancellationToken cancellationToken = default) + { + return await GetAsync($"/wallets/{walletId}", cancellationToken); + } + + /// + /// Generate a new address for a wallet. + /// + public async Task
GenerateAddressAsync( + string walletId, + bool isChange = false, + CancellationToken cancellationToken = default) + { + var request = new { is_change = isChange }; + return await PostAsync
($"/wallets/{walletId}/addresses", request, cancellationToken); + } + + /// + /// Get a stealth address for privacy transactions. + /// + public async Task GetStealthAddressAsync( + string walletId, + CancellationToken cancellationToken = default) + { + return await GetAsync($"/wallets/{walletId}/stealth-address", cancellationToken); + } + + /// + /// Sign a transaction. + /// + public async Task SignTransactionAsync( + string walletId, + Transaction transaction, + CancellationToken cancellationToken = default) + { + var request = new { wallet_id = walletId, transaction }; + return await PostAsync("/transactions/sign", request, cancellationToken); + } + + /// + /// Sign a message. + /// + public async Task SignMessageAsync( + string walletId, + string message, + int addressIndex = 0, + CancellationToken cancellationToken = default) + { + var request = new { wallet_id = walletId, message, address_index = addressIndex }; + return await PostAsync("/messages/sign", request, cancellationToken); + } + + /// + /// Verify a message signature. + /// + public async Task VerifyMessageAsync( + string message, + string signature, + string address, + CancellationToken cancellationToken = default) + { + var request = new { message, signature, address }; + var response = await PostAsync("/messages/verify", request, cancellationToken); + return response.GetProperty("valid").GetBoolean(); + } + + /// + /// Get balance for an address. + /// + public async Task GetBalanceAsync( + string address, + CancellationToken cancellationToken = default) + { + return await GetAsync($"/addresses/{address}/balance", cancellationToken); + } + + /// + /// Get UTXOs for an address. + /// + public async Task> GetUTXOsAsync( + string address, + int minConfirmations = 1, + CancellationToken cancellationToken = default) + { + return await GetAsync>( + $"/addresses/{address}/utxos?min_confirmations={minConfirmations}", + cancellationToken); + } + + /// + /// Estimate transaction fee. + /// + public async Task EstimateFeeAsync( + Priority priority = Priority.Medium, + CancellationToken cancellationToken = default) + { + var response = await GetAsync( + $"/fees/estimate?priority={priority.ToString().ToLower()}", + cancellationToken); + return response.GetProperty("fee_per_byte").GetInt64(); + } + + private async Task GetAsync(string path, CancellationToken cancellationToken) + { + return await ExecuteWithRetryAsync(async () => + { + var response = await _httpClient.GetAsync(path, cancellationToken); + await EnsureSuccessAsync(response); + return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken) + ?? throw new WalletException("Invalid response"); + }); + } + + private async Task PostAsync(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(_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 ExecuteWithRetryAsync(Func> 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); + } +} diff --git a/sdk/csharp/Synor.Sdk/Wallet/Types.cs b/sdk/csharp/Synor.Sdk/Wallet/Types.cs new file mode 100644 index 0000000..992b1ef --- /dev/null +++ b/sdk/csharp/Synor.Sdk/Wallet/Types.cs @@ -0,0 +1,167 @@ +using System.Text.Json.Serialization; + +namespace Synor.Sdk.Wallet; + +/// +/// Network environment for wallet operations. +/// +public enum Network +{ + [JsonPropertyName("mainnet")] Mainnet, + [JsonPropertyName("testnet")] Testnet, + [JsonPropertyName("devnet")] Devnet +} + +/// +/// Type of wallet to create. +/// +public enum WalletType +{ + [JsonPropertyName("standard")] Standard, + [JsonPropertyName("multisig")] Multisig, + [JsonPropertyName("hardware")] Hardware, + [JsonPropertyName("stealth")] Stealth +} + +/// +/// Transaction priority for fee estimation. +/// +public enum Priority +{ + [JsonPropertyName("low")] Low, + [JsonPropertyName("medium")] Medium, + [JsonPropertyName("high")] High +} + +/// +/// Configuration for the wallet client. +/// +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; +} + +/// +/// A wallet address. +/// +public record Address( + [property: JsonPropertyName("address")] string AddressString, + [property: JsonPropertyName("index")] int Index, + [property: JsonPropertyName("is_change")] bool IsChange = false +); + +/// +/// A Synor wallet with addresses and keys. +/// +public record Wallet( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("type")] WalletType Type, + [property: JsonPropertyName("network")] Network Network, + [property: JsonPropertyName("addresses")] List
Addresses, + [property: JsonPropertyName("created_at")] string CreatedAt +); + +/// +/// Stealth address for privacy transactions. +/// +public record StealthAddress( + [property: JsonPropertyName("spend_public_key")] string SpendPublicKey, + [property: JsonPropertyName("view_public_key")] string ViewPublicKey, + [property: JsonPropertyName("one_time_address")] string OneTimeAddress +); + +/// +/// Result of wallet creation. +/// +public record CreateWalletResult( + [property: JsonPropertyName("wallet")] Wallet Wallet, + [property: JsonPropertyName("mnemonic")] string? Mnemonic = null +); + +/// +/// Transaction input (UTXO reference). +/// +public record TransactionInput( + [property: JsonPropertyName("txid")] string Txid, + [property: JsonPropertyName("vout")] int Vout, + [property: JsonPropertyName("amount")] long Amount +); + +/// +/// Transaction output. +/// +public record TransactionOutput( + [property: JsonPropertyName("address")] string Address, + [property: JsonPropertyName("amount")] long Amount +); + +/// +/// Transaction for signing. +/// +public record Transaction( + List Inputs, + List Outputs, + long? Fee = null, + Priority Priority = Priority.Medium +); + +/// +/// Signed transaction ready for broadcast. +/// +public record SignedTransaction( + [property: JsonPropertyName("txid")] string Txid, + [property: JsonPropertyName("hex")] string Hex, + [property: JsonPropertyName("size")] int Size, + [property: JsonPropertyName("fee")] long Fee +); + +/// +/// Cryptographic signature. +/// +public record Signature( + [property: JsonPropertyName("signature")] string SignatureString, + [property: JsonPropertyName("address")] string Address, + [property: JsonPropertyName("recovery_id")] int? RecoveryId = null +); + +/// +/// Unspent transaction output. +/// +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 +); + +/// +/// Wallet balance information. +/// +public record Balance( + [property: JsonPropertyName("confirmed")] long Confirmed, + [property: JsonPropertyName("unconfirmed")] long Unconfirmed, + [property: JsonPropertyName("total")] long Total +); + +/// +/// Exception thrown by wallet operations. +/// +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; + } +} diff --git a/sdk/flutter/.dart_tool/package_config.json b/sdk/flutter/.dart_tool/package_config.json index 590534a..7f0861e 100644 --- a/sdk/flutter/.dart_tool/package_config.json +++ b/sdk/flutter/.dart_tool/package_config.json @@ -404,7 +404,7 @@ "languageVersion": "3.4" }, { - "name": "synor_compute", + "name": "synor_sdk", "rootUri": "../", "packageUri": "lib/", "languageVersion": "3.0" diff --git a/sdk/flutter/.dart_tool/package_graph.json b/sdk/flutter/.dart_tool/package_graph.json index c873863..49f7c00 100644 --- a/sdk/flutter/.dart_tool/package_graph.json +++ b/sdk/flutter/.dart_tool/package_graph.json @@ -1,10 +1,10 @@ { "roots": [ - "synor_compute" + "synor_sdk" ], "packages": [ { - "name": "synor_compute", + "name": "synor_sdk", "version": "0.1.0", "dependencies": [ "collection", @@ -22,242 +22,6 @@ "mockito" ] }, - { - "name": "flutter_test", - "version": "0.0.0", - "dependencies": [ - "clock", - "collection", - "fake_async", - "flutter", - "leak_tracker_flutter_testing", - "matcher", - "meta", - "path", - "stack_trace", - "stream_channel", - "test_api", - "vector_math" - ] - }, - { - "name": "collection", - "version": "1.19.1", - "dependencies": [] - }, - { - "name": "flutter", - "version": "0.0.0", - "dependencies": [ - "characters", - "collection", - "material_color_utilities", - "meta", - "sky_engine", - "vector_math" - ] - }, - { - "name": "vector_math", - "version": "2.2.0", - "dependencies": [] - }, - { - "name": "test_api", - "version": "0.7.7", - "dependencies": [ - "async", - "boolean_selector", - "collection", - "meta", - "source_span", - "stack_trace", - "stream_channel", - "string_scanner", - "term_glyph" - ] - }, - { - "name": "stream_channel", - "version": "2.1.4", - "dependencies": [ - "async" - ] - }, - { - "name": "stack_trace", - "version": "1.12.1", - "dependencies": [ - "path" - ] - }, - { - "name": "path", - "version": "1.9.1", - "dependencies": [] - }, - { - "name": "meta", - "version": "1.17.0", - "dependencies": [] - }, - { - "name": "matcher", - "version": "0.12.17", - "dependencies": [ - "async", - "meta", - "stack_trace", - "term_glyph", - "test_api" - ] - }, - { - "name": "leak_tracker_flutter_testing", - "version": "3.0.10", - "dependencies": [ - "flutter", - "leak_tracker", - "leak_tracker_testing", - "matcher", - "meta" - ] - }, - { - "name": "fake_async", - "version": "1.3.3", - "dependencies": [ - "clock", - "collection" - ] - }, - { - "name": "clock", - "version": "1.1.2", - "dependencies": [] - }, - { - "name": "sky_engine", - "version": "0.0.0", - "dependencies": [] - }, - { - "name": "material_color_utilities", - "version": "0.11.1", - "dependencies": [ - "collection" - ] - }, - { - "name": "characters", - "version": "1.4.0", - "dependencies": [] - }, - { - "name": "json_annotation", - "version": "4.9.0", - "dependencies": [ - "meta" - ] - }, - { - "name": "leak_tracker_testing", - "version": "3.0.2", - "dependencies": [ - "leak_tracker", - "matcher", - "meta" - ] - }, - { - "name": "leak_tracker", - "version": "11.0.2", - "dependencies": [ - "clock", - "collection", - "meta", - "path", - "vm_service" - ] - }, - { - "name": "flutter_lints", - "version": "3.0.2", - "dependencies": [ - "lints" - ] - }, - { - "name": "lints", - "version": "3.0.0", - "dependencies": [] - }, - { - "name": "term_glyph", - "version": "1.2.2", - "dependencies": [] - }, - { - "name": "boolean_selector", - "version": "2.1.2", - "dependencies": [ - "source_span", - "string_scanner" - ] - }, - { - "name": "async", - "version": "2.13.0", - "dependencies": [ - "collection", - "meta" - ] - }, - { - "name": "crypto", - "version": "3.0.7", - "dependencies": [ - "typed_data" - ] - }, - { - "name": "typed_data", - "version": "1.4.0", - "dependencies": [ - "collection" - ] - }, - { - "name": "web_socket_channel", - "version": "2.4.5", - "dependencies": [ - "async", - "crypto", - "stream_channel", - "web" - ] - }, - { - "name": "web", - "version": "0.5.1", - "dependencies": [] - }, - { - "name": "string_scanner", - "version": "1.4.1", - "dependencies": [ - "source_span" - ] - }, - { - "name": "source_span", - "version": "1.10.1", - "dependencies": [ - "collection", - "path", - "term_glyph" - ] - }, { "name": "mockito", "version": "5.6.3", @@ -274,191 +38,6 @@ "test_api" ] }, - { - "name": "analyzer", - "version": "10.0.1", - "dependencies": [ - "_fe_analyzer_shared", - "collection", - "convert", - "crypto", - "glob", - "meta", - "package_config", - "path", - "pub_semver", - "source_span", - "watcher", - "yaml" - ] - }, - { - "name": "_fe_analyzer_shared", - "version": "93.0.0", - "dependencies": [ - "meta", - "source_span" - ] - }, - { - "name": "pub_semver", - "version": "2.2.0", - "dependencies": [ - "collection" - ] - }, - { - "name": "yaml", - "version": "3.1.3", - "dependencies": [ - "collection", - "source_span", - "string_scanner" - ] - }, - { - "name": "package_config", - "version": "2.2.0", - "dependencies": [ - "path" - ] - }, - { - "name": "convert", - "version": "3.1.2", - "dependencies": [ - "typed_data" - ] - }, - { - "name": "watcher", - "version": "1.2.1", - "dependencies": [ - "async", - "path" - ] - }, - { - "name": "glob", - "version": "2.1.3", - "dependencies": [ - "async", - "collection", - "file", - "path", - "string_scanner" - ] - }, - { - "name": "file", - "version": "7.0.1", - "dependencies": [ - "meta", - "path" - ] - }, - { - "name": "source_gen", - "version": "4.2.0", - "dependencies": [ - "analyzer", - "async", - "build", - "dart_style", - "glob", - "path", - "pub_semver", - "source_span", - "yaml" - ] - }, - { - "name": "dart_style", - "version": "3.1.4", - "dependencies": [ - "analyzer", - "args", - "collection", - "package_config", - "path", - "pub_semver", - "source_span", - "yaml" - ] - }, - { - "name": "args", - "version": "2.7.0", - "dependencies": [] - }, - { - "name": "code_builder", - "version": "4.11.1", - "dependencies": [ - "built_collection", - "built_value", - "collection", - "matcher", - "meta" - ] - }, - { - "name": "built_collection", - "version": "5.1.1", - "dependencies": [] - }, - { - "name": "build", - "version": "4.0.4", - "dependencies": [ - "analyzer", - "crypto", - "glob", - "logging", - "package_config", - "path" - ] - }, - { - "name": "logging", - "version": "1.3.0", - "dependencies": [] - }, - { - "name": "built_value", - "version": "8.12.3", - "dependencies": [ - "built_collection", - "collection", - "fixnum", - "meta" - ] - }, - { - "name": "fixnum", - "version": "1.1.1", - "dependencies": [] - }, - { - "name": "http", - "version": "1.6.0", - "dependencies": [ - "async", - "http_parser", - "meta", - "web" - ] - }, - { - "name": "http_parser", - "version": "4.1.2", - "dependencies": [ - "collection", - "source_span", - "string_scanner", - "typed_data" - ] - }, { "name": "json_serializable", "version": "6.11.4", @@ -477,49 +56,6 @@ "source_helper" ] }, - { - "name": "build_config", - "version": "1.2.0", - "dependencies": [ - "checked_yaml", - "json_annotation", - "path", - "pubspec_parse" - ] - }, - { - "name": "source_helper", - "version": "1.3.10", - "dependencies": [ - "analyzer", - "source_gen" - ] - }, - { - "name": "checked_yaml", - "version": "2.0.4", - "dependencies": [ - "json_annotation", - "source_span", - "yaml" - ] - }, - { - "name": "pubspec_parse", - "version": "1.5.0", - "dependencies": [ - "checked_yaml", - "collection", - "json_annotation", - "pub_semver", - "yaml" - ] - }, - { - "name": "vm_service", - "version": "15.0.2", - "dependencies": [] - }, { "name": "build_runner", "version": "2.10.5", @@ -558,11 +94,246 @@ ] }, { - "name": "pool", - "version": "1.5.2", + "name": "flutter_lints", + "version": "3.0.2", + "dependencies": [ + "lints" + ] + }, + { + "name": "flutter_test", + "version": "0.0.0", + "dependencies": [ + "clock", + "collection", + "fake_async", + "flutter", + "leak_tracker_flutter_testing", + "matcher", + "meta", + "path", + "stack_trace", + "stream_channel", + "test_api", + "vector_math" + ] + }, + { + "name": "collection", + "version": "1.19.1", + "dependencies": [] + }, + { + "name": "crypto", + "version": "3.0.7", + "dependencies": [ + "typed_data" + ] + }, + { + "name": "json_annotation", + "version": "4.9.0", + "dependencies": [ + "meta" + ] + }, + { + "name": "web_socket_channel", + "version": "2.4.5", "dependencies": [ "async", - "stack_trace" + "crypto", + "stream_channel", + "web" + ] + }, + { + "name": "http", + "version": "1.6.0", + "dependencies": [ + "async", + "http_parser", + "meta", + "web" + ] + }, + { + "name": "flutter", + "version": "0.0.0", + "dependencies": [ + "characters", + "collection", + "material_color_utilities", + "meta", + "sky_engine", + "vector_math" + ] + }, + { + "name": "test_api", + "version": "0.7.7", + "dependencies": [ + "async", + "boolean_selector", + "collection", + "meta", + "source_span", + "stack_trace", + "stream_channel", + "string_scanner", + "term_glyph" + ] + }, + { + "name": "source_gen", + "version": "4.2.0", + "dependencies": [ + "analyzer", + "async", + "build", + "dart_style", + "glob", + "path", + "pub_semver", + "source_span", + "yaml" + ] + }, + { + "name": "path", + "version": "1.9.1", + "dependencies": [] + }, + { + "name": "meta", + "version": "1.17.0", + "dependencies": [] + }, + { + "name": "matcher", + "version": "0.12.17", + "dependencies": [ + "async", + "meta", + "stack_trace", + "term_glyph", + "test_api" + ] + }, + { + "name": "dart_style", + "version": "3.1.4", + "dependencies": [ + "analyzer", + "args", + "collection", + "package_config", + "path", + "pub_semver", + "source_span", + "yaml" + ] + }, + { + "name": "code_builder", + "version": "4.11.1", + "dependencies": [ + "built_collection", + "built_value", + "collection", + "matcher", + "meta" + ] + }, + { + "name": "build", + "version": "4.0.4", + "dependencies": [ + "analyzer", + "crypto", + "glob", + "logging", + "package_config", + "path" + ] + }, + { + "name": "analyzer", + "version": "10.0.1", + "dependencies": [ + "_fe_analyzer_shared", + "collection", + "convert", + "crypto", + "glob", + "meta", + "package_config", + "path", + "pub_semver", + "source_span", + "watcher", + "yaml" + ] + }, + { + "name": "source_helper", + "version": "1.3.10", + "dependencies": [ + "analyzer", + "source_gen" + ] + }, + { + "name": "pubspec_parse", + "version": "1.5.0", + "dependencies": [ + "checked_yaml", + "collection", + "json_annotation", + "pub_semver", + "yaml" + ] + }, + { + "name": "pub_semver", + "version": "2.2.0", + "dependencies": [ + "collection" + ] + }, + { + "name": "build_config", + "version": "1.2.0", + "dependencies": [ + "checked_yaml", + "json_annotation", + "path", + "pubspec_parse" + ] + }, + { + "name": "async", + "version": "2.13.0", + "dependencies": [ + "collection", + "meta" + ] + }, + { + "name": "yaml", + "version": "3.1.3", + "dependencies": [ + "collection", + "source_span", + "string_scanner" + ] + }, + { + "name": "watcher", + "version": "1.2.1", + "dependencies": [ + "async", + "path" ] }, { @@ -571,12 +342,51 @@ "dependencies": [] }, { - "name": "graphs", - "version": "2.3.2", + "name": "shelf_web_socket", + "version": "3.0.0", "dependencies": [ - "collection" + "shelf", + "stream_channel", + "web_socket_channel" ] }, + { + "name": "shelf", + "version": "1.4.2", + "dependencies": [ + "async", + "collection", + "http_parser", + "path", + "stack_trace", + "stream_channel" + ] + }, + { + "name": "pool", + "version": "1.5.2", + "dependencies": [ + "async", + "stack_trace" + ] + }, + { + "name": "package_config", + "version": "2.2.0", + "dependencies": [ + "path" + ] + }, + { + "name": "mime", + "version": "2.0.0", + "dependencies": [] + }, + { + "name": "logging", + "version": "1.3.0", + "dependencies": [] + }, { "name": "io", "version": "1.0.5", @@ -593,6 +403,46 @@ "async" ] }, + { + "name": "graphs", + "version": "2.3.2", + "dependencies": [ + "collection" + ] + }, + { + "name": "glob", + "version": "2.1.3", + "dependencies": [ + "async", + "collection", + "file", + "path", + "string_scanner" + ] + }, + { + "name": "convert", + "version": "3.1.2", + "dependencies": [ + "typed_data" + ] + }, + { + "name": "built_value", + "version": "8.12.3", + "dependencies": [ + "built_collection", + "collection", + "fixnum", + "meta" + ] + }, + { + "name": "built_collection", + "version": "5.1.1", + "dependencies": [] + }, { "name": "build_daemon", "version": "4.1.1", @@ -612,30 +462,180 @@ ] }, { - "name": "shelf_web_socket", - "version": "3.0.0", - "dependencies": [ - "shelf", - "stream_channel", - "web_socket_channel" - ] - }, - { - "name": "mime", - "version": "2.0.0", + "name": "args", + "version": "2.7.0", "dependencies": [] }, { - "name": "shelf", - "version": "1.4.2", + "name": "lints", + "version": "3.0.0", + "dependencies": [] + }, + { + "name": "stream_channel", + "version": "2.1.4", "dependencies": [ - "async", - "collection", - "http_parser", - "path", - "stack_trace", - "stream_channel" + "async" ] + }, + { + "name": "leak_tracker_flutter_testing", + "version": "3.0.10", + "dependencies": [ + "flutter", + "leak_tracker", + "leak_tracker_testing", + "matcher", + "meta" + ] + }, + { + "name": "vector_math", + "version": "2.2.0", + "dependencies": [] + }, + { + "name": "stack_trace", + "version": "1.12.1", + "dependencies": [ + "path" + ] + }, + { + "name": "clock", + "version": "1.1.2", + "dependencies": [] + }, + { + "name": "fake_async", + "version": "1.3.3", + "dependencies": [ + "clock", + "collection" + ] + }, + { + "name": "typed_data", + "version": "1.4.0", + "dependencies": [ + "collection" + ] + }, + { + "name": "web", + "version": "0.5.1", + "dependencies": [] + }, + { + "name": "http_parser", + "version": "4.1.2", + "dependencies": [ + "collection", + "source_span", + "string_scanner", + "typed_data" + ] + }, + { + "name": "sky_engine", + "version": "0.0.0", + "dependencies": [] + }, + { + "name": "material_color_utilities", + "version": "0.11.1", + "dependencies": [ + "collection" + ] + }, + { + "name": "characters", + "version": "1.4.0", + "dependencies": [] + }, + { + "name": "term_glyph", + "version": "1.2.2", + "dependencies": [] + }, + { + "name": "string_scanner", + "version": "1.4.1", + "dependencies": [ + "source_span" + ] + }, + { + "name": "source_span", + "version": "1.10.1", + "dependencies": [ + "collection", + "path", + "term_glyph" + ] + }, + { + "name": "boolean_selector", + "version": "2.1.2", + "dependencies": [ + "source_span", + "string_scanner" + ] + }, + { + "name": "_fe_analyzer_shared", + "version": "93.0.0", + "dependencies": [ + "meta", + "source_span" + ] + }, + { + "name": "checked_yaml", + "version": "2.0.4", + "dependencies": [ + "json_annotation", + "source_span", + "yaml" + ] + }, + { + "name": "file", + "version": "7.0.1", + "dependencies": [ + "meta", + "path" + ] + }, + { + "name": "fixnum", + "version": "1.1.1", + "dependencies": [] + }, + { + "name": "leak_tracker_testing", + "version": "3.0.2", + "dependencies": [ + "leak_tracker", + "matcher", + "meta" + ] + }, + { + "name": "leak_tracker", + "version": "11.0.2", + "dependencies": [ + "clock", + "collection", + "meta", + "path", + "vm_service" + ] + }, + { + "name": "vm_service", + "version": "15.0.2", + "dependencies": [] } ], "configVersion": 1 diff --git a/sdk/flutter/lib/src/rpc/synor_rpc.dart b/sdk/flutter/lib/src/rpc/synor_rpc.dart new file mode 100644 index 0000000..f52d89f --- /dev/null +++ b/sdk/flutter/lib/src/rpc/synor_rpc.dart @@ -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 _subscriptions = {}; + + SynorRpc(this.config) : _client = http.Client(); + + // Block operations + + /// Get the latest block. + Future getLatestBlock() async { + return _get('/blocks/latest', Block.fromJson); + } + + /// Get a block by hash or height. + Future getBlock(String hashOrHeight) async { + return _get('/blocks/$hashOrHeight', Block.fromJson); + } + + /// Get a block header by hash or height. + Future getBlockHeader(String hashOrHeight) async { + return _get('/blocks/$hashOrHeight/header', BlockHeader.fromJson); + } + + /// Get blocks in a range. + Future> getBlocks(int startHeight, int endHeight) async { + return _getList( + '/blocks?start=$startHeight&end=$endHeight', + Block.fromJson, + ); + } + + // Transaction operations + + /// Get a transaction by ID. + Future getTransaction(String txid) async { + return _get('/transactions/$txid', RpcTransaction.fromJson); + } + + /// Get raw transaction hex. + Future getRawTransaction(String txid) async { + final response = await _get>( + '/transactions/$txid/raw', + (json) => json, + ); + return response['hex'] as String; + } + + /// Send a raw transaction. + Future sendRawTransaction(String hex) async { + return _post('/transactions/send', {'hex': hex}, SubmitResult.fromJson); + } + + /// Decode a raw transaction without broadcasting. + Future decodeRawTransaction(String hex) async { + return _post('/transactions/decode', {'hex': hex}, RpcTransaction.fromJson); + } + + /// Get transactions for an address. + Future> 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 estimateFee({RpcPriority priority = RpcPriority.medium}) async { + return _get( + '/fees/estimate?priority=${priority.toJson()}', + FeeEstimate.fromJson, + ); + } + + /// Get all fee estimates. + Future> 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 getChainInfo() async { + return _get('/chain/info', ChainInfo.fromJson); + } + + /// Get mempool information. + Future getMempoolInfo() async { + return _get('/mempool/info', MempoolInfo.fromJson); + } + + /// Get mempool transactions. + Future> 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); + 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); + 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); + 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; + 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 _get(String path, T Function(Map) 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> _getList( + String path, + T Function(Map) 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 jsonList = jsonDecode(response.body); + return jsonList + .map((e) => fromJson(e as Map)) + .toList(); + } + + throw RpcException( + 'HTTP error: ${response.body}', + statusCode: response.statusCode, + ); + }); + } + + Future _post( + String path, + Map body, + T Function(Map) 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 get _headers => { + 'Authorization': 'Bearer ${config.apiKey}', + 'Content-Type': 'application/json', + }; + + T _handleResponse( + http.Response response, + T Function(Map) fromJson, + ) { + if (response.statusCode >= 200 && response.statusCode < 300) { + final json = jsonDecode(response.body) as Map; + return fromJson(json); + } + + throw RpcException( + 'HTTP error: ${response.body}', + statusCode: response.statusCode, + ); + } + + Future _executeWithRetry(Future 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(); + } +} diff --git a/sdk/flutter/lib/src/rpc/types.dart b/sdk/flutter/lib/src/rpc/types.dart new file mode 100644 index 0000000..7f4e5a4 --- /dev/null +++ b/sdk/flutter/lib/src/rpc/types.dart @@ -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 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 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 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)) + .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 inputs; + final List 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 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)) + .toList(), + outputs: (json['outputs'] as List) + .map((e) => RpcTransactionOutput.fromJson(e as Map)) + .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? witness; + + const RpcTransactionInput({ + required this.txid, + required this.vout, + this.scriptSig, + required this.sequence, + this.witness, + }); + + factory RpcTransactionInput.fromJson(Map 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(), + ); + } +} + +/// 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 json) { + return RpcTransactionOutput( + value: json['value'] as int, + n: json['n'] as int, + scriptPubKey: + ScriptPubKey.fromJson(json['script_pubkey'] as Map), + ); + } +} + +/// 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 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 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 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 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 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'; +} diff --git a/sdk/flutter/lib/src/storage/synor_storage.dart b/sdk/flutter/lib/src/storage/synor_storage.dart new file mode 100644 index 0000000..511ee09 --- /dev/null +++ b/sdk/flutter/lib/src/storage/synor_storage.dart @@ -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 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; + return UploadResponse.fromJson(json); + } + + throw StorageException( + 'Upload failed: ${response.body}', + statusCode: response.statusCode, + ); + }); + } + + /// Upload multiple files as a directory. + Future uploadDirectory( + List 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; + return UploadResponse.fromJson(json); + } + + throw StorageException( + 'Directory upload failed: ${response.body}', + statusCode: response.statusCode, + ); + }); + } + + // Download operations + + /// Download content by CID. + Future 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> 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(PinRequest request) async { + return _post('/pins', request.toJson(), Pin.fromJson); + } + + /// Unpin content. + Future unpin(String cid) async { + await _delete('/pins/$cid'); + } + + /// Get pin status. + Future getPinStatus(String cid) async { + return _get('/pins/$cid', Pin.fromJson); + } + + /// List all pins. + Future> 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 createCar(List 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; + return CarFile.fromJson(json); + } + + throw StorageException( + 'CAR creation failed: ${response.body}', + statusCode: response.statusCode, + ); + }); + } + + /// Import a CAR file. + Future> 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; + return (json['cids'] as List).cast(); + } + + throw StorageException( + 'CAR import failed: ${response.body}', + statusCode: response.statusCode, + ); + }); + } + + /// Export content as a CAR file. + Future 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> listDirectory(String cid) async { + return _getList('/content/$cid/ls', DirectoryEntry.fromJson); + } + + // Statistics + + /// Get storage statistics. + Future getStats() async { + return _get('/stats', StorageStats.fromJson); + } + + // HTTP helper methods + + Future _get(String path, T Function(Map) 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> _getList( + String path, + T Function(Map) 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 jsonList = jsonDecode(response.body); + return jsonList + .map((e) => fromJson(e as Map)) + .toList(); + } + + throw StorageException( + 'HTTP error: ${response.body}', + statusCode: response.statusCode, + ); + }); + } + + Future _post( + String path, + Map body, + T Function(Map) 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 _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 get _headers => { + 'Authorization': 'Bearer ${config.apiKey}', + 'Content-Type': 'application/json', + }; + + T _handleResponse( + http.Response response, + T Function(Map) fromJson, + ) { + if (response.statusCode >= 200 && response.statusCode < 300) { + final json = jsonDecode(response.body) as Map; + return fromJson(json); + } + + throw StorageException( + 'HTTP error: ${response.body}', + statusCode: response.statusCode, + ); + } + + Future _executeWithRetry(Future 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(); + } +} diff --git a/sdk/flutter/lib/src/storage/types.dart b/sdk/flutter/lib/src/storage/types.dart new file mode 100644 index 0000000..bd17495 --- /dev/null +++ b/sdk/flutter/lib/src/storage/types.dart @@ -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 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? 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 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(), + ); + } +} + +/// Request to pin content. +class PinRequest { + final String cid; + final String? name; + final int? durationDays; + final List? origins; + final Map? meta; + + const PinRequest({ + required this.cid, + this.name, + this.durationDays, + this.origins, + this.meta, + }); + + Map 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 roots; + final String createdAt; + + const CarFile({ + required this.cid, + required this.size, + required this.roots, + required this.createdAt, + }); + + factory CarFile.fromJson(Map json) { + return CarFile( + cid: json['cid'] as String, + size: json['size'] as int, + roots: (json['roots'] as List).cast(), + 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 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 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'; +} diff --git a/sdk/flutter/lib/src/wallet/synor_wallet.dart b/sdk/flutter/lib/src/wallet/synor_wallet.dart new file mode 100644 index 0000000..646b3a0 --- /dev/null +++ b/sdk/flutter/lib/src/wallet/synor_wallet.dart @@ -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 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 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 getWallet(String walletId) async { + return _get('/wallets/$walletId', Wallet.fromJson); + } + + /// Generate a new address for a wallet. + Future
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 getStealthAddress(String walletId) async { + return _get('/wallets/$walletId/stealth-address', StealthAddress.fromJson); + } + + /// Sign a transaction. + Future 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 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 verifyMessage( + String message, + String signature, + String address, + ) async { + final body = { + 'message': message, + 'signature': signature, + 'address': address, + }; + final response = await _post>( + '/messages/verify', + body, + (json) => json, + ); + return response['valid'] as bool? ?? false; + } + + /// Get the balance for an address. + Future getBalance(String address) async { + return _get('/addresses/$address/balance', Balance.fromJson); + } + + /// Get UTXOs for an address. + Future> getUTXOs(String address, {int minConfirmations = 1}) async { + return _getList( + '/addresses/$address/utxos?min_confirmations=$minConfirmations', + UTXO.fromJson, + ); + } + + /// Estimate transaction fee. + Future estimateFee({Priority priority = Priority.medium}) async { + final response = await _get>( + '/fees/estimate?priority=${priority.toJson()}', + (json) => json, + ); + return response['fee_per_byte'] as int? ?? 0; + } + + // HTTP helper methods + + Future _get(String path, T Function(Map) 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> _getList( + String path, + T Function(Map) 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 jsonList = jsonDecode(response.body); + return jsonList + .map((e) => fromJson(e as Map)) + .toList(); + } + + throw WalletException( + 'HTTP error: ${response.body}', + statusCode: response.statusCode, + ); + }); + } + + Future _post( + String path, + Map body, + T Function(Map) 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 get _headers => { + 'Authorization': 'Bearer ${config.apiKey}', + 'Content-Type': 'application/json', + }; + + T _handleResponse( + http.Response response, + T Function(Map) fromJson, + ) { + if (response.statusCode >= 200 && response.statusCode < 300) { + final json = jsonDecode(response.body) as Map; + return fromJson(json); + } + + throw WalletException( + 'HTTP error: ${response.body}', + statusCode: response.statusCode, + ); + } + + Future _executeWithRetry(Future 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(); + } +} diff --git a/sdk/flutter/lib/src/wallet/types.dart b/sdk/flutter/lib/src/wallet/types.dart new file mode 100644 index 0000000..65f4c81 --- /dev/null +++ b/sdk/flutter/lib/src/wallet/types.dart @@ -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 json) { + return Address( + address: json['address'] as String, + index: json['index'] as int, + isChange: json['is_change'] as bool? ?? false, + ); + } + + Map 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
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 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)) + .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 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 json) { + return CreateWalletResult( + wallet: Wallet.fromJson(json['wallet'] as Map), + 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 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 toJson() => { + 'address': address, + 'amount': amount, + }; +} + +/// A blockchain transaction for signing. +class Transaction { + final List inputs; + final List outputs; + final int? fee; + final Priority priority; + + const Transaction({ + required this.inputs, + required this.outputs, + this.fee, + this.priority = Priority.medium, + }); + + Map 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 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 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 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 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'; +} diff --git a/sdk/flutter/lib/synor_sdk.dart b/sdk/flutter/lib/synor_sdk.dart new file mode 100644 index 0000000..51b0cd7 --- /dev/null +++ b/sdk/flutter/lib/synor_sdk.dart @@ -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'; diff --git a/sdk/flutter/pubspec.yaml b/sdk/flutter/pubspec.yaml index eaeea06..30c515a 100644 --- a/sdk/flutter/pubspec.yaml +++ b/sdk/flutter/pubspec.yaml @@ -1,5 +1,5 @@ -name: synor_compute -description: Flutter/Dart SDK for Synor Compute - distributed heterogeneous computing platform +name: synor_sdk +description: Flutter/Dart SDK for Synor - Compute, Wallet, RPC, and Storage version: 0.1.0 homepage: https://github.com/mrgulshanyadav/Blockchain.cc repository: https://github.com/mrgulshanyadav/Blockchain.cc/tree/main/sdk/flutter diff --git a/sdk/go/bridge/client.go b/sdk/go/bridge/client.go new file mode 100644 index 0000000..e1ef4a9 --- /dev/null +++ b/sdk/go/bridge/client.go @@ -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<= 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 +} diff --git a/sdk/go/bridge/types.go b/sdk/go/bridge/types.go new file mode 100644 index 0000000..305cccb --- /dev/null +++ b/sdk/go/bridge/types.go @@ -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, + } +} diff --git a/sdk/go/database/client.go b/sdk/go/database/client.go new file mode 100644 index 0000000..d98340f --- /dev/null +++ b/sdk/go/database/client.go @@ -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) +} diff --git a/sdk/go/database/types.go b/sdk/go/database/types.go new file mode 100644 index 0000000..13ce6b4 --- /dev/null +++ b/sdk/go/database/types.go @@ -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, + } +} diff --git a/sdk/go/hosting/client.go b/sdk/go/hosting/client.go new file mode 100644 index 0000000..18bd190 --- /dev/null +++ b/sdk/go/hosting/client.go @@ -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 +} diff --git a/sdk/go/hosting/types.go b/sdk/go/hosting/types.go new file mode 100644 index 0000000..65047c1 --- /dev/null +++ b/sdk/go/hosting/types.go @@ -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, + } +} diff --git a/sdk/java/pom.xml b/sdk/java/pom.xml index 5bca88e..4d02b6f 100644 --- a/sdk/java/pom.xml +++ b/sdk/java/pom.xml @@ -5,13 +5,13 @@ 4.0.0 io.synor - synor-compute + synor-sdk 0.1.0 jar - Synor Compute SDK - Java SDK for Synor Compute - Distributed Heterogeneous Computing - https://github.com/synor/synor-compute-java + Synor SDK + Java SDK for Synor - Compute, Wallet, RPC, and Storage + https://synor.cc @@ -48,6 +48,11 @@ jackson-databind ${jackson.version} + + com.google.code.gson + gson + 2.10.1 + diff --git a/sdk/java/src/main/java/io/synor/bridge/SynorBridge.java b/sdk/java/src/main/java/io/synor/bridge/SynorBridge.java new file mode 100644 index 0000000..67343cc --- /dev/null +++ b/sdk/java/src/main/java/io/synor/bridge/SynorBridge.java @@ -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 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> getSupportedChains() { + return request("GET", "/chains", null) + .thenApply(resp -> { + Type type = new TypeToken(){}.getType(); + ChainsResponse result = gson.fromJson(resp, type); + return result.chains; + }); + } + + public CompletableFuture getChain(ChainId chainId) { + return request("GET", "/chains/" + chainId.name().toLowerCase(), null) + .thenApply(resp -> gson.fromJson(resp, Chain.class)); + } + + public CompletableFuture isChainSupported(ChainId chainId) { + return getChain(chainId) + .thenApply(Chain::isSupported) + .exceptionally(e -> false); + } + + // ==================== Asset Operations ==================== + + public CompletableFuture> getSupportedAssets(ChainId chainId) { + return request("GET", "/chains/" + chainId.name().toLowerCase() + "/assets", null) + .thenApply(resp -> { + Type type = new TypeToken(){}.getType(); + AssetsResponse result = gson.fromJson(resp, type); + return result.assets; + }); + } + + public CompletableFuture getAsset(String assetId) { + return request("GET", "/assets/" + encode(assetId), null) + .thenApply(resp -> gson.fromJson(resp, Asset.class)); + } + + public CompletableFuture 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 estimateFee(String asset, String amount, ChainId sourceChain, ChainId targetChain) { + Map 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 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 lock(String asset, String amount, ChainId targetChain, LockOptions options) { + Map 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 getLockProof(String lockReceiptId) { + return request("GET", "/transfers/lock/" + encode(lockReceiptId) + "/proof", null) + .thenApply(resp -> gson.fromJson(resp, LockProof.class)); + } + + public CompletableFuture waitForLockProof(String lockReceiptId, long pollIntervalMs, long maxWaitMs) { + long deadline = System.currentTimeMillis() + maxWaitMs; + return waitForLockProofInternal(lockReceiptId, pollIntervalMs, deadline); + } + + private CompletableFuture 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 mint(LockProof proof, String targetAddress, MintOptions options) { + Map 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 burn(String wrappedAsset, String amount, BurnOptions options) { + Map 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 getBurnProof(String burnReceiptId) { + return request("GET", "/transfers/burn/" + encode(burnReceiptId) + "/proof", null) + .thenApply(resp -> gson.fromJson(resp, BurnProof.class)); + } + + public CompletableFuture waitForBurnProof(String burnReceiptId, long pollIntervalMs, long maxWaitMs) { + long deadline = System.currentTimeMillis() + maxWaitMs; + return waitForBurnProofInternal(burnReceiptId, pollIntervalMs, deadline); + } + + private CompletableFuture 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 unlock(BurnProof proof, UnlockOptions options) { + Map 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 getTransfer(String transferId) { + return request("GET", "/transfers/" + encode(transferId), null) + .thenApply(resp -> gson.fromJson(resp, Transfer.class)); + } + + public CompletableFuture getTransferStatus(String transferId) { + return getTransfer(transferId).thenApply(Transfer::getStatus); + } + + public CompletableFuture> 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(){}.getType(); + TransfersResponse result = gson.fromJson(resp, type); + return result.transfers; + }); + } + + public CompletableFuture waitForTransfer(String transferId, long pollIntervalMs, long maxWaitMs) { + long deadline = System.currentTimeMillis() + maxWaitMs; + return waitForTransferInternal(transferId, pollIntervalMs, deadline); + } + + private CompletableFuture 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 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 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 healthCheck() { + return request("GET", "/health", null) + .thenApply(resp -> { + Map map = gson.fromJson(resp, new TypeToken>(){}.getType()); + return "healthy".equals(map.get("status")); + }) + .exceptionally(e -> false); + } + + // ==================== Private Methods ==================== + + private CompletableFuture 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 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 errorBody = gson.fromJson(response.body(), + new TypeToken>(){}.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 chains; } + private static class AssetsResponse { List assets; } + private static class TransfersResponse { List transfers; } +} diff --git a/sdk/java/src/main/java/io/synor/bridge/Types.java b/sdk/java/src/main/java/io/synor/bridge/Types.java new file mode 100644 index 0000000..34a57a9 --- /dev/null +++ b/sdk/java/src/main/java/io/synor/bridge/Types.java @@ -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 merkleProof; + private String blockHeader; + private List signatures; + + public LockReceipt getLockReceipt() { return lockReceipt; } + public List getMerkleProof() { return merkleProof; } + public String getBlockHeader() { return blockHeader; } + public List 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 merkleProof; + private String blockHeader; + private List signatures; + + public BurnReceipt getBurnReceipt() { return burnReceipt; } + public List getMerkleProof() { return merkleProof; } + public String getBlockHeader() { return blockHeader; } + public List 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; } +} diff --git a/sdk/java/src/main/java/io/synor/database/SynorDatabase.java b/sdk/java/src/main/java/io/synor/database/SynorDatabase.java new file mode 100644 index 0000000..9e0a8da --- /dev/null +++ b/sdk/java/src/main/java/io/synor/database/SynorDatabase.java @@ -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 get(String key) { + return db.request("GET", "/kv/" + encode(key), null) + .thenApply(resp -> { + Map map = gson.fromJson(resp, new TypeToken>(){}.getType()); + return map.get("value"); + }); + } + + public CompletableFuture set(String key, Object value, Integer ttl) { + Map 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 delete(String key) { + return db.request("DELETE", "/kv/" + encode(key), null) + .thenApply(resp -> null); + } + + public CompletableFuture> list(String prefix) { + String path = "/kv?prefix=" + encode(prefix); + return db.request("GET", path, null) + .thenApply(resp -> { + Type type = new TypeToken>(){}.getType(); + ListResponse 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 create(String collection, Map document) { + return db.request("POST", "/collections/" + encode(collection) + "/documents", document) + .thenApply(resp -> { + Map map = gson.fromJson(resp, new TypeToken>(){}.getType()); + return (String) map.get("id"); + }); + } + + public CompletableFuture 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 update(String collection, String id, Map update) { + return db.request("PATCH", "/collections/" + encode(collection) + "/documents/" + encode(id), update) + .thenApply(resp -> null); + } + + public CompletableFuture delete(String collection, String id) { + return db.request("DELETE", "/collections/" + encode(collection) + "/documents/" + encode(id), null) + .thenApply(resp -> null); + } + + public CompletableFuture> query(String collection, Query query) { + return db.request("POST", "/collections/" + encode(collection) + "/query", query) + .thenApply(resp -> { + Type type = new TypeToken>(){}.getType(); + ListResponse 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 upsert(String collection, List vectors) { + Map body = new HashMap<>(); + body.put("vectors", vectors); + + return db.request("POST", "/vectors/" + encode(collection) + "/upsert", body) + .thenApply(resp -> null); + } + + public CompletableFuture> search(String collection, double[] vector, int k) { + Map 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>(){}.getType(); + ListResponse result = gson.fromJson(resp, type); + return result.results; + }); + } + + public CompletableFuture delete(String collection, List ids) { + Map 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 write(String series, List points) { + Map body = new HashMap<>(); + body.put("points", points); + + return db.request("POST", "/timeseries/" + encode(series) + "/write", body) + .thenApply(resp -> null); + } + + public CompletableFuture> query(String series, TimeRange range, Aggregation aggregation) { + Map 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>(){}.getType(); + ListResponse result = gson.fromJson(resp, type); + return result.points; + }); + } + } + + // ==================== Lifecycle ==================== + + @Override + public void close() { + closed.set(true); + } + + public boolean isClosed() { + return closed.get(); + } + + public CompletableFuture healthCheck() { + return request("GET", "/health", null) + .thenApply(resp -> { + Map map = gson.fromJson(resp, new TypeToken>(){}.getType()); + return "healthy".equals(map.get("status")); + }) + .exceptionally(e -> false); + } + + // ==================== Private Methods ==================== + + private CompletableFuture 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 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 errorBody = gson.fromJson(response.body(), + new TypeToken>(){}.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 { + List items; + List documents; + List results; + List points; + } +} diff --git a/sdk/java/src/main/java/io/synor/database/Types.java b/sdk/java/src/main/java/io/synor/database/Types.java new file mode 100644 index 0000000..de71e8c --- /dev/null +++ b/sdk/java/src/main/java/io/synor/database/Types.java @@ -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 data; + private Long createdAt; + private Long updatedAt; + + public String getId() { return id; } + public String getCollection() { return collection; } + public Map getData() { return data; } + public Long getCreatedAt() { return createdAt; } + public Long getUpdatedAt() { return updatedAt; } +} + +class Query { + private Map filter; + private Map sort; + private Integer limit; + private Integer offset; + private String[] projection; + + public Query filter(Map filter) { + this.filter = filter; + return this; + } + + public Query sort(Map 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 metadata; + + public VectorEntry(String id, double[] vector) { + this.id = id; + this.vector = vector; + } + + public VectorEntry(String id, double[] vector, Map metadata) { + this.id = id; + this.vector = vector; + this.metadata = metadata; + } + + public String getId() { return id; } + public double[] getVector() { return vector; } + public Map getMetadata() { return metadata; } +} + +class SearchResult { + private String id; + private double score; + private double[] vector; + private Map metadata; + + public String getId() { return id; } + public double getScore() { return score; } + public double[] getVector() { return vector; } + public Map getMetadata() { return metadata; } +} + +// ==================== Time Series Types ==================== + +class DataPoint { + private long timestamp; + private double value; + private Map tags; + + public DataPoint(long timestamp, double value) { + this.timestamp = timestamp; + this.value = value; + } + + public DataPoint(long timestamp, double value, Map tags) { + this.timestamp = timestamp; + this.value = value; + this.tags = tags; + } + + public long getTimestamp() { return timestamp; } + public double getValue() { return value; } + public Map 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; } +} diff --git a/sdk/java/src/main/java/io/synor/hosting/SynorHosting.java b/sdk/java/src/main/java/io/synor/hosting/SynorHosting.java new file mode 100644 index 0000000..9932d6c --- /dev/null +++ b/sdk/java/src/main/java/io/synor/hosting/SynorHosting.java @@ -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 checkAvailability(String name) { + return request("GET", "/domains/check/" + encode(name), null) + .thenApply(resp -> gson.fromJson(resp, DomainAvailability.class)); + } + + public CompletableFuture registerDomain(String name, RegisterDomainOptions options) { + Map 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 getDomain(String name) { + return request("GET", "/domains/" + encode(name), null) + .thenApply(resp -> gson.fromJson(resp, Domain.class)); + } + + public CompletableFuture> listDomains() { + return request("GET", "/domains", null) + .thenApply(resp -> { + Type type = new TypeToken(){}.getType(); + DomainsResponse result = gson.fromJson(resp, type); + return result.domains; + }); + } + + public CompletableFuture updateDomainRecord(String name, DomainRecord record) { + return request("PUT", "/domains/" + encode(name) + "/record", record) + .thenApply(resp -> gson.fromJson(resp, Domain.class)); + } + + public CompletableFuture resolveDomain(String name) { + return request("GET", "/domains/" + encode(name) + "/resolve", null) + .thenApply(resp -> gson.fromJson(resp, DomainRecord.class)); + } + + public CompletableFuture renewDomain(String name, int years) { + Map 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 getDnsZone(String domain) { + return request("GET", "/dns/" + encode(domain), null) + .thenApply(resp -> gson.fromJson(resp, DnsZone.class)); + } + + public CompletableFuture setDnsRecords(String domain, List records) { + Map body = new HashMap<>(); + body.put("records", records); + return request("PUT", "/dns/" + encode(domain), body) + .thenApply(resp -> gson.fromJson(resp, DnsZone.class)); + } + + public CompletableFuture addDnsRecord(String domain, DnsRecord record) { + return request("POST", "/dns/" + encode(domain) + "/records", record) + .thenApply(resp -> gson.fromJson(resp, DnsZone.class)); + } + + public CompletableFuture 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 deploy(String cid, DeployOptions options) { + Map 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 getDeployment(String id) { + return request("GET", "/deployments/" + encode(id), null) + .thenApply(resp -> gson.fromJson(resp, Deployment.class)); + } + + public CompletableFuture> listDeployments(String domain) { + String path = domain != null ? "/deployments?domain=" + encode(domain) : "/deployments"; + return request("GET", path, null) + .thenApply(resp -> { + Type type = new TypeToken(){}.getType(); + DeploymentsResponse result = gson.fromJson(resp, type); + return result.deployments; + }); + } + + public CompletableFuture rollback(String domain, String deploymentId) { + Map body = new HashMap<>(); + body.put("domain", domain); + return request("POST", "/deployments/" + encode(deploymentId) + "/rollback", body) + .thenApply(resp -> gson.fromJson(resp, Deployment.class)); + } + + public CompletableFuture deleteDeployment(String id) { + return request("DELETE", "/deployments/" + encode(id), null) + .thenApply(resp -> null); + } + + // ==================== SSL Operations ==================== + + public CompletableFuture provisionSsl(String domain, ProvisionSslOptions options) { + return request("POST", "/ssl/" + encode(domain), options) + .thenApply(resp -> gson.fromJson(resp, Certificate.class)); + } + + public CompletableFuture getCertificate(String domain) { + return request("GET", "/ssl/" + encode(domain), null) + .thenApply(resp -> gson.fromJson(resp, Certificate.class)); + } + + public CompletableFuture renewCertificate(String domain) { + return request("POST", "/ssl/" + encode(domain) + "/renew", null) + .thenApply(resp -> gson.fromJson(resp, Certificate.class)); + } + + public CompletableFuture deleteCertificate(String domain) { + return request("DELETE", "/ssl/" + encode(domain), null) + .thenApply(resp -> null); + } + + // ==================== Site Configuration ==================== + + public CompletableFuture getSiteConfig(String domain) { + return request("GET", "/sites/" + encode(domain) + "/config", null) + .thenApply(resp -> gson.fromJson(resp, SiteConfig.class)); + } + + public CompletableFuture updateSiteConfig(String domain, Map config) { + return request("PATCH", "/sites/" + encode(domain) + "/config", config) + .thenApply(resp -> gson.fromJson(resp, SiteConfig.class)); + } + + public CompletableFuture purgeCache(String domain, List paths) { + Map body = new HashMap<>(); + if (paths != null) body.put("paths", paths); + return request("DELETE", "/sites/" + encode(domain) + "/cache", body) + .thenApply(resp -> { + Map map = gson.fromJson(resp, new TypeToken>(){}.getType()); + return ((Number) map.get("purged")).longValue(); + }); + } + + // ==================== Analytics ==================== + + public CompletableFuture 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 healthCheck() { + return request("GET", "/health", null) + .thenApply(resp -> { + Map map = gson.fromJson(resp, new TypeToken>(){}.getType()); + return "healthy".equals(map.get("status")); + }) + .exceptionally(e -> false); + } + + // ==================== Private Methods ==================== + + private CompletableFuture 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 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 errorBody = gson.fromJson(response.body(), + new TypeToken>(){}.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 domains; } + private static class DeploymentsResponse { List deployments; } +} diff --git a/sdk/java/src/main/java/io/synor/hosting/Types.java b/sdk/java/src/main/java/io/synor/hosting/Types.java new file mode 100644 index 0000000..2853b85 --- /dev/null +++ b/sdk/java/src/main/java/io/synor/hosting/Types.java @@ -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 ipv4; + private List ipv6; + private String cname; + private List txt; + private Map metadata; + + public String getCid() { return cid; } + public List getIpv4() { return ipv4; } + public List getIpv6() { return ipv6; } + public String getCname() { return cname; } + public List getTxt() { return txt; } + public Map getMetadata() { return metadata; } + + public void setCid(String cid) { this.cid = cid; } + public void setIpv4(List ipv4) { this.ipv4 = ipv4; } + public void setIpv6(List ipv6) { this.ipv6 = ipv6; } + public void setCname(String cname) { this.cname = cname; } + public void setTxt(List txt) { this.txt = txt; } + public void setMetadata(Map 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 records; + private long updatedAt; + + public String getDomain() { return domain; } + public List 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 headers; + private List redirects; + private Map errorPages; + private boolean spa; + private boolean cleanUrls; + private boolean trailingSlash; + + public String getDomain() { return domain; } + public String getCid() { return cid; } + public Map getHeaders() { return headers; } + public List getRedirects() { return redirects; } + public Map 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 topPages; + private List topReferrers; + private List 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 getTopPages() { return topPages; } + public List getTopReferrers() { return topReferrers; } + public List 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; } +} diff --git a/sdk/java/src/main/java/io/synor/rpc/SynorRpc.java b/sdk/java/src/main/java/io/synor/rpc/SynorRpc.java new file mode 100644 index 0000000..ce45ab6 --- /dev/null +++ b/sdk/java/src/main/java/io/synor/rpc/SynorRpc.java @@ -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: + *
{@code
+ * SynorRpc rpc = new SynorRpc("your-api-key");
+ * Block block = rpc.getLatestBlock().join();
+ * System.out.println("Height: " + block.getHeight());
+ * }
+ */ +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> 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 getBlockByHash(String hash) { + return get("/blocks/" + hash, Block.class); + } + + /** Get a block by height. */ + public CompletableFuture getBlockByHeight(long height) { + return get("/blocks/height/" + height, Block.class); + } + + /** Get the latest block. */ + public CompletableFuture getLatestBlock() { + return get("/blocks/latest", Block.class); + } + + /** Get a block header by hash. */ + public CompletableFuture getBlockHeaderByHash(String hash) { + return get("/blocks/" + hash + "/header", BlockHeaderResponse.class) + .thenApply(BlockHeaderResponse::getHeader); + } + + /** Get a transaction by ID. */ + public CompletableFuture getTransaction(String txid) { + return get("/transactions/" + txid, Transaction.class); + } + + /** Send a raw transaction. */ + public CompletableFuture sendRawTransaction(String rawTx) { + var body = Map.of("raw", rawTx); + return post("/transactions", body, SendTxResponse.class) + .thenApply(SendTxResponse::getTxid); + } + + /** Estimate transaction fee. */ + public CompletableFuture estimateFee(Priority priority) { + return get("/fees/estimate?priority=" + priority.getValue(), FeeEstimate.class); + } + + /** Get chain information. */ + public CompletableFuture getChainInfo() { + return get("/chain", ChainInfo.class); + } + + /** Get mempool information. */ + public CompletableFuture getMempoolInfo() { + return get("/mempool", MempoolInfo.class); + } + + /** Get UTXOs for an address. */ + public CompletableFuture getUTXOs(String address) { + return get("/addresses/" + address + "/utxos", UTXOsResponse.class) + .thenApply(UTXOsResponse::getUtxos); + } + + /** Get balance for an address. */ + public CompletableFuture getBalance(String address) { + return get("/addresses/" + address + "/balance", Balance.class); + } + + /** Subscribe to new blocks. */ + public CompletableFuture subscribeBlocks(Consumer callback) { + return subscribe("blocks", null, data -> { + Block block = gson.fromJson(data, BlockNotification.class).getBlock(); + callback.accept(block); + }); + } + + /** Subscribe to address transactions. */ + public CompletableFuture subscribeAddress(String address, Consumer callback) { + return subscribe("address", Map.of("address", address), data -> { + Transaction tx = gson.fromJson(data, TransactionNotification.class).getTransaction(); + callback.accept(tx); + }); + } + + private CompletableFuture subscribe(String type, Map params, Consumer handler) { + return ensureWebSocket().thenApply(ws -> { + String subId = type + "_" + System.currentTimeMillis(); + subscriptions.put(subId, handler); + + var msg = new java.util.HashMap(); + 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 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 CompletableFuture get(String path, Class 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 CompletableFuture post(String path, Object body, Class 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 handleResponse(HttpResponse response, Class 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; } } +} diff --git a/sdk/java/src/main/java/io/synor/rpc/Types.java b/sdk/java/src/main/java/io/synor/rpc/Types.java new file mode 100644 index 0000000..d148a48 --- /dev/null +++ b/sdk/java/src/main/java/io/synor/rpc/Types.java @@ -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 transactions; + private int size; + private int weight; + private int txCount; + + public List 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; } +} diff --git a/sdk/java/src/main/java/io/synor/storage/SynorStorage.java b/sdk/java/src/main/java/io/synor/storage/SynorStorage.java new file mode 100644 index 0000000..14245a8 --- /dev/null +++ b/sdk/java/src/main/java/io/synor/storage/SynorStorage.java @@ -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: + *
{@code
+ * SynorStorage storage = new SynorStorage("your-api-key");
+ * UploadResponse result = storage.upload("Hello, World!".getBytes()).join();
+ * System.out.println("CID: " + result.getCid());
+ * }
+ */ +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 upload(byte[] data) { + return upload(data, UploadOptions.defaults()); + } + + /** Upload content with options. */ + public CompletableFuture 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 download(String cid) { + return download(cid, DownloadOptions.defaults()); + } + + /** Download content with options. */ + public CompletableFuture 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(PinRequest pinRequest) { + return post("/pins", pinRequest, Pin.class); + } + + /** Unpin content. */ + public CompletableFuture unpin(String cid) { + return delete("/pins/" + cid); + } + + /** Get pin status. */ + public CompletableFuture getPinStatus(String cid) { + return get("/pins/" + cid, Pin.class); + } + + /** List pins. */ + public CompletableFuture 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 createCar(List files) { + return post("/car/create", Map.of("files", files), CarFile.class); + } + + /** Import CAR file. */ + public CompletableFuture 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 createDirectory(List files) { + return post("/directory", Map.of("files", files), UploadResponse.class); + } + + /** List directory. */ + public CompletableFuture> 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 getStats() { + return get("/stats", StorageStats.class); + } + + /** Check if content exists. */ + public CompletableFuture 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 CompletableFuture get(String path, Class 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 CompletableFuture post(String path, Object body, Class 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 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 handleResponse(HttpResponse response, Class 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 entries) { List getEntries() { return entries; } } + private record ErrorResponse(String message, String code) { String getMessage() { return message; } String getCode() { return code; } } +} diff --git a/sdk/java/src/main/java/io/synor/storage/Types.java b/sdk/java/src/main/java/io/synor/storage/Types.java new file mode 100644 index 0000000..7ac3ab9 --- /dev/null +++ b/sdk/java/src/main/java/io/synor/storage/Types.java @@ -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 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 getDelegates() { return delegates; } +} + +/** Pin request. */ +class PinRequest { + private String cid; + private String name; + private Long duration; + private List 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 origins) { this.origins = origins; return this; } +} + +/** List pins options. */ +class ListPinsOptions { + private Integer limit; + private Integer offset; + private List 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 pins; + private int total; + private boolean hasMore; + + public List 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 roots; + private List blocks; + private Long size; + + public int getVersion() { return version; } + public List getRoots() { return roots; } + public List getBlocks() { return blocks; } + public Long getSize() { return size; } +} + +/** Import CAR response. */ +class ImportCarResponse { + private List roots; + private int blocksImported; + + public List 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; } +} diff --git a/sdk/java/src/main/java/io/synor/wallet/SynorWallet.java b/sdk/java/src/main/java/io/synor/wallet/SynorWallet.java new file mode 100644 index 0000000..3523391 --- /dev/null +++ b/sdk/java/src/main/java/io/synor/wallet/SynorWallet.java @@ -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: + *
{@code
+ * SynorWallet wallet = new SynorWallet("your-api-key");
+ * CreateWalletResult result = wallet.createWallet(WalletType.STANDARD).join();
+ * System.out.println("Address: " + result.getWallet().getAddress());
+ * }
+ */ +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 createWallet(WalletType type) { + var body = new CreateWalletRequest(type, config.getNetwork()); + return post("/wallets", body, CreateWalletResult.class); + } + + /** + * Import a wallet from mnemonic. + */ + public CompletableFuture 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 getWallet(String walletId) { + return get("/wallets/" + walletId, WalletResponse.class) + .thenApply(WalletResponse::getWallet); + } + + /** + * Sign a transaction. + */ + public CompletableFuture 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 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 getBalance(String address, boolean includeTokens) { + String path = "/balances/" + address + "?includeTokens=" + includeTokens; + return get(path, BalanceResponse.class); + } + + /** + * Get UTXOs for an address. + */ + public CompletableFuture getUTXOs(String address) { + return get("/utxos/" + address, UTXOsResponse.class) + .thenApply(UTXOsResponse::getUtxos); + } + + /** + * Estimate transaction fee. + */ + public CompletableFuture estimateFee(Priority priority) { + String path = "/fees/estimate?priority=" + priority.getValue(); + return get(path, FeeEstimate.class); + } + + private CompletableFuture get(String path, Class 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 CompletableFuture post(String path, Object body, Class 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 handleResponse(HttpResponse response, Class 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; } } +} diff --git a/sdk/java/src/main/java/io/synor/wallet/Types.java b/sdk/java/src/main/java/io/synor/wallet/Types.java new file mode 100644 index 0000000..a068875 --- /dev/null +++ b/sdk/java/src/main/java/io/synor/wallet/Types.java @@ -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 inputs; + private List outputs; + private int lockTime; + private String fee; + + public int getVersion() { return version; } + public List getInputs() { return inputs; } + public List 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 tokens; + + public Balance getNativeBalance() { return nativeBalance; } + public List 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; } +} diff --git a/sdk/js/src/bridge/client.ts b/sdk/js/src/bridge/client.ts new file mode 100644 index 0000000..fe2ecd2 --- /dev/null +++ b/sdk/js/src/bridge/client.ts @@ -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; + 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 { + const response = await this.request<{ chains: Chain[] }>('GET', '/chains'); + return response.chains; + } + + /** Get chain by ID */ + async getChain(chainId: ChainId): Promise { + return this.request('GET', `/chains/${chainId}`); + } + + /** Check if chain is supported */ + async isChainSupported(chainId: ChainId): Promise { + 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 { + const response = await this.request<{ assets: Asset[] }>( + 'GET', + `/chains/${chainId}/assets` + ); + return response.assets; + } + + /** Get asset by ID */ + async getAsset(assetId: string): Promise { + return this.request('GET', `/assets/${assetId}`); + } + + /** Get wrapped asset mapping */ + async getWrappedAsset( + originalAssetId: string, + targetChain: ChainId + ): Promise { + return this.request( + 'GET', + `/assets/${originalAssetId}/wrapped/${targetChain}` + ); + } + + /** Get all wrapped assets for a chain */ + async getWrappedAssets(chainId: ChainId): Promise { + 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 { + return this.request('POST', '/fees/estimate', { + asset, + amount, + sourceChain, + targetChain, + }); + } + + /** Get exchange rate between assets */ + async getExchangeRate(fromAsset: string, toAsset: string): Promise { + return this.request( + '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 { + return this.request('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 { + return this.request('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 { + 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 { + return this.request('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 { + return this.request('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 { + return this.request('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 { + 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 { + return this.request('POST', '/transfers/unlock', { + proof, + gasLimit: options?.gasLimit, + gasPrice: options?.gasPrice, + }); + } + + // ==================== Transfer Management ==================== + + /** Get transfer by ID */ + async getTransfer(transferId: string): Promise { + return this.request('GET', `/transfers/${transferId}`); + } + + /** Get transfer status */ + async getTransferStatus(transferId: string): Promise { + const transfer = await this.getTransfer(transferId); + return transfer.status; + } + + /** List transfers with optional filters */ + async listTransfers(filter?: TransferFilter): Promise { + 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 { + 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 { + // 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 { + // 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 { + try { + const response = await this.request<{ status: string }>('GET', '/health'); + return response.status === 'healthy'; + } catch { + return false; + } + } + + // ==================== Private Methods ==================== + + private async request( + method: string, + path: string, + body?: Record + ): Promise { + 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(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( + method: string, + path: string, + body?: Record + ): Promise { + const url = `${this.config.endpoint}${path}`; + + const headers: Record = { + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/sdk/js/src/bridge/index.ts b/sdk/js/src/bridge/index.ts new file mode 100644 index 0000000..6053df4 --- /dev/null +++ b/sdk/js/src/bridge/index.ts @@ -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'; diff --git a/sdk/js/src/bridge/types.ts b/sdk/js/src/bridge/types.ts new file mode 100644 index 0000000..d88ee99 --- /dev/null +++ b/sdk/js/src/bridge/types.ts @@ -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 + ) { + super(message); + this.name = 'BridgeError'; + } +} diff --git a/sdk/js/src/database/client.ts b/sdk/js/src/database/client.ts new file mode 100644 index 0000000..08ffbbf --- /dev/null +++ b/sdk/js/src/database/client.ts @@ -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(key: string): Promise { + const response = await this.client.request<{ value: T | null }>(`/kv/${encodeURIComponent(key)}`); + return response.value; + } + + /** + * Get a full entry with metadata + */ + async getEntry(key: string): Promise | null> { + return this.client.request | null>(`/kv/${encodeURIComponent(key)}/entry`); + } + + /** + * Set a value + */ + async set(key: string, value: T, options?: SetOptions): Promise { + await this.client.request(`/kv/${encodeURIComponent(key)}`, { + method: 'PUT', + body: { value, ...options }, + }); + } + + /** + * Delete a key + */ + async delete(key: string): Promise { + 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 { + const response = await this.client.request<{ exists: boolean }>(`/kv/${encodeURIComponent(key)}/exists`); + return response.exists; + } + + /** + * List keys with optional prefix filtering + */ + async list(options?: ListOptions): Promise> { + 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>(`/kv?${params}`); + } + + /** + * Get multiple values at once + */ + async mget(keys: string[]): Promise> { + 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(entries: { key: string; value: T }[]): Promise { + await this.client.request('/kv/mset', { + method: 'POST', + body: { entries }, + }); + } + + /** + * Increment a numeric value + */ + async incr(key: string, by: number = 1): Promise { + 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>( + collection: string, + data: T, + options?: CreateDocumentOptions + ): Promise> { + return this.client.request>(`/documents/${encodeURIComponent(collection)}`, { + method: 'POST', + body: { data, ...options }, + }); + } + + /** + * Get a document by ID + */ + async get>( + collection: string, + id: string + ): Promise | null> { + return this.client.request | null>( + `/documents/${encodeURIComponent(collection)}/${encodeURIComponent(id)}` + ); + } + + /** + * Update a document + */ + async update>( + collection: string, + id: string, + update: Partial, + options?: UpdateDocumentOptions + ): Promise> { + return this.client.request>( + `/documents/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, + { + method: 'PATCH', + body: { update, ...options }, + } + ); + } + + /** + * Replace a document + */ + async replace>( + collection: string, + id: string, + data: T + ): Promise> { + return this.client.request>( + `/documents/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, + { + method: 'PUT', + body: { data }, + } + ); + } + + /** + * Delete a document + */ + async delete(collection: string, id: string): Promise { + const response = await this.client.request<{ deleted: boolean }>( + `/documents/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, + { method: 'DELETE' } + ); + return response.deleted; + } + + /** + * Query documents + */ + async query>( + collection: string, + options?: QueryOptions + ): Promise> { + return this.client.request>( + `/documents/${encodeURIComponent(collection)}/query`, + { + method: 'POST', + body: options || {}, + } + ); + } + + /** + * Count documents matching a filter + */ + async count(collection: string, filter?: QueryOptions['filter']): Promise { + 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( + collection: string, + pipeline: AggregateStage[] + ): Promise { + 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 { + await this.client.request('/vectors/collections', { + method: 'POST', + body: config, + }); + } + + /** + * Delete a vector collection + */ + async deleteCollection(name: string): Promise { + await this.client.request(`/vectors/collections/${encodeURIComponent(name)}`, { + method: 'DELETE', + }); + } + + /** + * Get collection stats + */ + async getCollectionStats(name: string): Promise { + return this.client.request( + `/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 { + 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 { + 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 { + 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 { + return this.client.request( + `/timeseries/${encodeURIComponent(series)}/query`, + { + method: 'POST', + body: { range, ...options }, + } + ); + } + + /** + * Query multiple series + */ + async queryMulti( + series: string[], + range: TimeRange, + options?: TimeSeriesQueryOptions + ): Promise { + 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 + ): 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 { + return this.client.request(`/timeseries/${encodeURIComponent(series)}/info`); + } + + /** + * List all series + */ + async listSeries(prefix?: string): Promise { + 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 { + 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; + 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 { + return this.request('/stats'); + } + + /** + * Health check + */ + async healthCheck(): Promise { + 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( + path: string, + options: { method?: string; body?: unknown } = {} + ): Promise { + 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'); + } +} diff --git a/sdk/js/src/database/index.ts b/sdk/js/src/database/index.ts new file mode 100644 index 0000000..5c19c6e --- /dev/null +++ b/sdk/js/src/database/index.ts @@ -0,0 +1,7 @@ +/** + * Synor Database SDK + * Multi-model database: Key-Value, Document, Vector, and Time Series + */ + +export * from './types'; +export * from './client'; diff --git a/sdk/js/src/database/types.ts b/sdk/js/src/database/types.ts new file mode 100644 index 0000000..a90f637 --- /dev/null +++ b/sdk/js/src/database/types.ts @@ -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 { + key: string; + value: T; + metadata?: Record; + expiresAt?: number; + createdAt: number; + updatedAt: number; +} + +export interface SetOptions { + ttl?: number; // Time-to-live in seconds + metadata?: Record; + ifNotExists?: boolean; + ifExists?: boolean; +} + +export interface ListOptions { + prefix?: string; + cursor?: string; + limit?: number; +} + +export interface ListResult { + entries: KeyValueEntry[]; + cursor?: string; + hasMore: boolean; +} + +// ==================== Document Store Types ==================== + +export interface Document> { + id: string; + data: T; + metadata?: Record; + createdAt: number; + updatedAt: number; + version: number; +} + +export interface CreateDocumentOptions { + id?: string; // Auto-generate if not provided + metadata?: Record; +} + +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> { + documents: Document[]; + 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; + 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; + 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; +} + +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; + 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'; + } +} diff --git a/sdk/js/src/hosting/client.ts b/sdk/js/src/hosting/client.ts new file mode 100644 index 0000000..ff780be --- /dev/null +++ b/sdk/js/src/hosting/client.ts @@ -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; + 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 { + return this.request(`/domains/check/${encodeURIComponent(name)}`); + } + + /** + * Register a new domain + */ + async registerDomain(name: string, options?: RegisterDomainOptions): Promise { + return this.request('/domains', { + method: 'POST', + body: { name, ...options }, + }); + } + + /** + * Get domain information + */ + async getDomain(name: string): Promise { + return this.request(`/domains/${encodeURIComponent(name)}`); + } + + /** + * List all domains + */ + async listDomains(): Promise { + 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 { + return this.request(`/domains/${encodeURIComponent(name)}/record`, { + method: 'PUT', + body: record, + }); + } + + /** + * Resolve a domain to its record + */ + async resolveDomain(name: string): Promise { + return this.request(`/domains/${encodeURIComponent(name)}/resolve`); + } + + /** + * Renew a domain + */ + async renewDomain(name: string, years: number = 1): Promise { + return this.request(`/domains/${encodeURIComponent(name)}/renew`, { + method: 'POST', + body: { years }, + }); + } + + /** + * Transfer domain ownership + */ + async transferDomain(name: string, newOwner: string): Promise { + return this.request(`/domains/${encodeURIComponent(name)}/transfer`, { + method: 'POST', + body: { new_owner: newOwner }, + }); + } + + // ==================== DNS Operations ==================== + + /** + * Get DNS zone for a domain + */ + async getDnsZone(domain: string): Promise { + return this.request(`/dns/${encodeURIComponent(domain)}`); + } + + /** + * Set DNS records for a domain + */ + async setDnsRecords(domain: string, records: DnsRecord[]): Promise { + return this.request(`/dns/${encodeURIComponent(domain)}`, { + method: 'PUT', + body: { records }, + }); + } + + /** + * Add a DNS record + */ + async addDnsRecord(domain: string, record: DnsRecord): Promise { + return this.request(`/dns/${encodeURIComponent(domain)}/records`, { + method: 'POST', + body: record, + }); + } + + /** + * Delete a DNS record + */ + async deleteDnsRecord(domain: string, recordType: string, name: string): Promise { + return this.request( + `/dns/${encodeURIComponent(domain)}/records/${recordType}/${encodeURIComponent(name)}`, + { method: 'DELETE' } + ); + } + + // ==================== Deployment Operations ==================== + + /** + * Deploy a site from CID + */ + async deploy(cid: string, options?: DeployOptions): Promise { + return this.request('/deployments', { + method: 'POST', + body: { cid, ...options }, + }); + } + + /** + * Get deployment by ID + */ + async getDeployment(id: string): Promise { + return this.request(`/deployments/${encodeURIComponent(id)}`); + } + + /** + * List deployments + */ + async listDeployments(domain?: string): Promise { + 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 { + return this.request(`/deployments/${encodeURIComponent(deploymentId)}/rollback`, { + method: 'POST', + body: { domain }, + }); + } + + /** + * Delete a deployment + */ + async deleteDeployment(id: string): Promise { + await this.request(`/deployments/${encodeURIComponent(id)}`, { method: 'DELETE' }); + } + + /** + * Get deployment stats + */ + async getDeploymentStats(id: string, period: string = '24h'): Promise { + return this.request( + `/deployments/${encodeURIComponent(id)}/stats?period=${period}` + ); + } + + // ==================== SSL Operations ==================== + + /** + * Provision SSL certificate + */ + async provisionSsl(domain: string, options?: ProvisionSslOptions): Promise { + return this.request(`/ssl/${encodeURIComponent(domain)}`, { + method: 'POST', + body: options || {}, + }); + } + + /** + * Get certificate status + */ + async getCertificate(domain: string): Promise { + return this.request(`/ssl/${encodeURIComponent(domain)}`); + } + + /** + * Renew SSL certificate + */ + async renewCertificate(domain: string): Promise { + return this.request(`/ssl/${encodeURIComponent(domain)}/renew`, { + method: 'POST', + }); + } + + /** + * Delete/revoke SSL certificate + */ + async deleteCertificate(domain: string): Promise { + await this.request(`/ssl/${encodeURIComponent(domain)}`, { method: 'DELETE' }); + } + + // ==================== Site Configuration ==================== + + /** + * Get site configuration + */ + async getSiteConfig(domain: string): Promise { + return this.request(`/sites/${encodeURIComponent(domain)}/config`); + } + + /** + * Update site configuration + */ + async updateSiteConfig(domain: string, config: Partial): Promise { + return this.request(`/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 { + 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(`/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 { + try { + const response = await this.request<{ status: string }>('/health'); + return response.status === 'healthy'; + } catch { + return false; + } + } + + /** + * Internal request method + */ + private async request( + path: string, + options: { method?: string; body?: unknown } = {} + ): Promise { + 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'); + } +} diff --git a/sdk/js/src/hosting/index.ts b/sdk/js/src/hosting/index.ts new file mode 100644 index 0000000..340efda --- /dev/null +++ b/sdk/js/src/hosting/index.ts @@ -0,0 +1,7 @@ +/** + * Synor Hosting SDK + * Decentralized web hosting with domain management, DNS, and deployments. + */ + +export * from './types'; +export * from './client'; diff --git a/sdk/js/src/hosting/types.ts b/sdk/js/src/hosting/types.ts new file mode 100644 index 0000000..12bda79 --- /dev/null +++ b/sdk/js/src/hosting/types.ts @@ -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; +} + +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; + 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; + 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'; + } +} diff --git a/sdk/kotlin/build.gradle.kts b/sdk/kotlin/build.gradle.kts index 3d8d71c..9d4033a 100644 --- a/sdk/kotlin/build.gradle.kts +++ b/sdk/kotlin/build.gradle.kts @@ -21,11 +21,13 @@ dependencies { implementation("io.ktor:ktor-client-core: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-websockets:2.3.7") implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.7") // Testing testImplementation(kotlin("test")) 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") } @@ -42,9 +44,16 @@ publishing { create("maven") { from(components["java"]) pom { - name.set("Synor Compute SDK") - description.set("Kotlin SDK for Synor Compute - Distributed Heterogeneous Computing") - url.set("https://github.com/synor/synor-compute-kotlin") + name.set("Synor SDK") + description.set("Kotlin SDK for Synor - Compute, Wallet, RPC, and Storage") + url.set("https://synor.cc") + + licenses { + license { + name.set("MIT License") + url.set("https://opensource.org/licenses/MIT") + } + } } } } diff --git a/sdk/kotlin/settings.gradle.kts b/sdk/kotlin/settings.gradle.kts new file mode 100644 index 0000000..bc96b0c --- /dev/null +++ b/sdk/kotlin/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "synor-sdk" diff --git a/sdk/kotlin/src/main/kotlin/io/synor/rpc/SynorRpc.kt b/sdk/kotlin/src/main/kotlin/io/synor/rpc/SynorRpc.kt new file mode 100644 index 0000000..0b6f29b --- /dev/null +++ b/sdk/kotlin/src/main/kotlin/io/synor/rpc/SynorRpc.kt @@ -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 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 { + 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 = 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 { + 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 { + val estimates: List = 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 { + 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(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(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(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(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 get(path: String): T { + return executeWithRetry { + val response = client.get("${config.endpoint}$path") + handleResponse(response) + } + } + + private suspend inline fun post(path: String, body: R): T { + return executeWithRetry { + val response = client.post("${config.endpoint}$path") { + setBody(body) + } + handleResponse(response) + } + } + + private suspend inline fun 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 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() + } +} diff --git a/sdk/kotlin/src/main/kotlin/io/synor/rpc/Types.kt b/sdk/kotlin/src/main/kotlin/io/synor/rpc/Types.kt new file mode 100644 index 0000000..93ab471 --- /dev/null +++ b/sdk/kotlin/src/main/kotlin/io/synor/rpc/Types.kt @@ -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, + 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, + val outputs: List, + 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? = 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) diff --git a/sdk/kotlin/src/main/kotlin/io/synor/storage/SynorStorage.kt b/sdk/kotlin/src/main/kotlin/io/synor/storage/SynorStorage.kt new file mode 100644 index 0000000..e528bd2 --- /dev/null +++ b/sdk/kotlin/src/main/kotlin/io/synor/storage/SynorStorage.kt @@ -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, 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 = 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 { + 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): 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 { + 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> = 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 { + return get("/content/$cid/ls") + } + + // Statistics + + /** + * Get storage statistics. + * + * @return Storage stats + */ + suspend fun getStats(): StorageStats { + return get("/stats") + } + + private suspend inline fun get(path: String): T { + return executeWithRetry { + val response = client.get("${config.endpoint}$path") { + header("Content-Type", "application/json") + } + handleResponse(response) + } + } + + private suspend inline fun 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 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 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() + } +} diff --git a/sdk/kotlin/src/main/kotlin/io/synor/storage/Types.kt b/sdk/kotlin/src/main/kotlin/io/synor/storage/Types.kt new file mode 100644 index 0000000..dc0eb98 --- /dev/null +++ b/sdk/kotlin/src/main/kotlin/io/synor/storage/Types.kt @@ -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? = 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? = null, + val meta: Map? = null +) + +/** + * CAR (Content Addressable Archive) file. + */ +@Serializable +data class CarFile( + val cid: String, + val size: Long, + val roots: List, + @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) diff --git a/sdk/kotlin/src/main/kotlin/io/synor/wallet/SynorWallet.kt b/sdk/kotlin/src/main/kotlin/io/synor/wallet/SynorWallet.kt new file mode 100644 index 0000000..c3493f9 --- /dev/null +++ b/sdk/kotlin/src/main/kotlin/io/synor/wallet/SynorWallet.kt @@ -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 = 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 { + 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 = get("/fees/estimate?priority=${priority.name.lowercase()}") + return response["fee_per_byte"] ?: 0L + } + + private suspend inline fun get(path: String): T { + return executeWithRetry { + val response = client.get("${config.endpoint}$path") + handleResponse(response) + } + } + + private suspend inline fun post(path: String, body: R): T { + return executeWithRetry { + val response = client.post("${config.endpoint}$path") { + setBody(body) + } + handleResponse(response) + } + } + + private suspend inline fun 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 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() + } +} diff --git a/sdk/kotlin/src/main/kotlin/io/synor/wallet/Types.kt b/sdk/kotlin/src/main/kotlin/io/synor/wallet/Types.kt new file mode 100644 index 0000000..0688a38 --- /dev/null +++ b/sdk/kotlin/src/main/kotlin/io/synor/wallet/Types.kt @@ -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
, + @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, + val outputs: List, + 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) diff --git a/sdk/python/src/synor_bridge/__init__.py b/sdk/python/src/synor_bridge/__init__.py new file mode 100644 index 0000000..17463ad --- /dev/null +++ b/sdk/python/src/synor_bridge/__init__.py @@ -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", +] diff --git a/sdk/python/src/synor_bridge/client.py b/sdk/python/src/synor_bridge/client.py new file mode 100644 index 0000000..33d2254 --- /dev/null +++ b/sdk/python/src/synor_bridge/client.py @@ -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}") diff --git a/sdk/python/src/synor_bridge/types.py b/sdk/python/src/synor_bridge/types.py new file mode 100644 index 0000000..88859d6 --- /dev/null +++ b/sdk/python/src/synor_bridge/types.py @@ -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 {} diff --git a/sdk/python/src/synor_database/__init__.py b/sdk/python/src/synor_database/__init__.py new file mode 100644 index 0000000..32bda85 --- /dev/null +++ b/sdk/python/src/synor_database/__init__.py @@ -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", +] diff --git a/sdk/python/src/synor_database/client.py b/sdk/python/src/synor_database/client.py new file mode 100644 index 0000000..063f683 --- /dev/null +++ b/sdk/python/src/synor_database/client.py @@ -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") diff --git a/sdk/python/src/synor_database/types.py b/sdk/python/src/synor_database/types.py new file mode 100644 index 0000000..a295593 --- /dev/null +++ b/sdk/python/src/synor_database/types.py @@ -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) diff --git a/sdk/python/src/synor_hosting/__init__.py b/sdk/python/src/synor_hosting/__init__.py new file mode 100644 index 0000000..b1847e4 --- /dev/null +++ b/sdk/python/src/synor_hosting/__init__.py @@ -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", +] diff --git a/sdk/python/src/synor_hosting/client.py b/sdk/python/src/synor_hosting/client.py new file mode 100644 index 0000000..fe7a04c --- /dev/null +++ b/sdk/python/src/synor_hosting/client.py @@ -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), + ) diff --git a/sdk/python/src/synor_hosting/types.py b/sdk/python/src/synor_hosting/types.py new file mode 100644 index 0000000..ce0332e --- /dev/null +++ b/sdk/python/src/synor_hosting/types.py @@ -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) diff --git a/sdk/ruby/lib/synor_rpc.rb b/sdk/ruby/lib/synor_rpc.rb new file mode 100644 index 0000000..ed87b19 --- /dev/null +++ b/sdk/ruby/lib/synor_rpc.rb @@ -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 diff --git a/sdk/ruby/lib/synor_rpc/client.rb b/sdk/ruby/lib/synor_rpc/client.rb new file mode 100644 index 0000000..c425dc1 --- /dev/null +++ b/sdk/ruby/lib/synor_rpc/client.rb @@ -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] + 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] + 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] + 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] + 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 diff --git a/sdk/ruby/lib/synor_rpc/types.rb b/sdk/ruby/lib/synor_rpc/types.rb new file mode 100644 index 0000000..4b26b55 --- /dev/null +++ b/sdk/ruby/lib/synor_rpc/types.rb @@ -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 diff --git a/sdk/ruby/lib/synor_rpc/version.rb b/sdk/ruby/lib/synor_rpc/version.rb new file mode 100644 index 0000000..586b407 --- /dev/null +++ b/sdk/ruby/lib/synor_rpc/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module SynorRpc + VERSION = "0.1.0" +end diff --git a/sdk/ruby/lib/synor_storage.rb b/sdk/ruby/lib/synor_storage.rb new file mode 100644 index 0000000..0be1907 --- /dev/null +++ b/sdk/ruby/lib/synor_storage.rb @@ -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 diff --git a/sdk/ruby/lib/synor_storage/client.rb b/sdk/ruby/lib/synor_storage/client.rb new file mode 100644 index 0000000..7de82fb --- /dev/null +++ b/sdk/ruby/lib/synor_storage/client.rb @@ -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] 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] + 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] + # @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] 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] + 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 diff --git a/sdk/ruby/lib/synor_storage/types.rb b/sdk/ruby/lib/synor_storage/types.rb new file mode 100644 index 0000000..c5defd5 --- /dev/null +++ b/sdk/ruby/lib/synor_storage/types.rb @@ -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 diff --git a/sdk/ruby/lib/synor_storage/version.rb b/sdk/ruby/lib/synor_storage/version.rb new file mode 100644 index 0000000..5f61e86 --- /dev/null +++ b/sdk/ruby/lib/synor_storage/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module SynorStorage + VERSION = "0.1.0" +end diff --git a/sdk/ruby/lib/synor_wallet.rb b/sdk/ruby/lib/synor_wallet.rb new file mode 100644 index 0000000..145e624 --- /dev/null +++ b/sdk/ruby/lib/synor_wallet.rb @@ -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 diff --git a/sdk/ruby/lib/synor_wallet/client.rb b/sdk/ruby/lib/synor_wallet/client.rb new file mode 100644 index 0000000..ee5ae5d --- /dev/null +++ b/sdk/ruby/lib/synor_wallet/client.rb @@ -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] + 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 diff --git a/sdk/ruby/lib/synor_wallet/types.rb b/sdk/ruby/lib/synor_wallet/types.rb new file mode 100644 index 0000000..a7a482e --- /dev/null +++ b/sdk/ruby/lib/synor_wallet/types.rb @@ -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 diff --git a/sdk/ruby/lib/synor_wallet/version.rb b/sdk/ruby/lib/synor_wallet/version.rb new file mode 100644 index 0000000..251c3a6 --- /dev/null +++ b/sdk/ruby/lib/synor_wallet/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module SynorWallet + VERSION = "0.1.0" +end diff --git a/sdk/ruby/synor_rpc.gemspec b/sdk/ruby/synor_rpc.gemspec new file mode 100644 index 0000000..036a138 --- /dev/null +++ b/sdk/ruby/synor_rpc.gemspec @@ -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 diff --git a/sdk/ruby/synor_storage.gemspec b/sdk/ruby/synor_storage.gemspec new file mode 100644 index 0000000..8ecaad0 --- /dev/null +++ b/sdk/ruby/synor_storage.gemspec @@ -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 diff --git a/sdk/ruby/synor_wallet.gemspec b/sdk/ruby/synor_wallet.gemspec new file mode 100644 index 0000000..4eded5f --- /dev/null +++ b/sdk/ruby/synor_wallet.gemspec @@ -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 diff --git a/sdk/rust/src/bridge/client.rs b/sdk/rust/src/bridge/client.rs new file mode 100644 index 0000000..7cec305 --- /dev/null +++ b/sdk/rust/src/bridge/client.rs @@ -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, +} + +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> { + #[derive(Deserialize)] + struct Response { + chains: Vec, + } + 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 { + 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> { + #[derive(Deserialize)] + struct Response { + assets: Vec, + } + 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 { + 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 { + 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> { + #[derive(Deserialize)] + struct Response { + assets: Vec, + } + 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 { + #[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 { + 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, + ) -> Result { + #[derive(Serialize)] + struct Request { + asset: String, + amount: String, + #[serde(rename = "targetChain")] + target_chain: ChainId, + #[serde(flatten)] + options: Option, + } + 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 { + 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, + max_wait: Option, + ) -> Result { + 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, + ) -> Result { + #[derive(Serialize)] + struct Request<'a> { + proof: &'a LockProof, + #[serde(rename = "targetAddress")] + target_address: String, + #[serde(flatten)] + options: Option, + } + 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, + ) -> Result { + #[derive(Serialize)] + struct Request { + #[serde(rename = "wrappedAsset")] + wrapped_asset: String, + amount: String, + #[serde(flatten)] + options: Option, + } + 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 { + 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, + max_wait: Option, + ) -> Result { + 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) -> Result { + #[derive(Serialize)] + struct Request<'a> { + proof: &'a BurnProof, + #[serde(flatten)] + options: Option, + } + 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 { + 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 { + let transfer = self.get_transfer(transfer_id).await?; + Ok(transfer.status) + } + + /// List transfers + pub async fn list_transfers(&self, filter: Option) -> Result> { + 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, + } + 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, + max_wait: Option, + ) -> Result { + 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, + mint_options: Option, + ) -> Result { + // 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, + unlock_options: Option, + ) -> Result { + // 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( + &self, + method: &str, + path: &str, + body: Option, + ) -> Result { + 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( + &self, + method: &str, + path: &str, + body: &Option, + ) -> Result { + 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) + } +} diff --git a/sdk/rust/src/bridge/error.rs b/sdk/rust/src/bridge/error.rs new file mode 100644 index 0000000..e09b052 --- /dev/null +++ b/sdk/rust/src/bridge/error.rs @@ -0,0 +1,58 @@ +//! Bridge error types + +use std::fmt; + +/// Bridge error +#[derive(Debug)] +pub struct BridgeError { + pub message: String, + pub code: Option, + pub status_code: Option, +} + +impl BridgeError { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + code: None, + status_code: None, + } + } + + pub fn with_code(mut self, code: impl Into) -> 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 for BridgeError { + fn from(err: reqwest::Error) -> Self { + Self::new(err.to_string()) + } +} + +impl From for BridgeError { + fn from(err: serde_json::Error) -> Self { + Self::new(err.to_string()) + } +} + +/// Result type alias +pub type Result = std::result::Result; diff --git a/sdk/rust/src/bridge/mod.rs b/sdk/rust/src/bridge/mod.rs new file mode 100644 index 0000000..829696a --- /dev/null +++ b/sdk/rust/src/bridge/mod.rs @@ -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::*; diff --git a/sdk/rust/src/bridge/types.rs b/sdk/rust/src/bridge/types.rs new file mode 100644 index 0000000..60df03a --- /dev/null +++ b/sdk/rust/src/bridge/types.rs @@ -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, + pub decimals: u8, + #[serde(rename = "logoUrl", skip_serializing_if = "Option::is_none")] + pub logo_url: Option, + #[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, + #[serde(rename = "blockHeader")] + pub block_header: String, + pub signatures: Vec, +} + +/// 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, + #[serde(rename = "blockHeader")] + pub block_header: String, + pub signatures: Vec, +} + +/// 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, + #[serde(rename = "targetTxHash", skip_serializing_if = "Option::is_none")] + pub target_tx_hash: Option, + 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, + #[serde(rename = "errorMessage", skip_serializing_if = "Option::is_none")] + pub error_message: Option, +} + +// ==================== 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, +} + +/// 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, + #[serde(rename = "maxFeePerGas", skip_serializing_if = "Option::is_none")] + pub max_fee_per_gas: Option, + #[serde(rename = "maxPriorityFeePerGas", skip_serializing_if = "Option::is_none")] + pub max_priority_fee_per_gas: Option, + pub nonce: u64, + pub signature: String, +} + +// ==================== Filter Types ==================== + +/// Transfer filter +#[derive(Debug, Clone, Default)] +pub struct TransferFilter { + pub status: Option, + pub source_chain: Option, + pub target_chain: Option, + pub asset: Option, + pub sender: Option, + pub recipient: Option, + pub from_date: Option, + pub to_date: Option, + pub limit: Option, + pub offset: Option, +} + +// ==================== Options Types ==================== + +/// Lock options +#[derive(Debug, Clone, Default, Serialize)] +pub struct LockOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub recipient: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deadline: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub slippage: Option, +} + +/// Mint options +#[derive(Debug, Clone, Default, Serialize)] +pub struct MintOptions { + #[serde(rename = "gasLimit", skip_serializing_if = "Option::is_none")] + pub gas_limit: Option, + #[serde(rename = "maxFeePerGas", skip_serializing_if = "Option::is_none")] + pub max_fee_per_gas: Option, + #[serde(rename = "maxPriorityFeePerGas", skip_serializing_if = "Option::is_none")] + pub max_priority_fee_per_gas: Option, +} + +/// Burn options +#[derive(Debug, Clone, Default, Serialize)] +pub struct BurnOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub recipient: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deadline: Option, +} + +/// Unlock options +#[derive(Debug, Clone, Default, Serialize)] +pub struct UnlockOptions { + #[serde(rename = "gasLimit", skip_serializing_if = "Option::is_none")] + pub gas_limit: Option, + #[serde(rename = "gasPrice", skip_serializing_if = "Option::is_none")] + pub gas_price: Option, +} + +// ==================== 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, + } + } +} diff --git a/sdk/rust/src/database/client.rs b/sdk/rust/src/database/client.rs new file mode 100644 index 0000000..acd0004 --- /dev/null +++ b/sdk/rust/src/database/client.rs @@ -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, +} + +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 { + 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( + &self, + method: &str, + path: &str, + body: Option, + ) -> Result { + 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( + &self, + method: &str, + path: &str, + body: &Option, + ) -> Result { + 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(&self, key: &str) -> Result> { + #[derive(serde::Deserialize)] + struct Response { + value: Option, + } + + let path = format!("/kv/{}", urlencoding::encode(key)); + let resp: Response = self.client.request("GET", &path, Option::<()>::None).await?; + Ok(resp.value) + } + + /// Get a full entry with metadata + pub async fn get_entry(&self, key: &str) -> Result>> { + let path = format!("/kv/{}/entry", urlencoding::encode(key)); + self.client.request("GET", &path, Option::<()>::None).await + } + + /// Set a value + pub async fn set(&self, key: &str, value: T, options: Option) -> Result<()> { + #[derive(Serialize)] + struct SetRequest { + value: T, + #[serde(flatten)] + options: Option, + } + + 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 { + #[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 { + #[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(&self, options: Option) -> Result> { + 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 { + #[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( + &self, + collection: &str, + data: T, + options: Option, + ) -> Result> { + #[derive(Serialize)] + struct CreateRequest { + data: T, + #[serde(flatten)] + options: Option, + } + + 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(&self, collection: &str, id: &str) -> Result>> { + 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( + &self, + collection: &str, + id: &str, + update: T, + options: Option, + ) -> Result> { + #[derive(Serialize)] + struct UpdateRequest { + update: T, + #[serde(flatten)] + options: Option, + } + + 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 { + #[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(&self, collection: &str, options: Option) -> Result> { + 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) -> Result { + #[derive(Serialize)] + struct CountRequest { + filter: Option, + } + + #[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, + options: Option, + ) -> Result { + #[derive(Serialize)] + struct UpsertRequest { + vectors: Vec, + #[serde(flatten)] + options: Option, + } + + #[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, + options: Option, + ) -> Result> { + #[derive(Serialize)] + struct SearchRequest { + vector: Vec, + #[serde(flatten)] + options: Option, + } + + #[derive(serde::Deserialize)] + struct Response { + results: Vec, + } + + 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, namespace: Option) -> Result { + #[derive(Serialize)] + struct DeleteRequest { + ids: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + namespace: Option, + } + + #[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, + options: Option, + ) -> Result { + #[derive(Serialize)] + struct WriteRequest { + points: Vec, + #[serde(flatten)] + options: Option, + } + + #[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, + ) -> Result { + #[derive(Serialize)] + struct QueryRequest { + range: TimeRange, + #[serde(flatten)] + options: Option, + } + + 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>, + ) -> Result { + #[derive(Serialize)] + struct DeleteRequest { + range: TimeRange, + #[serde(skip_serializing_if = "Option::is_none")] + tags: Option>, + } + + #[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 { + 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> { + #[derive(serde::Deserialize)] + struct Response { + series: Vec, + } + + 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(()) + } +} diff --git a/sdk/rust/src/database/error.rs b/sdk/rust/src/database/error.rs new file mode 100644 index 0000000..6bfebd2 --- /dev/null +++ b/sdk/rust/src/database/error.rs @@ -0,0 +1,54 @@ +//! Database error types + +use std::fmt; + +/// Database error +#[derive(Debug)] +pub struct DatabaseError { + pub message: String, + pub code: Option, + pub status_code: Option, +} + +impl DatabaseError { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + code: None, + status_code: None, + } + } + + pub fn with_code(mut self, code: impl Into) -> 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 for DatabaseError { + fn from(err: reqwest::Error) -> Self { + Self::new(err.to_string()) + } +} + +impl From for DatabaseError { + fn from(err: serde_json::Error) -> Self { + Self::new(err.to_string()) + } +} + +/// Result type alias +pub type Result = std::result::Result; diff --git a/sdk/rust/src/database/mod.rs b/sdk/rust/src/database/mod.rs new file mode 100644 index 0000000..9ae7053 --- /dev/null +++ b/sdk/rust/src/database/mod.rs @@ -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> { +//! 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::("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::*; diff --git a/sdk/rust/src/database/types.rs b/sdk/rust/src/database/types.rs new file mode 100644 index 0000000..a6b4977 --- /dev/null +++ b/sdk/rust/src/database/types.rs @@ -0,0 +1,330 @@ +//! Database types + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Network environment +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum Network { + #[default] + Mainnet, + Testnet, + Devnet, +} + +/// Database configuration +#[derive(Debug, Clone)] +pub struct Config { + pub api_key: String, + pub endpoint: String, + pub network: Network, + pub timeout_secs: u64, + pub retries: u32, + pub debug: bool, +} + +impl Default for Config { + fn default() -> Self { + Self { + api_key: String::new(), + endpoint: "https://database.synor.io/v1".to_string(), + network: Network::Mainnet, + timeout_secs: 30, + retries: 3, + debug: false, + } + } +} + +// ==================== Key-Value Types ==================== + +/// Key-value entry with metadata +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KeyValueEntry { + pub key: String, + pub value: T, + #[serde(default)] + pub metadata: Option>, + #[serde(default)] + pub expires_at: Option, + pub created_at: i64, + pub updated_at: i64, +} + +/// Options for setting a key-value pair +#[derive(Debug, Clone, Default, Serialize)] +pub struct SetOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub ttl: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub if_not_exists: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub if_exists: Option, +} + +/// Options for listing keys +#[derive(Debug, Clone, Default, Serialize)] +pub struct ListOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub prefix: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +/// Result of listing keys +#[derive(Debug, Clone, Deserialize)] +pub struct ListResult { + pub entries: Vec>, + pub cursor: Option, + pub has_more: bool, +} + +// ==================== Document Store Types ==================== + +/// A document with metadata +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Document { + pub id: String, + pub data: T, + #[serde(default)] + pub metadata: Option>, + pub created_at: i64, + pub updated_at: i64, + pub version: i32, +} + +/// Options for creating a document +#[derive(Debug, Clone, Default, Serialize)] +pub struct CreateDocumentOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, +} + +/// Options for updating a document +#[derive(Debug, Clone, Default, Serialize)] +pub struct UpdateDocumentOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub upsert: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub return_document: Option, +} + +/// Sort order +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SortOrder { + Asc, + Desc, +} + +/// Query options for document store +#[derive(Debug, Clone, Default, Serialize)] +pub struct QueryOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub filter: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sort: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub skip: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub projection: Option>, +} + +/// Result of a document query +#[derive(Debug, Clone, Deserialize)] +pub struct QueryResult { + pub documents: Vec>, + pub total: u64, + pub has_more: bool, +} + +// ==================== Vector Store Types ==================== + +/// A vector entry +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct VectorEntry { + pub id: String, + pub vector: Vec, + #[serde(default)] + pub metadata: Option, + #[serde(default)] + pub content: Option, +} + +/// Vector distance metric +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum DistanceMetric { + #[default] + Cosine, + Euclidean, + DotProduct, +} + +/// Options for upserting vectors +#[derive(Debug, Clone, Default, Serialize)] +pub struct UpsertVectorOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub namespace: Option, +} + +/// Options for vector search +#[derive(Debug, Clone, Default, Serialize)] +pub struct SearchOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub namespace: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_k: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub filter: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub include_metadata: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub include_vectors: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub min_score: Option, +} + +/// Result of a vector search +#[derive(Debug, Clone, Deserialize)] +pub struct SearchResult { + pub id: String, + pub score: f64, + #[serde(default)] + pub vector: Option>, + #[serde(default)] + pub metadata: Option, + #[serde(default)] + pub content: Option, +} + +/// Configuration for a vector collection +#[derive(Debug, Clone, Serialize)] +pub struct VectorCollectionConfig { + pub name: String, + pub dimension: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub metric: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub index_type: Option, +} + +/// Statistics for a vector collection +#[derive(Debug, Clone, Deserialize)] +pub struct VectorCollectionStats { + pub name: String, + pub vector_count: u64, + pub dimension: u32, + pub index_size: u64, +} + +// ==================== Time Series Types ==================== + +/// A time series data point +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DataPoint { + pub timestamp: i64, + pub value: f64, + #[serde(default)] + pub tags: Option>, +} + +/// Options for writing data points +#[derive(Debug, Clone, Default, Serialize)] +pub struct WritePointsOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub precision: Option, +} + +/// Time series aggregation function +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Aggregation { + Mean, + Sum, + Count, + Min, + Max, + First, + Last, + Stddev, + Variance, +} + +/// Time range for queries +#[derive(Debug, Clone, Serialize)] +pub struct TimeRange { + pub start: serde_json::Value, + pub end: serde_json::Value, +} + +impl TimeRange { + pub fn new(start: i64, end: i64) -> Self { + Self { + start: serde_json::Value::Number(start.into()), + end: serde_json::Value::Number(end.into()), + } + } + + pub fn from_strings(start: &str, end: &str) -> Self { + Self { + start: serde_json::Value::String(start.to_string()), + end: serde_json::Value::String(end.to_string()), + } + } +} + +/// Options for time series queries +#[derive(Debug, Clone, Default, Serialize)] +pub struct TimeSeriesQueryOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub aggregation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub interval: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fill: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +/// Result of a time series query +#[derive(Debug, Clone, Deserialize)] +pub struct TimeSeriesResult { + pub series: String, + pub points: Vec, + #[serde(default)] + pub statistics: Option>, +} + +/// Information about a time series +#[derive(Debug, Clone, Deserialize)] +pub struct SeriesInfo { + pub name: String, + pub tags: Vec, + pub retention_days: i32, + pub point_count: u64, +} + +// ==================== Database Stats ==================== + +/// Overall database statistics +#[derive(Debug, Clone, Deserialize)] +pub struct DatabaseStats { + pub kv_entries: u64, + pub document_count: u64, + pub vector_count: u64, + pub timeseries_points: u64, + pub storage_used: u64, + pub storage_limit: u64, +} diff --git a/sdk/rust/src/hosting/client.rs b/sdk/rust/src/hosting/client.rs new file mode 100644 index 0000000..6146a55 --- /dev/null +++ b/sdk/rust/src/hosting/client.rs @@ -0,0 +1,351 @@ +//! Hosting 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::{HostingError, Result}; +use super::types::*; + +/// Synor Hosting Client +pub struct HostingClient { + config: HostingConfig, + http_client: reqwest::Client, + closed: Arc, +} + +impl HostingClient { + /// Create a new hosting client + pub fn new(config: HostingConfig) -> 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)), + } + } + + // ==================== Domain Operations ==================== + + /// Check domain availability + pub async fn check_availability(&self, name: &str) -> Result { + self.request("GET", &format!("/domains/check/{}", urlencoding::encode(name)), Option::<()>::None).await + } + + /// Register a new domain + pub async fn register_domain(&self, name: &str, options: Option) -> Result { + #[derive(Serialize)] + struct Request { + name: String, + #[serde(flatten)] + options: Option, + } + self.request("POST", "/domains", Some(Request { name: name.to_string(), options })).await + } + + /// Get domain information + pub async fn get_domain(&self, name: &str) -> Result { + self.request("GET", &format!("/domains/{}", urlencoding::encode(name)), Option::<()>::None).await + } + + /// List all domains + pub async fn list_domains(&self) -> Result> { + #[derive(Deserialize)] + struct Response { + domains: Vec, + } + let resp: Response = self.request("GET", "/domains", Option::<()>::None).await?; + Ok(resp.domains) + } + + /// Update domain record + pub async fn update_domain_record(&self, name: &str, record: &DomainRecord) -> Result { + self.request("PUT", &format!("/domains/{}/record", urlencoding::encode(name)), Some(record)).await + } + + /// Resolve a domain + pub async fn resolve_domain(&self, name: &str) -> Result { + self.request("GET", &format!("/domains/{}/resolve", urlencoding::encode(name)), Option::<()>::None).await + } + + /// Renew a domain + pub async fn renew_domain(&self, name: &str, years: i32) -> Result { + #[derive(Serialize)] + struct Request { + years: i32, + } + self.request("POST", &format!("/domains/{}/renew", urlencoding::encode(name)), Some(Request { years })).await + } + + // ==================== DNS Operations ==================== + + /// Get DNS zone for a domain + pub async fn get_dns_zone(&self, domain: &str) -> Result { + self.request("GET", &format!("/dns/{}", urlencoding::encode(domain)), Option::<()>::None).await + } + + /// Set DNS records for a domain + pub async fn set_dns_records(&self, domain: &str, records: Vec) -> Result { + #[derive(Serialize)] + struct Request { + records: Vec, + } + self.request("PUT", &format!("/dns/{}", urlencoding::encode(domain)), Some(Request { records })).await + } + + /// Add a DNS record + pub async fn add_dns_record(&self, domain: &str, record: DnsRecord) -> Result { + self.request("POST", &format!("/dns/{}/records", urlencoding::encode(domain)), Some(record)).await + } + + /// Delete a DNS record + pub async fn delete_dns_record(&self, domain: &str, record_type: &str, name: &str) -> Result { + self.request( + "DELETE", + &format!("/dns/{}/records/{}/{}", urlencoding::encode(domain), record_type, urlencoding::encode(name)), + Option::<()>::None, + ).await + } + + // ==================== Deployment Operations ==================== + + /// Deploy a site from CID + pub async fn deploy(&self, cid: &str, options: Option) -> Result { + #[derive(Serialize)] + struct Request { + cid: String, + #[serde(flatten)] + options: Option, + } + self.request("POST", "/deployments", Some(Request { cid: cid.to_string(), options })).await + } + + /// Get deployment by ID + pub async fn get_deployment(&self, id: &str) -> Result { + self.request("GET", &format!("/deployments/{}", urlencoding::encode(id)), Option::<()>::None).await + } + + /// List deployments + pub async fn list_deployments(&self, domain: Option<&str>) -> Result> { + let path = match domain { + Some(d) => format!("/deployments?domain={}", urlencoding::encode(d)), + None => "/deployments".to_string(), + }; + #[derive(Deserialize)] + struct Response { + deployments: Vec, + } + let resp: Response = self.request("GET", &path, Option::<()>::None).await?; + Ok(resp.deployments) + } + + /// Rollback to a previous deployment + pub async fn rollback(&self, domain: &str, deployment_id: &str) -> Result { + #[derive(Serialize)] + struct Request { + domain: String, + } + self.request( + "POST", + &format!("/deployments/{}/rollback", urlencoding::encode(deployment_id)), + Some(Request { domain: domain.to_string() }), + ).await + } + + /// Delete a deployment + pub async fn delete_deployment(&self, id: &str) -> Result<()> { + self.request::<_, serde_json::Value>("DELETE", &format!("/deployments/{}", urlencoding::encode(id)), Option::<()>::None).await?; + Ok(()) + } + + /// Get deployment stats + pub async fn get_deployment_stats(&self, id: &str, period: &str) -> Result { + self.request( + "GET", + &format!("/deployments/{}/stats?period={}", urlencoding::encode(id), urlencoding::encode(period)), + Option::<()>::None, + ).await + } + + // ==================== SSL Operations ==================== + + /// Provision SSL certificate + pub async fn provision_ssl(&self, domain: &str, options: Option) -> Result { + self.request("POST", &format!("/ssl/{}", urlencoding::encode(domain)), options.or(Some(ProvisionSslOptions::default()))).await + } + + /// Get certificate status + pub async fn get_certificate(&self, domain: &str) -> Result { + self.request("GET", &format!("/ssl/{}", urlencoding::encode(domain)), Option::<()>::None).await + } + + /// Renew SSL certificate + pub async fn renew_certificate(&self, domain: &str) -> Result { + self.request("POST", &format!("/ssl/{}/renew", urlencoding::encode(domain)), Option::<()>::None).await + } + + /// Delete/revoke SSL certificate + pub async fn delete_certificate(&self, domain: &str) -> Result<()> { + self.request::<_, serde_json::Value>("DELETE", &format!("/ssl/{}", urlencoding::encode(domain)), Option::<()>::None).await?; + Ok(()) + } + + // ==================== Site Configuration ==================== + + /// Get site configuration + pub async fn get_site_config(&self, domain: &str) -> Result { + self.request("GET", &format!("/sites/{}/config", urlencoding::encode(domain)), Option::<()>::None).await + } + + /// Update site configuration + pub async fn update_site_config(&self, domain: &str, config: serde_json::Value) -> Result { + self.request("PATCH", &format!("/sites/{}/config", urlencoding::encode(domain)), Some(config)).await + } + + /// Purge CDN cache + pub async fn purge_cache(&self, domain: &str, paths: Option>) -> Result { + #[derive(Serialize)] + struct Request { + #[serde(skip_serializing_if = "Option::is_none")] + paths: Option>, + } + #[derive(Deserialize)] + struct Response { + purged: u64, + } + let resp: Response = self.request("DELETE", &format!("/sites/{}/cache", urlencoding::encode(domain)), Some(Request { paths })).await?; + Ok(resp.purged) + } + + // ==================== Analytics ==================== + + /// Get site analytics + pub async fn get_analytics(&self, domain: &str, options: Option) -> Result { + let mut path = format!("/sites/{}/analytics", urlencoding::encode(domain)); + if let Some(opts) = options { + let mut params = vec![]; + if let Some(period) = opts.period { + params.push(format!("period={}", urlencoding::encode(&period))); + } + if let Some(start) = opts.start { + params.push(format!("start={}", urlencoding::encode(&start))); + } + if let Some(end) = opts.end { + params.push(format!("end={}", urlencoding::encode(&end))); + } + if !params.is_empty() { + path = format!("{}?{}", path, params.join("&")); + } + } + self.request("GET", &path, Option::<()>::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( + &self, + method: &str, + path: &str, + body: Option, + ) -> Result { + if self.is_closed() { + return Err(HostingError::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(|| HostingError::new("Unknown error after retries"))) + } + + async fn do_request( + &self, + method: &str, + path: &str, + body: &Option, + ) -> Result { + 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(HostingError::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(HostingError::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) + } +} diff --git a/sdk/rust/src/hosting/error.rs b/sdk/rust/src/hosting/error.rs new file mode 100644 index 0000000..8c3f0fa --- /dev/null +++ b/sdk/rust/src/hosting/error.rs @@ -0,0 +1,54 @@ +//! Hosting error types + +use std::fmt; + +/// Hosting error +#[derive(Debug)] +pub struct HostingError { + pub message: String, + pub code: Option, + pub status_code: Option, +} + +impl HostingError { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + code: None, + status_code: None, + } + } + + pub fn with_code(mut self, code: impl Into) -> 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 HostingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for HostingError {} + +impl From for HostingError { + fn from(err: reqwest::Error) -> Self { + Self::new(err.to_string()) + } +} + +impl From for HostingError { + fn from(err: serde_json::Error) -> Self { + Self::new(err.to_string()) + } +} + +/// Result type alias +pub type Result = std::result::Result; diff --git a/sdk/rust/src/hosting/mod.rs b/sdk/rust/src/hosting/mod.rs new file mode 100644 index 0000000..7e71571 --- /dev/null +++ b/sdk/rust/src/hosting/mod.rs @@ -0,0 +1,11 @@ +//! Synor Hosting SDK for Rust +//! +//! Decentralized web hosting with domain management, DNS, and deployments. + +mod types; +mod error; +mod client; + +pub use types::*; +pub use error::*; +pub use client::*; diff --git a/sdk/rust/src/hosting/types.rs b/sdk/rust/src/hosting/types.rs new file mode 100644 index 0000000..c043ad8 --- /dev/null +++ b/sdk/rust/src/hosting/types.rs @@ -0,0 +1,324 @@ +//! Hosting types + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Network environment +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum Network { + #[default] + Mainnet, + Testnet, + Devnet, +} + +/// Hosting configuration +#[derive(Debug, Clone)] +pub struct HostingConfig { + pub api_key: String, + pub endpoint: String, + pub network: Network, + pub timeout_secs: u64, + pub retries: u32, + pub debug: bool, +} + +impl Default for HostingConfig { + fn default() -> Self { + Self { + api_key: String::new(), + endpoint: "https://hosting.synor.io/v1".to_string(), + network: Network::Mainnet, + timeout_secs: 60, + retries: 3, + debug: false, + } + } +} + +// ==================== Domain Types ==================== + +/// Domain status +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DomainStatus { + Pending, + Active, + Expired, + Suspended, +} + +/// Domain information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Domain { + pub name: String, + pub status: DomainStatus, + pub owner: String, + pub registered_at: i64, + pub expires_at: i64, + pub auto_renew: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub records: Option, +} + +/// Domain record for resolution +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct DomainRecord { + #[serde(skip_serializing_if = "Option::is_none")] + pub cid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ipv4: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub ipv6: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub cname: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub txt: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, +} + +/// Domain availability +#[derive(Debug, Clone, Deserialize)] +pub struct DomainAvailability { + pub name: String, + pub available: bool, + pub price: Option, + #[serde(default)] + pub premium: bool, +} + +/// Options for registering a domain +#[derive(Debug, Clone, Default, Serialize)] +pub struct RegisterDomainOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub years: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_renew: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub records: Option, +} + +// ==================== DNS Types ==================== + +/// DNS record type +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum DnsRecordType { + A, + AAAA, + CNAME, + TXT, + MX, + NS, + SRV, + CAA, +} + +/// DNS record +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DnsRecord { + #[serde(rename = "type")] + pub record_type: DnsRecordType, + pub name: String, + pub value: String, + #[serde(default = "default_ttl")] + pub ttl: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub priority: Option, +} + +fn default_ttl() -> i32 { + 3600 +} + +/// DNS zone +#[derive(Debug, Clone, Deserialize)] +pub struct DnsZone { + pub domain: String, + pub records: Vec, + pub updated_at: i64, +} + +// ==================== Deployment Types ==================== + +/// Deployment status +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DeploymentStatus { + Pending, + Building, + Deploying, + Active, + Failed, + Inactive, +} + +/// Deployment information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Deployment { + pub id: String, + pub domain: String, + pub cid: String, + pub status: DeploymentStatus, + pub url: String, + pub created_at: i64, + pub updated_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub build_logs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_message: Option, +} + +/// Redirect rule +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RedirectRule { + pub source: String, + pub destination: String, + #[serde(default = "default_status_code")] + pub status_code: i32, + #[serde(default = "default_permanent")] + pub permanent: bool, +} + +fn default_status_code() -> i32 { + 301 +} + +fn default_permanent() -> bool { + true +} + +/// Options for deploying +#[derive(Debug, Clone, Default, Serialize)] +pub struct DeployOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub domain: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub subdomain: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub redirects: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub spa: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub clean_urls: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub trailing_slash: Option, +} + +/// Deployment statistics +#[derive(Debug, Clone, Deserialize)] +pub struct DeploymentStats { + pub requests: u64, + pub bandwidth: u64, + pub unique_visitors: u64, + pub period: String, +} + +// ==================== SSL Types ==================== + +/// Certificate status +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CertificateStatus { + Pending, + Issued, + Expired, + Revoked, +} + +/// SSL certificate +#[derive(Debug, Clone, Deserialize)] +pub struct Certificate { + pub domain: String, + pub status: CertificateStatus, + pub auto_renew: bool, + pub issuer: String, + pub issued_at: Option, + pub expires_at: Option, + pub fingerprint: Option, +} + +/// Options for provisioning SSL +#[derive(Debug, Clone, Default, Serialize)] +pub struct ProvisionSslOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub include_www: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub auto_renew: Option, +} + +// ==================== Site Configuration ==================== + +/// Site configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SiteConfig { + pub domain: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub cid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub redirects: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_pages: Option>, + #[serde(default)] + pub spa: bool, + #[serde(default = "default_true")] + pub clean_urls: bool, + #[serde(default)] + pub trailing_slash: bool, +} + +fn default_true() -> bool { + true +} + +// ==================== Analytics ==================== + +/// Page view data +#[derive(Debug, Clone, Deserialize)] +pub struct PageView { + pub path: String, + pub views: u64, +} + +/// Referrer data +#[derive(Debug, Clone, Deserialize)] +pub struct Referrer { + pub referrer: String, + pub count: u64, +} + +/// Country data +#[derive(Debug, Clone, Deserialize)] +pub struct Country { + pub country: String, + pub count: u64, +} + +/// Analytics data +#[derive(Debug, Clone, Deserialize)] +pub struct AnalyticsData { + pub domain: String, + pub period: String, + pub page_views: u64, + pub unique_visitors: u64, + pub bandwidth: u64, + pub top_pages: Vec, + pub top_referrers: Vec, + pub top_countries: Vec, +} + +/// Analytics options +#[derive(Debug, Clone, Default, Serialize)] +pub struct AnalyticsOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub period: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub start: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub end: Option, +} diff --git a/sdk/rust/src/lib.rs b/sdk/rust/src/lib.rs index 171e6a0..5aa10c9 100644 --- a/sdk/rust/src/lib.rs +++ b/sdk/rust/src/lib.rs @@ -51,6 +51,9 @@ mod error; pub mod wallet; pub mod rpc; pub mod storage; +pub mod database; +pub mod hosting; +pub mod bridge; #[cfg(test)] mod tests; diff --git a/sdk/swift/Package.swift b/sdk/swift/Package.swift index 4f0c62a..f8d0b56 100644 --- a/sdk/swift/Package.swift +++ b/sdk/swift/Package.swift @@ -2,7 +2,7 @@ import PackageDescription let package = Package( - name: "SynorCompute", + name: "SynorSDK", platforms: [ .macOS(.v12), .iOS(.v15), @@ -11,9 +11,13 @@ let package = Package( ], products: [ .library( - name: "SynorCompute", - targets: ["SynorCompute"] - ) + name: "SynorSDK", + targets: ["SynorCompute", "SynorWallet", "SynorRpc", "SynorStorage"] + ), + .library(name: "SynorCompute", targets: ["SynorCompute"]), + .library(name: "SynorWallet", targets: ["SynorWallet"]), + .library(name: "SynorRpc", targets: ["SynorRpc"]), + .library(name: "SynorStorage", targets: ["SynorStorage"]) ], dependencies: [], targets: [ @@ -22,10 +26,25 @@ let package = Package( dependencies: [], path: "Sources/SynorCompute" ), + .target( + name: "SynorWallet", + dependencies: [], + path: "Sources/Synor/Wallet" + ), + .target( + name: "SynorRpc", + dependencies: [], + path: "Sources/Synor/Rpc" + ), + .target( + name: "SynorStorage", + dependencies: [], + path: "Sources/Synor/Storage" + ), .testTarget( - name: "SynorComputeTests", - dependencies: ["SynorCompute"], - path: "Tests/SynorComputeTests" + name: "SynorTests", + dependencies: ["SynorCompute", "SynorWallet", "SynorRpc", "SynorStorage"], + path: "Tests/SynorTests" ) ] ) diff --git a/sdk/swift/Sources/Synor/Rpc/SynorRpc.swift b/sdk/swift/Sources/Synor/Rpc/SynorRpc.swift new file mode 100644 index 0000000..748493c --- /dev/null +++ b/sdk/swift/Sources/Synor/Rpc/SynorRpc.swift @@ -0,0 +1,343 @@ +import Foundation + +/// Synor RPC SDK client for Swift. +/// +/// Provides blockchain queries, transaction submission, and real-time +/// subscriptions via WebSocket. +/// +/// Example: +/// ```swift +/// let rpc = SynorRpc(config: RpcConfig(apiKey: "your-api-key")) +/// +/// // Get latest block +/// let block = try await rpc.getLatestBlock() +/// print("Latest block: \(block.height)") +/// +/// // Subscribe to new blocks +/// let subscription = try await rpc.subscribeBlocks { block in +/// print("New block: \(block.height)") +/// } +/// +/// // Later: cancel subscription +/// subscription.cancel() +/// ``` +public class SynorRpc { + private let config: RpcConfig + private let session: URLSession + private let decoder: JSONDecoder + private let encoder: JSONEncoder + + private var wsTask: URLSessionWebSocketTask? + private var subscriptions: [String: (String) -> Void] = [:] + private let subscriptionLock = NSLock() + + public init(config: RpcConfig) { + self.config = config + + let sessionConfig = URLSessionConfiguration.default + sessionConfig.timeoutIntervalForRequest = config.timeout + sessionConfig.timeoutIntervalForResource = config.timeout * 2 + self.session = URLSession(configuration: sessionConfig) + + self.decoder = JSONDecoder() + self.encoder = JSONEncoder() + } + + deinit { + wsTask?.cancel(with: .goingAway, reason: nil) + } + + // MARK: - Block Operations + + /// Get the latest block. + public func getLatestBlock() async throws -> Block { + return try await get(path: "/blocks/latest") + } + + /// Get a block by hash or height. + public func getBlock(hashOrHeight: String) async throws -> Block { + return try await get(path: "/blocks/\(hashOrHeight)") + } + + /// Get a block header by hash or height. + public func getBlockHeader(hashOrHeight: String) async throws -> BlockHeader { + return try await get(path: "/blocks/\(hashOrHeight)/header") + } + + /// Get blocks in a range. + public func getBlocks(startHeight: Int64, endHeight: Int64) async throws -> [Block] { + return try await get(path: "/blocks?start=\(startHeight)&end=\(endHeight)") + } + + // MARK: - Transaction Operations + + /// Get a transaction by ID. + public func getTransaction(txid: String) async throws -> RpcTransaction { + return try await get(path: "/transactions/\(txid)") + } + + /// Get raw transaction hex. + public func getRawTransaction(txid: String) async throws -> String { + let response: RawTxResponse = try await get(path: "/transactions/\(txid)/raw") + return response.hex + } + + /// Send a raw transaction. + public func sendRawTransaction(hex: String) async throws -> SubmitResult { + let request = SendRawTxRequest(hex: hex) + return try await post(path: "/transactions/send", body: request) + } + + /// Decode a raw transaction without broadcasting. + public func decodeRawTransaction(hex: String) async throws -> RpcTransaction { + let request = DecodeRawTxRequest(hex: hex) + return try await post(path: "/transactions/decode", body: request) + } + + /// Get transactions for an address. + public func getAddressTransactions( + address: String, + limit: Int = 50, + offset: Int = 0 + ) async throws -> [RpcTransaction] { + return try await get(path: "/addresses/\(address)/transactions?limit=\(limit)&offset=\(offset)") + } + + // MARK: - Fee Estimation + + /// Estimate fee for a given priority. + public func estimateFee(priority: RpcPriority = .medium) async throws -> FeeEstimate { + return try await get(path: "/fees/estimate?priority=\(priority.rawValue)") + } + + /// Get all fee estimates. + public func getAllFeeEstimates() async throws -> [RpcPriority: FeeEstimate] { + let estimates: [FeeEstimate] = try await get(path: "/fees/estimates") + return Dictionary(uniqueKeysWithValues: estimates.map { ($0.priority, $0) }) + } + + // MARK: - Chain Information + + /// Get chain information. + public func getChainInfo() async throws -> ChainInfo { + return try await get(path: "/chain/info") + } + + /// Get mempool information. + public func getMempoolInfo() async throws -> MempoolInfo { + return try await get(path: "/mempool/info") + } + + /// Get mempool transactions. + public func getMempoolTransactions(limit: Int = 100) async throws -> [String] { + return try await get(path: "/mempool/transactions?limit=\(limit)") + } + + // MARK: - WebSocket Subscriptions + + /// Subscribe to new blocks. + public func subscribeBlocks(callback: @escaping (Block) -> Void) async throws -> Subscription { + return try await subscribe(channel: "blocks", filter: nil) { [weak self] data in + guard let self = self else { return } + if let block = try? self.decoder.decode(Block.self, from: Data(data.utf8)) { + callback(block) + } + } + } + + /// Subscribe to transactions for a specific address. + public func subscribeAddress( + address: String, + callback: @escaping (RpcTransaction) -> Void + ) async throws -> Subscription { + return try await subscribe(channel: "address", filter: address) { [weak self] data in + guard let self = self else { return } + if let tx = try? self.decoder.decode(RpcTransaction.self, from: Data(data.utf8)) { + callback(tx) + } + } + } + + /// Subscribe to mempool transactions. + public func subscribeMempool(callback: @escaping (RpcTransaction) -> Void) async throws -> Subscription { + return try await subscribe(channel: "mempool", filter: nil) { [weak self] data in + guard let self = self else { return } + if let tx = try? self.decoder.decode(RpcTransaction.self, from: Data(data.utf8)) { + callback(tx) + } + } + } + + private func subscribe( + channel: String, + filter: String?, + callback: @escaping (String) -> Void + ) async throws -> Subscription { + try await ensureWebSocketConnection() + + let subscriptionId = UUID().uuidString + subscriptionLock.lock() + subscriptions[subscriptionId] = callback + subscriptionLock.unlock() + + let message = WsMessage(type: "subscribe", channel: channel, data: nil, filter: filter) + let messageData = try encoder.encode(message) + let messageString = String(data: messageData, encoding: .utf8)! + + try await wsTask?.send(.string(messageString)) + + return Subscription(id: subscriptionId, channel: channel) { [weak self] in + self?.subscriptionLock.lock() + self?.subscriptions.removeValue(forKey: subscriptionId) + self?.subscriptionLock.unlock() + + Task { + let unsubMessage = WsMessage(type: "unsubscribe", channel: channel, data: nil, filter: nil) + if let data = try? self?.encoder.encode(unsubMessage), + let str = String(data: data, encoding: .utf8) { + try? await self?.wsTask?.send(.string(str)) + } + } + } + } + + private func ensureWebSocketConnection() async throws { + guard wsTask == nil || wsTask?.state != .running else { return } + + guard let url = URL(string: "\(config.wsEndpoint)?token=\(config.apiKey)") else { + throw RpcError.invalidResponse + } + + wsTask = session.webSocketTask(with: url) + wsTask?.resume() + + // Start receiving messages + receiveMessages() + } + + private func receiveMessages() { + wsTask?.receive { [weak self] result in + guard let self = self else { return } + + switch result { + case .success(let message): + switch message { + case .string(let text): + self.handleWebSocketMessage(text) + case .data(let data): + if let text = String(data: data, encoding: .utf8) { + self.handleWebSocketMessage(text) + } + @unknown default: + break + } + // Continue receiving + self.receiveMessages() + + case .failure(let error): + if self.config.debug { + print("WebSocket error: \(error)") + } + } + } + } + + private func handleWebSocketMessage(_ text: String) { + guard let data = text.data(using: .utf8), + let message = try? decoder.decode(WsMessage.self, from: data), + message.type == "data", + let messageData = message.data else { + return + } + + subscriptionLock.lock() + let callbacks = Array(subscriptions.values) + subscriptionLock.unlock() + + for callback in callbacks { + callback(messageData) + } + } + + // MARK: - HTTP Methods + + private func get(path: String) async throws -> T { + return try await executeWithRetry { + try await self.performRequest(method: "GET", path: path, body: nil as Empty?) + } + } + + private func post(path: String, body: R) async throws -> T { + return try await executeWithRetry { + try await self.performRequest(method: "POST", path: path, body: body) + } + } + + private func performRequest( + method: String, + path: String, + body: R? + ) async throws -> T { + guard let url = URL(string: "\(config.endpoint)\(path)") else { + throw RpcError.invalidResponse + } + + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if let body = body { + do { + request.httpBody = try encoder.encode(body) + } catch { + throw RpcError.encodingError(error) + } + } + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw RpcError.networkError(error) + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw RpcError.invalidResponse + } + + if httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 { + do { + return try decoder.decode(T.self, from: data) + } catch { + throw RpcError.decodingError(error) + } + } + + let message = String(data: data, encoding: .utf8) ?? "Unknown error" + throw RpcError.httpError(statusCode: httpResponse.statusCode, message: message) + } + + private func executeWithRetry(_ operation: () async throws -> T) async throws -> T { + var lastError: Error? + + for attempt in 0.. Void + + init(id: String, channel: String, onCancel: @escaping () -> Void) { + self.id = id + self.channel = channel + self.onCancel = onCancel + } + + /// Cancel the subscription. + public func cancel() { + onCancel() + } +} + +// MARK: - Internal Types + +struct WsMessage: Codable { + let type: String + let channel: String + let data: String? + let filter: String? +} + +struct SendRawTxRequest: Codable { + let hex: String +} + +struct DecodeRawTxRequest: Codable { + let hex: String +} + +struct RawTxResponse: Codable { + let hex: String +} + +// MARK: - Errors + +/// Errors thrown by RPC operations. +public enum RpcError: Error, LocalizedError { + case invalidResponse + case httpError(statusCode: Int, message: String) + case networkError(Error) + case encodingError(Error) + case decodingError(Error) + case webSocketError(String) + + public var errorDescription: String? { + switch self { + case .invalidResponse: + return "Invalid response from server" + case .httpError(let code, let message): + return "HTTP error \(code): \(message)" + case .networkError(let error): + return "Network error: \(error.localizedDescription)" + case .encodingError(let error): + return "Encoding error: \(error.localizedDescription)" + case .decodingError(let error): + return "Decoding error: \(error.localizedDescription)" + case .webSocketError(let message): + return "WebSocket error: \(message)" + } + } +} diff --git a/sdk/swift/Sources/Synor/Storage/SynorStorage.swift b/sdk/swift/Sources/Synor/Storage/SynorStorage.swift new file mode 100644 index 0000000..79e8747 --- /dev/null +++ b/sdk/swift/Sources/Synor/Storage/SynorStorage.swift @@ -0,0 +1,465 @@ +import Foundation + +/// Synor Storage SDK client for Swift. +/// +/// Provides IPFS-compatible decentralized storage with upload, download, +/// pinning, and CAR file operations. +/// +/// Example: +/// ```swift +/// let storage = SynorStorage(config: StorageConfig(apiKey: "your-api-key")) +/// +/// // Upload a file +/// let data = "Hello, World!".data(using: .utf8)! +/// let result = try await storage.upload(data: data, options: UploadOptions(name: "hello.txt")) +/// print("CID: \(result.cid)") +/// +/// // Download content +/// let content = try await storage.download(cid: result.cid) +/// print("Content: \(String(data: content, encoding: .utf8)!)") +/// +/// // Pin content +/// let pin = try await storage.pin(request: PinRequest(cid: result.cid, durationDays: 30)) +/// print("Pin status: \(pin.status)") +/// ``` +public class SynorStorage { + private let config: StorageConfig + private let session: URLSession + private let decoder: JSONDecoder + private let encoder: JSONEncoder + + public init(config: StorageConfig) { + self.config = config + + let sessionConfig = URLSessionConfiguration.default + sessionConfig.timeoutIntervalForRequest = config.timeout + sessionConfig.timeoutIntervalForResource = config.timeout * 2 + self.session = URLSession(configuration: sessionConfig) + + self.decoder = JSONDecoder() + self.encoder = JSONEncoder() + } + + // MARK: - Upload Operations + + /// Upload data to storage. + public func upload(data: Data, options: UploadOptions = UploadOptions()) async throws -> UploadResponse { + return try await executeWithRetry { + try await self.performMultipartUpload( + path: "/upload", + data: data, + filename: options.name ?? "file", + additionalFields: self.buildUploadFields(options: options) + ) + } + } + + /// Upload multiple files as a directory. + public func uploadDirectory(files: [FileEntry], dirName: String? = nil) async throws -> UploadResponse { + return try await executeWithRetry { + try await self.performMultipartDirectoryUpload( + path: "/upload/directory", + files: files, + dirName: dirName + ) + } + } + + private func buildUploadFields(options: UploadOptions) -> [String: String] { + var fields: [String: String] = [ + "wrap_with_directory": String(options.wrapWithDirectory), + "hash_algorithm": options.hashAlgorithm.rawValue + ] + + if let name = options.name { + fields["name"] = name + } + if let chunkSize = options.chunkSize { + fields["chunk_size"] = String(chunkSize) + } + if let pinDuration = options.pinDurationDays { + fields["pin_duration_days"] = String(pinDuration) + } + + return fields + } + + // MARK: - Download Operations + + /// Download content by CID. + public func download(cid: String) async throws -> Data { + return try await executeWithRetry { + try await self.performDownload(path: "/content/\(cid)") + } + } + + /// Download content as an async stream. + public func downloadStream(cid: String) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + do { + guard let url = URL(string: "\(config.endpoint)/content/\(cid)") else { + continuation.finish(throwing: StorageError.invalidResponse) + return + } + + var request = URLRequest(url: url) + request.setValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization") + + let (bytes, response) = try await session.bytes(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 else { + continuation.finish(throwing: StorageError.httpError( + statusCode: (response as? HTTPURLResponse)?.statusCode ?? 0, + message: "Download failed" + )) + return + } + + var buffer = Data() + let chunkSize = 8192 + + for try await byte in bytes { + buffer.append(byte) + if buffer.count >= chunkSize { + continuation.yield(buffer) + buffer = Data() + } + } + + if !buffer.isEmpty { + continuation.yield(buffer) + } + + continuation.finish() + } catch { + continuation.finish(throwing: StorageError.networkError(error)) + } + } + } + } + + /// Get the gateway URL for content. + public func getGatewayUrl(cid: String, path: String? = nil) -> String { + if let path = path { + return "\(config.gateway)/ipfs/\(cid)/\(path)" + } + return "\(config.gateway)/ipfs/\(cid)" + } + + // MARK: - Pinning Operations + + /// Pin content to ensure availability. + public func pin(request: PinRequest) async throws -> Pin { + return try await post(path: "/pins", body: request) + } + + /// Unpin content. + public func unpin(cid: String) async throws { + try await delete(path: "/pins/\(cid)") + } + + /// Get pin status. + public func getPinStatus(cid: String) async throws -> Pin { + return try await get(path: "/pins/\(cid)") + } + + /// List all pins. + public func listPins(status: PinStatus? = nil, limit: Int = 50, offset: Int = 0) async throws -> [Pin] { + var path = "/pins?limit=\(limit)&offset=\(offset)" + if let status = status { + path += "&status=\(status.rawValue)" + } + return try await get(path: path) + } + + // MARK: - CAR File Operations + + /// Create a CAR file from entries. + public func createCar(entries: [CarEntry]) async throws -> CarFile { + return try await executeWithRetry { + let files = entries.map { FileEntry(path: $0.path, content: $0.content) } + return try await self.performMultipartDirectoryUpload( + path: "/car", + files: files, + dirName: nil + ) + } + } + + /// Import a CAR file. + public func importCar(carData: Data) async throws -> [String] { + let response: ImportCarResponse = try await executeWithRetry { + try await self.performMultipartUpload( + path: "/car/import", + data: carData, + filename: "archive.car", + additionalFields: [:] + ) + } + return response.cids + } + + /// Export content as a CAR file. + public func exportCar(cid: String) async throws -> Data { + return try await executeWithRetry { + try await self.performDownload(path: "/car/\(cid)") + } + } + + // MARK: - Directory Operations + + /// List directory contents. + public func listDirectory(cid: String) async throws -> [DirectoryEntry] { + return try await get(path: "/content/\(cid)/ls") + } + + // MARK: - Statistics + + /// Get storage statistics. + public func getStats() async throws -> StorageStats { + return try await get(path: "/stats") + } + + // MARK: - HTTP Methods + + private func get(path: String) async throws -> T { + return try await executeWithRetry { + try await self.performRequest(method: "GET", path: path, body: nil as Empty?) + } + } + + private func post(path: String, body: R) async throws -> T { + return try await executeWithRetry { + try await self.performRequest(method: "POST", path: path, body: body) + } + } + + private func delete(path: String) async throws { + try await executeWithRetry { + let _: EmptyResponse = try await self.performRequest(method: "DELETE", path: path, body: nil as Empty?) + } + } + + private func performRequest( + method: String, + path: String, + body: R? + ) async throws -> T { + guard let url = URL(string: "\(config.endpoint)\(path)") else { + throw StorageError.invalidResponse + } + + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if let body = body { + do { + request.httpBody = try encoder.encode(body) + } catch { + throw StorageError.encodingError(error) + } + } + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw StorageError.networkError(error) + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw StorageError.invalidResponse + } + + if httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 { + // Handle empty response for DELETE + if data.isEmpty && T.self == EmptyResponse.self { + return EmptyResponse() as! T + } + + do { + return try decoder.decode(T.self, from: data) + } catch { + throw StorageError.decodingError(error) + } + } + + let message = String(data: data, encoding: .utf8) ?? "Unknown error" + throw StorageError.httpError(statusCode: httpResponse.statusCode, message: message) + } + + private func performDownload(path: String) async throws -> Data { + guard let url = URL(string: "\(config.endpoint)\(path)") else { + throw StorageError.invalidResponse + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization") + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw StorageError.networkError(error) + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw StorageError.invalidResponse + } + + if httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 { + return data + } + + let message = String(data: data, encoding: .utf8) ?? "Unknown error" + throw StorageError.httpError(statusCode: httpResponse.statusCode, message: message) + } + + private func performMultipartUpload( + path: String, + data: Data, + filename: String, + additionalFields: [String: String] + ) async throws -> T { + guard let url = URL(string: "\(config.endpoint)\(path)") else { + throw StorageError.invalidResponse + } + + let boundary = UUID().uuidString + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + var body = Data() + + // Add file + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!) + body.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!) + body.append(data) + body.append("\r\n".data(using: .utf8)!) + + // Add additional fields + for (key, value) in additionalFields { + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!) + body.append("\(value)\r\n".data(using: .utf8)!) + } + + body.append("--\(boundary)--\r\n".data(using: .utf8)!) + request.httpBody = body + + let (responseData, response): (Data, URLResponse) + do { + (responseData, response) = try await session.data(for: request) + } catch { + throw StorageError.networkError(error) + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw StorageError.invalidResponse + } + + if httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 { + do { + return try decoder.decode(T.self, from: responseData) + } catch { + throw StorageError.decodingError(error) + } + } + + let message = String(data: responseData, encoding: .utf8) ?? "Unknown error" + throw StorageError.httpError(statusCode: httpResponse.statusCode, message: message) + } + + private func performMultipartDirectoryUpload( + path: String, + files: [FileEntry], + dirName: String? + ) async throws -> T { + guard let url = URL(string: "\(config.endpoint)\(path)") else { + throw StorageError.invalidResponse + } + + let boundary = UUID().uuidString + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + var body = Data() + + // Add files + for file in files { + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"files\"; filename=\"\(file.path)\"\r\n".data(using: .utf8)!) + body.append("Content-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!) + body.append(file.content) + body.append("\r\n".data(using: .utf8)!) + } + + // Add directory name if provided + if let dirName = dirName { + body.append("--\(boundary)\r\n".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"name\"\r\n\r\n".data(using: .utf8)!) + body.append("\(dirName)\r\n".data(using: .utf8)!) + } + + body.append("--\(boundary)--\r\n".data(using: .utf8)!) + request.httpBody = body + + let (responseData, response): (Data, URLResponse) + do { + (responseData, response) = try await session.data(for: request) + } catch { + throw StorageError.networkError(error) + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw StorageError.invalidResponse + } + + if httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 { + do { + return try decoder.decode(T.self, from: responseData) + } catch { + throw StorageError.decodingError(error) + } + } + + let message = String(data: responseData, encoding: .utf8) ?? "Unknown error" + throw StorageError.httpError(statusCode: httpResponse.statusCode, message: message) + } + + private func executeWithRetry(_ operation: () async throws -> T) async throws -> T { + var lastError: Error? + + for attempt in 0.. CreateWalletResult { + let request = CreateWalletRequest(type: type, network: config.network) + return try await post(path: "/wallets", body: request) + } + + /// Import a wallet from a mnemonic phrase. + public func importWallet(mnemonic: String, passphrase: String? = nil) async throws -> Wallet { + let request = ImportWalletRequest( + mnemonic: mnemonic, + passphrase: passphrase, + network: config.network + ) + return try await post(path: "/wallets/import", body: request) + } + + /// Get a wallet by ID. + public func getWallet(walletId: String) async throws -> Wallet { + return try await get(path: "/wallets/\(walletId)") + } + + /// Generate a new address for a wallet. + public func generateAddress(walletId: String, isChange: Bool = false) async throws -> Address { + let request = GenerateAddressRequest(isChange: isChange) + return try await post(path: "/wallets/\(walletId)/addresses", body: request) + } + + /// Get a stealth address for privacy transactions. + public func getStealthAddress(walletId: String) async throws -> StealthAddress { + return try await get(path: "/wallets/\(walletId)/stealth-address") + } + + // MARK: - Signing Operations + + /// Sign a transaction. + public func signTransaction(walletId: String, transaction: Transaction) async throws -> SignedTransaction { + let request = SignTransactionRequest(walletId: walletId, transaction: transaction) + return try await post(path: "/transactions/sign", body: request) + } + + /// Sign a message with a wallet address. + public func signMessage(walletId: String, message: String, addressIndex: Int = 0) async throws -> Signature { + let request = SignMessageRequest( + walletId: walletId, + message: message, + addressIndex: addressIndex + ) + return try await post(path: "/messages/sign", body: request) + } + + /// Verify a message signature. + public func verifyMessage(message: String, signature: String, address: String) async throws -> Bool { + let request = VerifyMessageRequest(message: message, signature: signature, address: address) + let response: VerifyMessageResponse = try await post(path: "/messages/verify", body: request) + return response.valid + } + + // MARK: - Balance Operations + + /// Get the balance for an address. + public func getBalance(address: String) async throws -> Balance { + return try await get(path: "/addresses/\(address)/balance") + } + + /// Get UTXOs for an address. + public func getUTXOs(address: String, minConfirmations: Int = 1) async throws -> [UTXO] { + return try await get(path: "/addresses/\(address)/utxos?min_confirmations=\(minConfirmations)") + } + + /// Estimate transaction fee. + public func estimateFee(priority: Priority = .medium) async throws -> Int64 { + let response: FeeEstimateResponse = try await get( + path: "/fees/estimate?priority=\(priority.rawValue)" + ) + return response.feePerByte + } + + // MARK: - HTTP Methods + + private func get(path: String) async throws -> T { + return try await executeWithRetry { + try await self.performRequest(method: "GET", path: path, body: nil as Empty?) + } + } + + private func post(path: String, body: R) async throws -> T { + return try await executeWithRetry { + try await self.performRequest(method: "POST", path: path, body: body) + } + } + + private func performRequest( + method: String, + path: String, + body: R? + ) async throws -> T { + guard let url = URL(string: "\(config.endpoint)\(path)") else { + throw WalletError.invalidResponse + } + + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if let body = body { + do { + request.httpBody = try encoder.encode(body) + } catch { + throw WalletError.encodingError(error) + } + } + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw WalletError.networkError(error) + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw WalletError.invalidResponse + } + + if httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 { + do { + return try decoder.decode(T.self, from: data) + } catch { + throw WalletError.decodingError(error) + } + } + + let message = String(data: data, encoding: .utf8) ?? "Unknown error" + throw WalletError.httpError(statusCode: httpResponse.statusCode, message: message) + } + + private func executeWithRetry(_ operation: () async throws -> T) async throws -> T { + var lastError: Error? + + for attempt in 0..