Implement Inter-Blockchain Communication (IBC) SDK with full ICS protocol support across all 12 programming languages: - JavaScript/TypeScript, Python, Go, Rust - Java, Kotlin, Swift, Flutter/Dart - C, C++, C#/.NET, Ruby Features: - Light client management (Tendermint, Solo Machine, WASM) - Connection handshake (4-way: Init, Try, Ack, Confirm) - Channel management with ordered/unordered support - ICS-20 fungible token transfers - HTLC atomic swaps with hashlock (SHA256) and timelock - Packet relay with timeout handling
470 lines
12 KiB
Ruby
470 lines
12 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'net/http'
|
|
require 'uri'
|
|
require 'json'
|
|
require 'base64'
|
|
|
|
module Synor
|
|
# IBC SDK for Ruby
|
|
#
|
|
# Inter-Blockchain Communication (IBC) protocol for cross-chain interoperability.
|
|
module Ibc
|
|
VERSION = '0.1.0'
|
|
|
|
# IBC Height representation
|
|
class Height
|
|
attr_reader :revision_number, :revision_height
|
|
|
|
def initialize(revision_number: 0, revision_height: 1)
|
|
@revision_number = revision_number
|
|
@revision_height = revision_height
|
|
end
|
|
|
|
def zero?
|
|
@revision_number.zero? && @revision_height.zero?
|
|
end
|
|
|
|
def increment
|
|
Height.new(revision_number: @revision_number, revision_height: @revision_height + 1)
|
|
end
|
|
|
|
def self.from_json(json)
|
|
Height.new(
|
|
revision_number: json['revision_number'] || 0,
|
|
revision_height: json['revision_height'] || 1
|
|
)
|
|
end
|
|
|
|
def to_json(*_args)
|
|
{ 'revision_number' => @revision_number, 'revision_height' => @revision_height }
|
|
end
|
|
end
|
|
|
|
# Light client types
|
|
module ClientType
|
|
TENDERMINT = 'tendermint'
|
|
SOLO_MACHINE = 'solo_machine'
|
|
LOCALHOST = 'localhost'
|
|
WASM = 'wasm'
|
|
end
|
|
|
|
# Trust level configuration
|
|
class TrustLevel
|
|
attr_reader :numerator, :denominator
|
|
|
|
def initialize(numerator: 1, denominator: 3)
|
|
@numerator = numerator
|
|
@denominator = denominator
|
|
end
|
|
|
|
def self.from_json(json)
|
|
TrustLevel.new(
|
|
numerator: json['numerator'] || 1,
|
|
denominator: json['denominator'] || 3
|
|
)
|
|
end
|
|
|
|
def to_json(*_args)
|
|
{ 'numerator' => @numerator, 'denominator' => @denominator }
|
|
end
|
|
end
|
|
|
|
# Light client state
|
|
class ClientState
|
|
attr_reader :chain_id, :trust_level, :trusting_period, :unbonding_period,
|
|
:max_clock_drift, :latest_height, :frozen_height
|
|
|
|
def initialize(chain_id:, trust_level:, trusting_period:, unbonding_period:,
|
|
max_clock_drift:, latest_height:, frozen_height: nil)
|
|
@chain_id = chain_id
|
|
@trust_level = trust_level
|
|
@trusting_period = trusting_period
|
|
@unbonding_period = unbonding_period
|
|
@max_clock_drift = max_clock_drift
|
|
@latest_height = latest_height
|
|
@frozen_height = frozen_height
|
|
end
|
|
|
|
def self.from_json(json)
|
|
ClientState.new(
|
|
chain_id: json['chain_id'],
|
|
trust_level: TrustLevel.from_json(json['trust_level']),
|
|
trusting_period: json['trusting_period'],
|
|
unbonding_period: json['unbonding_period'],
|
|
max_clock_drift: json['max_clock_drift'],
|
|
latest_height: Height.from_json(json['latest_height']),
|
|
frozen_height: json['frozen_height'] ? Height.from_json(json['frozen_height']) : nil
|
|
)
|
|
end
|
|
|
|
def to_json(*_args)
|
|
result = {
|
|
'chain_id' => @chain_id,
|
|
'trust_level' => @trust_level.to_json,
|
|
'trusting_period' => @trusting_period,
|
|
'unbonding_period' => @unbonding_period,
|
|
'max_clock_drift' => @max_clock_drift,
|
|
'latest_height' => @latest_height.to_json
|
|
}
|
|
result['frozen_height'] = @frozen_height.to_json if @frozen_height
|
|
result
|
|
end
|
|
end
|
|
|
|
# Connection state
|
|
module ConnectionState
|
|
UNINITIALIZED = 'uninitialized'
|
|
INIT = 'init'
|
|
TRYOPEN = 'tryopen'
|
|
OPEN = 'open'
|
|
end
|
|
|
|
# Channel ordering
|
|
module ChannelOrder
|
|
UNORDERED = 'unordered'
|
|
ORDERED = 'ordered'
|
|
end
|
|
|
|
# Channel state
|
|
module ChannelState
|
|
UNINITIALIZED = 'uninitialized'
|
|
INIT = 'init'
|
|
TRYOPEN = 'tryopen'
|
|
OPEN = 'open'
|
|
CLOSED = 'closed'
|
|
end
|
|
|
|
# Timeout information
|
|
class Timeout
|
|
attr_reader :height, :timestamp
|
|
|
|
def initialize(height:, timestamp: 0)
|
|
@height = height
|
|
@timestamp = timestamp
|
|
end
|
|
|
|
def self.from_height(h)
|
|
Timeout.new(height: Height.new(revision_height: h))
|
|
end
|
|
|
|
def self.from_timestamp(ts)
|
|
Timeout.new(height: Height.new, timestamp: ts)
|
|
end
|
|
|
|
def to_json(*_args)
|
|
{ 'height' => @height.to_json, 'timestamp' => @timestamp.to_s }
|
|
end
|
|
end
|
|
|
|
# Swap state
|
|
module SwapState
|
|
PENDING = 'pending'
|
|
LOCKED = 'locked'
|
|
COMPLETED = 'completed'
|
|
REFUNDED = 'refunded'
|
|
EXPIRED = 'expired'
|
|
CANCELLED = 'cancelled'
|
|
end
|
|
|
|
# Atomic swap
|
|
class AtomicSwap
|
|
attr_reader :swap_id, :state, :initiator_htlc, :responder_htlc
|
|
|
|
def initialize(swap_id:, state:, initiator_htlc:, responder_htlc: nil)
|
|
@swap_id = swap_id
|
|
@state = state
|
|
@initiator_htlc = initiator_htlc
|
|
@responder_htlc = responder_htlc
|
|
end
|
|
|
|
def self.from_json(json)
|
|
AtomicSwap.new(
|
|
swap_id: json['swap_id']['id'],
|
|
state: json['state'],
|
|
initiator_htlc: json['initiator_htlc'],
|
|
responder_htlc: json['responder_htlc']
|
|
)
|
|
end
|
|
end
|
|
|
|
# IBC SDK configuration
|
|
class Config
|
|
attr_accessor :api_key, :endpoint, :ws_endpoint, :timeout, :retries, :chain_id, :debug
|
|
|
|
def initialize(
|
|
api_key:,
|
|
endpoint: 'https://ibc.synor.io/v1',
|
|
ws_endpoint: 'wss://ibc.synor.io/v1/ws',
|
|
timeout: 30,
|
|
retries: 3,
|
|
chain_id: 'synor-1',
|
|
debug: false
|
|
)
|
|
@api_key = api_key
|
|
@endpoint = endpoint
|
|
@ws_endpoint = ws_endpoint
|
|
@timeout = timeout
|
|
@retries = retries
|
|
@chain_id = chain_id
|
|
@debug = debug
|
|
end
|
|
end
|
|
|
|
# IBC exception
|
|
class IbcError < StandardError
|
|
attr_reader :code, :status
|
|
|
|
def initialize(message, code: nil, status: nil)
|
|
super(message)
|
|
@code = code
|
|
@status = status
|
|
end
|
|
end
|
|
|
|
# Main IBC client
|
|
class Client
|
|
attr_reader :clients, :connections, :channels, :transfer, :swaps
|
|
|
|
def initialize(config)
|
|
@config = config
|
|
@closed = false
|
|
|
|
@clients = LightClientClient.new(self)
|
|
@connections = ConnectionsClient.new(self)
|
|
@channels = ChannelsClient.new(self)
|
|
@transfer = TransferClient.new(self)
|
|
@swaps = SwapsClient.new(self)
|
|
end
|
|
|
|
def chain_id
|
|
@config.chain_id
|
|
end
|
|
|
|
def get_chain_info
|
|
get('/chain')
|
|
end
|
|
|
|
def get_height
|
|
result = get('/chain/height')
|
|
Height.from_json(result)
|
|
end
|
|
|
|
def health_check
|
|
result = get('/health')
|
|
result['status'] == 'healthy'
|
|
rescue StandardError
|
|
false
|
|
end
|
|
|
|
def close
|
|
@closed = true
|
|
end
|
|
|
|
def closed?
|
|
@closed
|
|
end
|
|
|
|
# Internal HTTP methods
|
|
|
|
def get(path)
|
|
request(:get, path)
|
|
end
|
|
|
|
def post(path, body)
|
|
request(:post, path, body)
|
|
end
|
|
|
|
private
|
|
|
|
def request(method, path, body = nil)
|
|
raise IbcError.new('Client has been closed', code: 'CLIENT_CLOSED') if @closed
|
|
|
|
uri = URI.parse("#{@config.endpoint}#{path}")
|
|
|
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
http.use_ssl = uri.scheme == 'https'
|
|
http.read_timeout = @config.timeout
|
|
|
|
request = case method
|
|
when :get
|
|
Net::HTTP::Get.new(uri.request_uri)
|
|
when :post
|
|
req = Net::HTTP::Post.new(uri.request_uri)
|
|
req.body = body.to_json if body
|
|
req
|
|
end
|
|
|
|
request['Content-Type'] = 'application/json'
|
|
request['Authorization'] = "Bearer #{@config.api_key}"
|
|
request['X-SDK-Version'] = 'ruby/0.1.0'
|
|
request['X-Chain-Id'] = @config.chain_id
|
|
|
|
response = http.request(request)
|
|
|
|
unless response.is_a?(Net::HTTPSuccess)
|
|
error = JSON.parse(response.body) rescue {}
|
|
raise IbcError.new(
|
|
error['message'] || "HTTP #{response.code}",
|
|
code: error['code'],
|
|
status: response.code.to_i
|
|
)
|
|
end
|
|
|
|
JSON.parse(response.body)
|
|
end
|
|
end
|
|
|
|
# Light client sub-client
|
|
class LightClientClient
|
|
def initialize(ibc)
|
|
@ibc = ibc
|
|
end
|
|
|
|
def create(client_type:, client_state:, consensus_state:)
|
|
result = @ibc.post('/clients', {
|
|
'client_type' => client_type,
|
|
'client_state' => client_state.to_json,
|
|
'consensus_state' => consensus_state
|
|
})
|
|
result['client_id']
|
|
end
|
|
|
|
def get_state(client_id)
|
|
result = @ibc.get("/clients/#{client_id}/state")
|
|
ClientState.from_json(result)
|
|
end
|
|
|
|
def list
|
|
result = @ibc.get('/clients')
|
|
result['clients'] || []
|
|
end
|
|
end
|
|
|
|
# Connections sub-client
|
|
class ConnectionsClient
|
|
def initialize(ibc)
|
|
@ibc = ibc
|
|
end
|
|
|
|
def open_init(client_id:, counterparty_client_id:)
|
|
result = @ibc.post('/connections/init', {
|
|
'client_id' => client_id,
|
|
'counterparty_client_id' => counterparty_client_id
|
|
})
|
|
result['connection_id']
|
|
end
|
|
|
|
def get(connection_id)
|
|
@ibc.get("/connections/#{connection_id}")
|
|
end
|
|
|
|
def list
|
|
result = @ibc.get('/connections')
|
|
result['connections'] || []
|
|
end
|
|
end
|
|
|
|
# Channels sub-client
|
|
class ChannelsClient
|
|
def initialize(ibc)
|
|
@ibc = ibc
|
|
end
|
|
|
|
def bind_port(port_id:, module_name:)
|
|
@ibc.post('/ports/bind', { 'port_id' => port_id, 'module' => module_name })
|
|
end
|
|
|
|
def open_init(port_id:, ordering:, connection_id:, counterparty_port:, version:)
|
|
result = @ibc.post('/channels/init', {
|
|
'port_id' => port_id,
|
|
'ordering' => ordering,
|
|
'connection_id' => connection_id,
|
|
'counterparty_port' => counterparty_port,
|
|
'version' => version
|
|
})
|
|
result['channel_id']
|
|
end
|
|
|
|
def get(port_id:, channel_id:)
|
|
@ibc.get("/channels/#{port_id}/#{channel_id}")
|
|
end
|
|
|
|
def list
|
|
result = @ibc.get('/channels')
|
|
result['channels'] || []
|
|
end
|
|
end
|
|
|
|
# Transfer sub-client (ICS-20)
|
|
class TransferClient
|
|
def initialize(ibc)
|
|
@ibc = ibc
|
|
end
|
|
|
|
def transfer(source_port:, source_channel:, denom:, amount:, sender:, receiver:,
|
|
timeout: nil, memo: nil)
|
|
body = {
|
|
'source_port' => source_port,
|
|
'source_channel' => source_channel,
|
|
'token' => { 'denom' => denom, 'amount' => amount },
|
|
'sender' => sender,
|
|
'receiver' => receiver
|
|
}
|
|
if timeout
|
|
body['timeout_height'] = timeout.height.to_json
|
|
body['timeout_timestamp'] = timeout.timestamp.to_s
|
|
end
|
|
body['memo'] = memo if memo
|
|
@ibc.post('/transfer', body)
|
|
end
|
|
|
|
def get_denom_trace(ibc_denom)
|
|
@ibc.get("/transfer/denom_trace/#{ibc_denom}")
|
|
end
|
|
end
|
|
|
|
# Swaps sub-client (HTLC)
|
|
class SwapsClient
|
|
def initialize(ibc)
|
|
@ibc = ibc
|
|
end
|
|
|
|
def initiate(responder:, initiator_asset:, responder_asset:)
|
|
@ibc.post('/swaps/initiate', {
|
|
'responder' => responder,
|
|
'initiator_asset' => initiator_asset,
|
|
'responder_asset' => responder_asset
|
|
})
|
|
end
|
|
|
|
def lock(swap_id)
|
|
@ibc.post("/swaps/#{swap_id}/lock", {})
|
|
end
|
|
|
|
def respond(swap_id, asset)
|
|
@ibc.post("/swaps/#{swap_id}/respond", { 'asset' => asset })
|
|
end
|
|
|
|
def claim(swap_id, secret)
|
|
@ibc.post("/swaps/#{swap_id}/claim", {
|
|
'secret' => Base64.strict_encode64(secret.pack('C*'))
|
|
})
|
|
end
|
|
|
|
def refund(swap_id)
|
|
@ibc.post("/swaps/#{swap_id}/refund", {})
|
|
end
|
|
|
|
def get(swap_id)
|
|
result = @ibc.get("/swaps/#{swap_id}")
|
|
AtomicSwap.from_json(result)
|
|
end
|
|
|
|
def list_active
|
|
result = @ibc.get('/swaps/active')
|
|
(result['swaps'] || []).map { |s| AtomicSwap.from_json(s) }
|
|
end
|
|
end
|
|
end
|
|
end
|