# frozen_string_literal: true require "faraday" require "json" require "uri" module SynorGovernance # Synor Governance SDK client for Ruby. # Proposals, voting, DAOs, and vesting operations. class Client FINAL_STATUSES = [ ProposalStatus::PASSED, ProposalStatus::REJECTED, ProposalStatus::EXECUTED, ProposalStatus::CANCELLED ].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 # ==================== Proposal Operations ==================== def create_proposal(draft) body = proposal_draft_to_hash(draft) response = post("/proposals", body) parse_proposal(response) end def get_proposal(proposal_id) response = get("/proposals/#{encode(proposal_id)}") parse_proposal(response) end def list_proposals(filter: nil) params = {} if filter params[:status] = filter.status if filter.status params[:proposer] = filter.proposer if filter.proposer params[:dao_id] = filter.dao_id if filter.dao_id params[:limit] = filter.limit if filter.limit params[:offset] = filter.offset if filter.offset end response = get("/proposals", params) (response["proposals"] || []).map { |p| parse_proposal(p) } end def cancel_proposal(proposal_id) response = post("/proposals/#{encode(proposal_id)}/cancel", {}) parse_proposal(response) end def execute_proposal(proposal_id) response = post("/proposals/#{encode(proposal_id)}/execute", {}) parse_proposal(response) end def wait_for_proposal(proposal_id, poll_interval: 60, max_wait: 604_800) deadline = Time.now + max_wait while Time.now < deadline proposal = get_proposal(proposal_id) return proposal if FINAL_STATUSES.include?(proposal.status) sleep(poll_interval) end raise Error, "Timeout waiting for proposal completion" end # ==================== Voting Operations ==================== def vote(proposal_id:, vote:, weight: nil) body = { choice: vote.choice } body[:reason] = vote.reason if vote.reason body[:weight] = weight if weight response = post("/proposals/#{encode(proposal_id)}/vote", body) parse_vote_receipt(response) end def get_votes(proposal_id) response = get("/proposals/#{encode(proposal_id)}/votes") (response["votes"] || []).map { |v| parse_vote_receipt(v) } end def get_my_vote(proposal_id) response = get("/proposals/#{encode(proposal_id)}/votes/me") parse_vote_receipt(response) end def delegate(delegatee:, amount: nil) body = { delegatee: delegatee } body[:amount] = amount if amount response = post("/voting/delegate", body) parse_delegation_receipt(response) end def undelegate(delegatee) response = post("/voting/undelegate", { delegatee: delegatee }) parse_delegation_receipt(response) end def get_voting_power(address) response = get("/voting/power/#{encode(address)}") parse_voting_power(response) end def get_delegations(address) response = get("/voting/delegations/#{encode(address)}") (response["delegations"] || []).map { |d| parse_delegation_receipt(d) } end # ==================== DAO Operations ==================== def create_dao(config) body = dao_config_to_hash(config) response = post("/daos", body) parse_dao(response) end def get_dao(dao_id) response = get("/daos/#{encode(dao_id)}") parse_dao(response) end def list_daos(limit: nil, offset: nil) params = {} params[:limit] = limit if limit params[:offset] = offset if offset response = get("/daos", params) (response["daos"] || []).map { |d| parse_dao(d) } end def get_dao_treasury(dao_id) response = get("/daos/#{encode(dao_id)}/treasury") parse_dao_treasury(response) end def get_dao_members(dao_id) response = get("/daos/#{encode(dao_id)}/members") response["members"] || [] end # ==================== Vesting Operations ==================== def create_vesting_schedule(schedule) body = vesting_schedule_to_hash(schedule) response = post("/vesting", body) parse_vesting_contract(response) end def get_vesting_contract(contract_id) response = get("/vesting/#{encode(contract_id)}") parse_vesting_contract(response) end def list_vesting_contracts(beneficiary: nil) path = beneficiary ? "/vesting?beneficiary=#{encode(beneficiary)}" : "/vesting" response = get(path) (response["contracts"] || []).map { |c| parse_vesting_contract(c) } end def claim_vested(contract_id) response = post("/vesting/#{encode(contract_id)}/claim", {}) parse_claim_receipt(response) end def revoke_vesting(contract_id) response = post("/vesting/#{encode(contract_id)}/revoke", {}) parse_vesting_contract(response) end def get_releasable_amount(contract_id) response = get("/vesting/#{encode(contract_id)}/releasable") response["amount"] end # ==================== Lifecycle ==================== def health_check response = get("/health") response["status"] == "healthy" rescue StandardError false end 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_proposal(data) Proposal.new( id: data["id"], title: data["title"], description: data["description"], discussion_url: data["discussion_url"], proposer: data["proposer"], status: data["status"], created_at: data["created_at"], voting_start_time: data["voting_start_time"], voting_end_time: data["voting_end_time"], execution_time: data["execution_time"], vote_tally: data["vote_tally"] ? parse_vote_tally(data["vote_tally"]) : nil, actions: (data["actions"] || []).map { |a| parse_proposal_action(a) }, dao_id: data["dao_id"] ) end def parse_vote_tally(data) VoteTally.new( for_votes: data["for_votes"], against_votes: data["against_votes"], abstain_votes: data["abstain_votes"], quorum: data["quorum"], quorum_required: data["quorum_required"], total_voters: data["total_voters"] ) end def parse_proposal_action(data) ProposalAction.new( target: data["target"], method: data["method"], data: data["data"], value: data["value"] ) end def parse_vote_receipt(data) VoteReceipt.new( id: data["id"], proposal_id: data["proposal_id"], voter: data["voter"], choice: data["choice"], weight: data["weight"], reason: data["reason"], voted_at: data["voted_at"], tx_hash: data["tx_hash"] ) end def parse_voting_power(data) VotingPower.new( address: data["address"], delegated_power: data["delegated_power"], own_power: data["own_power"], total_power: data["total_power"], delegators: data["delegators"] || [] ) end def parse_delegation_receipt(data) DelegationReceipt.new( id: data["id"], from: data["from"], to: data["to"], amount: data["amount"], delegated_at: data["delegated_at"], tx_hash: data["tx_hash"] ) end def parse_dao(data) Dao.new( id: data["id"], name: data["name"], description: data["description"], type: data["type"], token_address: data["token_address"], voting_period_days: data["voting_period_days"], timelock_days: data["timelock_days"], quorum_percent: data["quorum_percent"], proposal_threshold: data["proposal_threshold"], total_proposals: data["total_proposals"], active_proposals: data["active_proposals"], treasury_value: data["treasury_value"], member_count: data["member_count"], created_at: data["created_at"] ) end def parse_dao_treasury(data) DaoTreasury.new( dao_id: data["dao_id"], total_value: data["total_value"], tokens: (data["tokens"] || []).map { |t| parse_treasury_token(t) }, last_updated: data["last_updated"] ) end def parse_treasury_token(data) TreasuryToken.new( address: data["address"], balance: data["balance"], name: data["name"], symbol: data["symbol"] ) end def parse_vesting_contract(data) VestingContract.new( id: data["id"], beneficiary: data["beneficiary"], grantor: data["grantor"], total_amount: data["total_amount"], released_amount: data["released_amount"], releasable_amount: data["releasable_amount"], start_time: data["start_time"], cliff_time: data["cliff_time"], end_time: data["end_time"], status: data["status"], revocable: data["revocable"], created_at: data["created_at"] ) end def parse_claim_receipt(data) ClaimReceipt.new( id: data["id"], contract_id: data["contract_id"], amount: data["amount"], tx_hash: data["tx_hash"], claimed_at: data["claimed_at"] ) end # Conversion methods def proposal_draft_to_hash(draft) hash = { title: draft.title, description: draft.description } hash[:discussion_url] = draft.discussion_url if draft.discussion_url hash[:voting_start_time] = draft.voting_start_time if draft.voting_start_time hash[:voting_end_time] = draft.voting_end_time if draft.voting_end_time hash[:dao_id] = draft.dao_id if draft.dao_id hash[:actions] = draft.actions.map { |a| proposal_action_to_hash(a) } if draft.actions hash end def proposal_action_to_hash(action) { target: action.target, method: action.method, data: action.data, value: action.value } end def dao_config_to_hash(config) hash = { name: config.name, description: config.description, type: config.type, voting_period_days: config.voting_period_days, timelock_days: config.timelock_days } hash[:token_address] = config.token_address if config.token_address hash[:quorum_percent] = config.quorum_percent if config.quorum_percent hash[:proposal_threshold] = config.proposal_threshold if config.proposal_threshold hash[:multisig_members] = config.multisig_members if config.multisig_members hash[:multisig_threshold] = config.multisig_threshold if config.multisig_threshold hash end def vesting_schedule_to_hash(schedule) { beneficiary: schedule.beneficiary, total_amount: schedule.total_amount, start_time: schedule.start_time, cliff_duration: schedule.cliff_duration, vesting_duration: schedule.vesting_duration, revocable: schedule.revocable } end end end