Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: serve claimable balances from Horizon #233

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions base/lib/stellar/asset.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,11 @@ def to_s
when AssetType.asset_type_credit_alphanum4
anum = alpha_num4!
issuer_address = Stellar::Convert.pk_to_address(anum.issuer)
"#{anum.asset_code}/#{issuer_address}"
"#{anum.asset_code.delete("\x00")}:#{issuer_address}"
when AssetType.asset_type_credit_alphanum12
anum = alpha_num12!
issuer_address = Stellar::Convert.pk_to_address(anum.issuer)
"#{anum.asset_code}/#{issuer_address}"
"#{anum.asset_code.delete("\x00")}:#{issuer_address}"
end
end

Expand Down
48 changes: 48 additions & 0 deletions base/lib/stellar/claim_predicate.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literals: true
require "active_support/core_ext/integer/time"
require "active_support/core_ext/string/conversions"
require "active_support/core_ext/hash/indifferent_access"

module Stellar
# Represents claim predicate on Stellar network.
Expand Down Expand Up @@ -74,6 +75,53 @@ def after(time)
~before(time)
end

# Compose a predicate from json-like hash.
# Useful for parsing Horizon output
#
# @example:
# ClaimPredicate.parse(
# "and" => [
# { "not" => { "abs_before" => "2021-09-17T09:59:34Z" } },
# { "abs_before" => "2021-10-17T09:59:34Z" }
# ]
# )
#
# @param object [Hash] see example for the format
# @return [ClaimPredicate]
def parse(object)
object = object.with_indifferent_access

# binding.irb
if object["unconditional"]
return unconditional
end

if object["not"]
return parse(object["not"]).not
end

method, args = object.to_a.first

if %w[and or].include?(method)
unless args.is_a?(Array)
raise ArgumentError, "invalid arguments #{args} for predicate '#{method}'"
end

parse(args[0]).public_send(method, parse(args[1]))
else
callable_method = {
"abs_before" => :before_absolute_time,
"rel_before" => :before_relative_time
}[method]

if callable_method.blank?
raise ArgumentError, "Unknown predicate '#{method}'"
end

public_send(callable_method, args)
end
end

# Compose a complex predicate by calling DSL methods from the block.
#
# @example
Expand Down
19 changes: 17 additions & 2 deletions base/lib/stellar/dsl.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
require "date"

module Stellar
module DSL
module_function
Expand Down Expand Up @@ -27,6 +29,19 @@ def Claimant(destination, &block)
)
end

def ClaimableBalance(id:, asset:, amount:, claimants: [])
Stellar::ClaimableBalanceEntry.new(
balance_id: id,
asset: Asset(asset),
amount: amount,
claimants: claimants.map do |claimant|
Claimant(claimant[:destination]) do
parse(claimant[:predicate])
end
end
)
end

def Account(subject = nil)
case subject
when Account
Expand Down Expand Up @@ -58,9 +73,9 @@ def Asset(subject = nil)
Asset.send(*subject)
when nil, /^(XLM[-:])?native$/
Asset.native
when /^([0-9A-Z]{1,4})[-:](G[A-Z0-9]{55})$/
when /^([0-9a-zA-Z]{1,4})[-:](G[A-Z0-9]{55})$/
Asset.alphanum4($1, KeyPair($2))
when /^([0-9A-Z]{5,12})[-:](G[A-Z0-9]{55})$/
when /^([0-9a-zA-Z]{5,12})[-:](G[A-Z0-9]{55})$/
Asset.alphanum12($1, KeyPair($2))
else
raise TypeError, "Cannot convert #{subject.inspect} to Stellar::Asset"
Expand Down
8 changes: 8 additions & 0 deletions base/spec/lib/stellar/asset_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,11 @@
expect { Stellar::Asset.native.code }.to raise_error(RuntimeError)
end
end

RSpec.describe Stellar::Asset, "#to_s" do
it "returns text representation of asset" do
a = Stellar::Asset.alphanum4("XXX", KeyPair("GCGQMVO4AOOQ3BQHGUGT52Y3XOMME6DPEB6QQDX44MC466FMDW2QTMRE"))

expect(a.to_s).to eq("XXX:GCGQMVO4AOOQ3BQHGUGT52Y3XOMME6DPEB6QQDX44MC466FMDW2QTMRE")
end
end
106 changes: 80 additions & 26 deletions base/spec/lib/stellar/claim_predicate_spec.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,29 @@
RSpec.describe Stellar::ClaimPredicate do
def self.specify_claim(created_at:, claimable_at:, not_claimable_at:)
created_at = created_at.to_time
subject(:evaluate) { predicate.method(:evaluate) }

context "with claim created at #{created_at.to_formatted_s(:db)}" do
let(:created) { created_at.to_time }

Array(claimable_at).each do |claimed|
claimed = created_at + claimed if claimed.is_a?(ActiveSupport::Duration)

it "evaluates to true at #{claimed.to_time.to_formatted_s(:db)}" do
expect(evaluate.call(created, claimed)).to be_truthy
end
end

Array(not_claimable_at).each do |claimed|
claimed = created_at + claimed if claimed.is_a?(ActiveSupport::Duration)

it "evaluates to false at #{claimed.to_time.to_formatted_s(:db)}" do
expect(evaluate.call(created, claimed)).to be_falsey
end
end
end
end

describe ".unconditional" do
subject { described_class.unconditional }

Expand Down Expand Up @@ -34,6 +59,60 @@
end
end

describe ".parse" do
context "unconditional" do
let(:predicate) { {"unconditional" => true} }
subject { described_class.parse(predicate) }

it { is_expected.to be_a(Stellar::ClaimPredicate) }
its(:type) { is_expected.to be(Stellar::ClaimPredicateType::UNCONDITIONAL) }
end

context "abs before" do
let(:timestamp) { "2022-11-16T00:00:00Z" }
let(:predicate) { {"abs_before" => timestamp} }
subject { described_class.parse(predicate) }

it { is_expected.to be_a(Stellar::ClaimPredicate) }
its(:type) { is_expected.to be(Stellar::ClaimPredicateType::BEFORE_ABSOLUTE_TIME) }
its(:abs_before) { is_expected.to be(DateTime.parse(timestamp).to_i) }
end

context "rel before" do
let(:predicate) { {"rel_before" => "3600"} }
subject { described_class.parse(predicate) }

it { is_expected.to be_a(Stellar::ClaimPredicate) }
its(:type) { is_expected.to be(Stellar::ClaimPredicateType::BEFORE_RELATIVE_TIME) }
its(:rel_before) { is_expected.to be(3600) }
end

context "complex case" do
let(:predicate) do
described_class.parse({
"and" => [
{"not" => {"abs_before" => "2021-09-17T09:59:34Z"}},
{"abs_before" => "2021-10-17T09:59:34Z"}
]
})
end

specify_claim(
created_at: "2021-09-18 09:00:00",
claimable_at: [
"2021-09-20 09:00:00 +0000",
"2021-10-10 09:24:20 +0000",
"2021-10-17 09:59:33 +0000"
],
not_claimable_at: [
-2.days,
+2.months,
"2021-10-17 12:59:34"
]
)
end
end

describe "#and" do
let(:predicate) { described_class.unconditional }
let(:other) { described_class.before_relative_time(3600) }
Expand Down Expand Up @@ -76,31 +155,6 @@
describe "#evaluate" do
let(:predicate) { described_class.unconditional }

def self.specify_claim(created_at:, claimable_at:, not_claimable_at:)
created_at = created_at.to_time
subject(:evaluate) { predicate.method(:evaluate) }

context "with claim created at #{created_at.to_formatted_s(:db)}" do
let(:created) { created_at.to_time }

Array(claimable_at).each do |claimed|
claimed = created_at + claimed if claimed.is_a?(ActiveSupport::Duration)

it "evaluates to true at #{claimed.to_time.to_formatted_s(:db)}" do
expect(evaluate.call(created, claimed)).to be_truthy
end
end

Array(not_claimable_at).each do |claimed|
claimed = created_at + claimed if claimed.is_a?(ActiveSupport::Duration)

it "evaluates to false at #{claimed.to_time.to_formatted_s(:db)}" do
expect(evaluate.call(created, claimed)).to be_falsey
end
end
end
end

context "before_relative_time(1.hour)" do
let(:predicate) { described_class.before_relative_time(1.hour) }

Expand All @@ -118,7 +172,7 @@ def self.specify_claim(created_at:, claimable_at:, not_claimable_at:)
)
end

context "before_relative_time(1.hour)" do
context "before_absolute_time" do
let(:predicate) { described_class.before_absolute_time("2020-10-22") }

specify_claim(
Expand Down
3 changes: 2 additions & 1 deletion horizon/lib/stellar-horizon.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ module Horizon

autoload :Client
autoload :Problem
autoload :TransactionPage
autoload :ResourcePage
autoload :ClaimableBalancePage
end
end
11 changes: 11 additions & 0 deletions horizon/lib/stellar/horizon/claimable_balance_page.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module Stellar::Horizon
class ClaimableBalancePage < ResourcePage
private

def objectify(record)
attributes = %i[id claimants asset amount]
hash = record.to_hash.deep_symbolize_keys.slice(*attributes)
ClaimableBalance(**hash)
end
end
end
27 changes: 22 additions & 5 deletions horizon/lib/stellar/horizon/client.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
require "hyperclient"
require "active_support/core_ext/object/blank"
require "active_support/core_ext/hash/keys"
require "securerandom"

module Stellar::Horizon
Expand All @@ -14,6 +15,8 @@ def initialize(message, account_id, operation_index)
end

class Client
include Stellar::DSL

DEFAULT_FEE = 100

HORIZON_LOCALHOST_URL = "http://127.0.0.1:8000"
Expand Down Expand Up @@ -45,7 +48,7 @@ def self.localhost(options = {})
# @option options [String] :horizon The Horizon server URL.
def initialize(options)
@options = options
@horizon = Hyperclient.new(options[:horizon]) { |client|
@horizon = Hyperclient.new(options[:horizon]) do |client|
client.faraday_block = lambda do |conn|
conn.use Faraday::Response::RaiseError
conn.use FaradayMiddleware::FollowRedirects
Expand All @@ -58,7 +61,7 @@ def initialize(options)
"X-Client-Name" => "ruby-stellar-sdk",
"X-Client-Version" => Stellar::Horizon::VERSION
}
}
end
end

# @param [Stellar::Account|String] account_or_address
Expand Down Expand Up @@ -115,6 +118,20 @@ def create_account(options = {})
submit_transaction(tx_envelope: envelope)
end

# Requests /claimable_balances endpoint with given parameters
#
# @param [Stellar::Asset|String] asset
# @param [Stellar::Account|String] claimant
# @param [Stellar::Account|String] sponsor
def claimable_balances(asset: nil, claimant: nil, sponsor: nil)
resource = @horizon.claimable_balances(
asset: asset.presence && Asset(asset).to_s,
claimant: claimant.presence && Account(claimant).address,
sponsor: sponsor.presence && Account(sponsor).address
)
Stellar::Horizon::ClaimableBalancePage.new(resource)
end

# @option options [Stellar::Account] :from The source account
# @option options [Stellar::Account] :to The destination account
# @option options [Stellar::Amount] :amount The amount to send
Expand Down Expand Up @@ -147,18 +164,18 @@ def send_payment(options = {})
# @option options [Stellar::Account] :account
# @option options [Integer] :limit
# @option options [Integer] :cursor
# @return [TransactionPage]
# @return [ResourcePage]
def transactions(options = {})
args = options.slice(:limit, :cursor)

resource = if options[:account]
args = args.merge(account_id: options[:account].address)
args = args.merge(account_id: Account(options[:account]).address)
@horizon.account_transactions(args)
else
@horizon.transactions(args)
end

TransactionPage.new(resource)
Stellar::Horizon::ResourcePage.new(resource)
end

# @param [Array(Symbol,String,Stellar::KeyPair|Stellar::Account)] asset
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,32 @@
module Stellar::Horizon
class TransactionPage
class ResourcePage
include Enumerable
include Stellar::DSL

# @param [Hyperclient::Link] resource
def initialize(resource)
@resource = resource
end

def each
@resource.records.each do |tx|
yield tx if block_given?
@resource.records.each do |record|
yield objectify(record) if block_given?
end
end

# @return [Stellar::TransactionPage]
# @return [Stellar::ResourcePage]
def next_page
self.class.new(@resource.next)
end

def next_page!
@resource = @resource.next
end

private

def objectify(record)
record
end
end
end
Loading