# frozen_string_literal: true require "faraday" require "json" require "uri" module SynorBridge # Synor Bridge SDK client for Ruby. # Cross-chain asset transfers with lock-mint and burn-unlock patterns. class Client FINAL_STATUSES = [ TransferStatus::COMPLETED, TransferStatus::FAILED, TransferStatus::REFUNDED ].freeze attr_reader :closed def initialize(config) @config = config @closed = false @conn = Faraday.new(url: config.endpoint) do |f| f.request :json f.response :json f.options.timeout = config.timeout f.headers["Authorization"] = "Bearer #{config.api_key}" f.headers["Content-Type"] = "application/json" f.headers["X-SDK-Version"] = "ruby/#{VERSION}" end end # ==================== Chain Operations ==================== # Get supported chains def get_supported_chains response = get("/chains") (response["chains"] || []).map { |c| parse_chain(c) } end # Get chain details def get_chain(chain_id) response = get("/chains/#{chain_id.downcase}") parse_chain(response) end # Check if chain is supported def chain_supported?(chain_id) chain = get_chain(chain_id) chain.supported rescue StandardError false end # ==================== Asset Operations ==================== # Get supported assets for a chain def get_supported_assets(chain_id) response = get("/chains/#{chain_id.downcase}/assets") (response["assets"] || []).map { |a| parse_asset(a) } end # Get asset details def get_asset(asset_id) response = get("/assets/#{encode(asset_id)}") parse_asset(response) end # Get wrapped asset info def get_wrapped_asset(original_asset_id, target_chain) response = get("/assets/#{encode(original_asset_id)}/wrapped/#{target_chain.downcase}") parse_wrapped_asset(response) end # ==================== Fee Operations ==================== # Estimate transfer fee def estimate_fee(asset:, amount:, source_chain:, target_chain:) body = { asset: asset, amount: amount, source_chain: source_chain.downcase, target_chain: target_chain.downcase } response = post("/fees/estimate", body) parse_fee_estimate(response) end # Get exchange rate def get_exchange_rate(from_asset, to_asset) response = get("/rates/#{encode(from_asset)}/#{encode(to_asset)}") parse_exchange_rate(response) end # ==================== Lock-Mint Flow ==================== # Lock assets for cross-chain transfer def lock(asset:, amount:, target_chain:, options: nil) body = { asset: asset, amount: amount, target_chain: target_chain.downcase } if options body[:recipient] = options.recipient if options.recipient body[:deadline] = options.deadline if options.deadline body[:slippage] = options.slippage if options.slippage end response = post("/transfers/lock", body) parse_lock_receipt(response) end # Get lock proof def get_lock_proof(lock_receipt_id) response = get("/transfers/lock/#{encode(lock_receipt_id)}/proof") parse_lock_proof(response) end # Wait for lock proof def wait_for_lock_proof(lock_receipt_id, poll_interval: 5, max_wait: 600) deadline = Time.now + max_wait while Time.now < deadline begin return get_lock_proof(lock_receipt_id) rescue HttpError => e raise e unless e.confirmations_pending? sleep(poll_interval) end end raise HttpError.new("Timeout waiting for lock proof", code: "CONFIRMATIONS_PENDING") end # Mint wrapped assets def mint(proof:, target_address:, options: nil) body = { proof: proof_to_hash(proof), target_address: target_address } if options body[:gas_limit] = options.gas_limit if options.gas_limit body[:max_fee_per_gas] = options.max_fee_per_gas if options.max_fee_per_gas body[:max_priority_fee_per_gas] = options.max_priority_fee_per_gas if options.max_priority_fee_per_gas end response = post("/transfers/mint", body) parse_signed_transaction(response) end # ==================== Burn-Unlock Flow ==================== # Burn wrapped assets def burn(wrapped_asset:, amount:, options: nil) body = { wrapped_asset: wrapped_asset, amount: amount } if options body[:recipient] = options.recipient if options.recipient body[:deadline] = options.deadline if options.deadline end response = post("/transfers/burn", body) parse_burn_receipt(response) end # Get burn proof def get_burn_proof(burn_receipt_id) response = get("/transfers/burn/#{encode(burn_receipt_id)}/proof") parse_burn_proof(response) end # Wait for burn proof def wait_for_burn_proof(burn_receipt_id, poll_interval: 5, max_wait: 600) deadline = Time.now + max_wait while Time.now < deadline begin return get_burn_proof(burn_receipt_id) rescue HttpError => e raise e unless e.confirmations_pending? sleep(poll_interval) end end raise HttpError.new("Timeout waiting for burn proof", code: "CONFIRMATIONS_PENDING") end # Unlock original assets def unlock(proof:, options: nil) body = { proof: burn_proof_to_hash(proof) } if options body[:gas_limit] = options.gas_limit if options.gas_limit body[:gas_price] = options.gas_price if options.gas_price end response = post("/transfers/unlock", body) parse_signed_transaction(response) end # ==================== Transfer Management ==================== # Get transfer details def get_transfer(transfer_id) response = get("/transfers/#{encode(transfer_id)}") parse_transfer(response) end # Get transfer status def get_transfer_status(transfer_id) transfer = get_transfer(transfer_id) transfer.status end # List transfers def list_transfers(filter: nil) params = {} if filter params[:status] = filter.status.downcase if filter.status params[:source_chain] = filter.source_chain.downcase if filter.source_chain params[:target_chain] = filter.target_chain.downcase if filter.target_chain params[:limit] = filter.limit if filter.limit params[:offset] = filter.offset if filter.offset end response = get("/transfers", params) (response["transfers"] || []).map { |t| parse_transfer(t) } end # Wait for transfer completion def wait_for_transfer(transfer_id, poll_interval: 10, max_wait: 1800) deadline = Time.now + max_wait while Time.now < deadline transfer = get_transfer(transfer_id) return transfer if FINAL_STATUSES.include?(transfer.status) sleep(poll_interval) end raise Error, "Timeout waiting for transfer completion" end # ==================== Convenience Methods ==================== # Bridge assets to another chain (lock-mint flow) def bridge_to(asset:, amount:, target_chain:, target_address:, lock_options: nil, mint_options: nil) receipt = lock(asset: asset, amount: amount, target_chain: target_chain, options: lock_options) puts "Locked: #{receipt.id}, waiting for confirmations..." if @config.debug proof = wait_for_lock_proof(receipt.id) puts "Proof ready, minting on #{target_chain}..." if @config.debug mint(proof: proof, target_address: target_address, options: mint_options) wait_for_transfer(receipt.id) end # Bridge assets back to original chain (burn-unlock flow) def bridge_back(wrapped_asset:, amount:, burn_options: nil, unlock_options: nil) receipt = burn(wrapped_asset: wrapped_asset, amount: amount, options: burn_options) puts "Burned: #{receipt.id}, waiting for confirmations..." if @config.debug proof = wait_for_burn_proof(receipt.id) puts "Proof ready, unlocking on #{receipt.target_chain}..." if @config.debug unlock(proof: proof, options: unlock_options) wait_for_transfer(receipt.id) end # ==================== Lifecycle ==================== # Health check def health_check response = get("/health") response["status"] == "healthy" rescue StandardError false end # Close the client def close @closed = true @conn.close if @conn.respond_to?(:close) end private def get(path, params = {}) execute { @conn.get(path, params).body } end def post(path, body) execute { @conn.post(path, body).body } end def execute raise ClientClosedError, "Client has been closed" if @closed last_error = nil @config.retries.times do |attempt| begin response = yield check_error(response) if response.is_a?(Hash) return response rescue StandardError => e last_error = e sleep(2**attempt) if attempt < @config.retries - 1 end end raise last_error end def check_error(response) return unless response["error"] || (response["code"] && response["message"]) message = response["message"] || response["error"] || "Unknown error" code = response["code"] status = response["status_code"] || 0 raise HttpError.new(message, status_code: status, code: code) end def encode(str) URI.encode_www_form_component(str) end # Parsing methods def parse_chain(data) return nil unless data Chain.new( id: data["id"], name: data["name"], chain_id: data["chain_id"], rpc_url: data["rpc_url"], explorer_url: data["explorer_url"], native_currency: data["native_currency"] ? NativeCurrency.new( name: data["native_currency"]["name"], symbol: data["native_currency"]["symbol"], decimals: data["native_currency"]["decimals"] ) : nil, confirmations: data["confirmations"], estimated_block_time: data["estimated_block_time"], supported: data["supported"] ) end def parse_asset(data) return nil unless data Asset.new( id: data["id"], symbol: data["symbol"], name: data["name"], type: data["type"], chain: data["chain"], contract_address: data["contract_address"], decimals: data["decimals"], logo_url: data["logo_url"], verified: data["verified"] ) end def parse_wrapped_asset(data) return nil unless data WrappedAsset.new( original_asset: parse_asset(data["original_asset"]), wrapped_asset: parse_asset(data["wrapped_asset"]), chain: data["chain"], bridge_contract: data["bridge_contract"] ) end def parse_lock_receipt(data) return nil unless data LockReceipt.new( id: data["id"], tx_hash: data["tx_hash"], source_chain: data["source_chain"], target_chain: data["target_chain"], asset: parse_asset(data["asset"]), amount: data["amount"], sender: data["sender"], recipient: data["recipient"], lock_timestamp: data["lock_timestamp"], confirmations: data["confirmations"], required_confirmations: data["required_confirmations"] ) end def parse_lock_proof(data) return nil unless data LockProof.new( lock_receipt: parse_lock_receipt(data["lock_receipt"]), merkle_proof: data["merkle_proof"], block_header: data["block_header"], signatures: (data["signatures"] || []).map do |s| ValidatorSignature.new( validator: s["validator"], signature: s["signature"], timestamp: s["timestamp"] ) end ) end def parse_burn_receipt(data) return nil unless data BurnReceipt.new( id: data["id"], tx_hash: data["tx_hash"], source_chain: data["source_chain"], target_chain: data["target_chain"], wrapped_asset: parse_asset(data["wrapped_asset"]), original_asset: parse_asset(data["original_asset"]), amount: data["amount"], sender: data["sender"], recipient: data["recipient"], burn_timestamp: data["burn_timestamp"], confirmations: data["confirmations"], required_confirmations: data["required_confirmations"] ) end def parse_burn_proof(data) return nil unless data BurnProof.new( burn_receipt: parse_burn_receipt(data["burn_receipt"]), merkle_proof: data["merkle_proof"], block_header: data["block_header"], signatures: (data["signatures"] || []).map do |s| ValidatorSignature.new( validator: s["validator"], signature: s["signature"], timestamp: s["timestamp"] ) end ) end def parse_transfer(data) return nil unless data Transfer.new( id: data["id"], direction: data["direction"], status: data["status"], source_chain: data["source_chain"], target_chain: data["target_chain"], asset: parse_asset(data["asset"]), amount: data["amount"], sender: data["sender"], recipient: data["recipient"], source_tx_hash: data["source_tx_hash"], target_tx_hash: data["target_tx_hash"], fee: data["fee"], fee_asset: parse_asset(data["fee_asset"]), created_at: data["created_at"], updated_at: data["updated_at"], completed_at: data["completed_at"], error_message: data["error_message"] ) end def parse_fee_estimate(data) return nil unless data FeeEstimate.new( bridge_fee: data["bridge_fee"], gas_fee_source: data["gas_fee_source"], gas_fee_target: data["gas_fee_target"], total_fee: data["total_fee"], fee_asset: parse_asset(data["fee_asset"]), estimated_time: data["estimated_time"], exchange_rate: data["exchange_rate"] ) end def parse_exchange_rate(data) return nil unless data ExchangeRate.new( from_asset: parse_asset(data["from_asset"]), to_asset: parse_asset(data["to_asset"]), rate: data["rate"], inverse_rate: data["inverse_rate"], last_updated: data["last_updated"], source: data["source"] ) end def parse_signed_transaction(data) return nil unless data SignedTransaction.new( tx_hash: data["tx_hash"], chain: data["chain"], from: data["from"], to: data["to"], value: data["value"], data: data["data"], gas_limit: data["gas_limit"], gas_price: data["gas_price"], max_fee_per_gas: data["max_fee_per_gas"], max_priority_fee_per_gas: data["max_priority_fee_per_gas"], nonce: data["nonce"], signature: data["signature"] ) end # Conversion methods def proof_to_hash(proof) { lock_receipt: lock_receipt_to_hash(proof.lock_receipt), merkle_proof: proof.merkle_proof, block_header: proof.block_header, signatures: proof.signatures.map { |s| signature_to_hash(s) } } end def lock_receipt_to_hash(receipt) { id: receipt.id, tx_hash: receipt.tx_hash, source_chain: receipt.source_chain, target_chain: receipt.target_chain, asset: asset_to_hash(receipt.asset), amount: receipt.amount, sender: receipt.sender, recipient: receipt.recipient, lock_timestamp: receipt.lock_timestamp, confirmations: receipt.confirmations, required_confirmations: receipt.required_confirmations } end def burn_proof_to_hash(proof) { burn_receipt: burn_receipt_to_hash(proof.burn_receipt), merkle_proof: proof.merkle_proof, block_header: proof.block_header, signatures: proof.signatures.map { |s| signature_to_hash(s) } } end def burn_receipt_to_hash(receipt) { id: receipt.id, tx_hash: receipt.tx_hash, source_chain: receipt.source_chain, target_chain: receipt.target_chain, wrapped_asset: asset_to_hash(receipt.wrapped_asset), original_asset: asset_to_hash(receipt.original_asset), amount: receipt.amount, sender: receipt.sender, recipient: receipt.recipient, burn_timestamp: receipt.burn_timestamp, confirmations: receipt.confirmations, required_confirmations: receipt.required_confirmations } end def asset_to_hash(asset) return nil unless asset { id: asset.id, symbol: asset.symbol, name: asset.name, type: asset.type, chain: asset.chain, contract_address: asset.contract_address, decimals: asset.decimals, logo_url: asset.logo_url, verified: asset.verified } end def signature_to_hash(sig) { validator: sig.validator, signature: sig.signature, timestamp: sig.timestamp } end end end