mixin_bot 2.6.0 → 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/AGENTS.md +3 -3
- data/API_COVERAGE.md +1 -0
- data/CHANGELOG.md +22 -1
- data/README.md +116 -5
- data/docs/agent/cookbook.md +49 -0
- data/examples/blaze_async.rb +6 -4
- data/lib/generators/mixin_bot/authentication/USAGE +29 -0
- data/lib/generators/mixin_bot/authentication/authentication_generator.rb +82 -0
- data/lib/generators/mixin_bot/authentication/templates/app/controllers/concerns/authentication.rb.tt +52 -0
- data/lib/generators/mixin_bot/authentication/templates/app/controllers/sessions_controller.rb.tt +24 -0
- data/lib/generators/mixin_bot/authentication/templates/app/models/current.rb.tt +4 -0
- data/lib/generators/mixin_bot/authentication/templates/app/models/session.rb.tt +3 -0
- data/lib/generators/mixin_bot/authentication/templates/app/models/user.rb.tt +12 -0
- data/lib/generators/mixin_bot/authentication/templates/app/views/sessions/new.html.erb.tt +3 -0
- data/lib/generators/mixin_bot/authentication/templates/config/initializers/omniauth.rb.tt +21 -0
- data/lib/generators/mixin_bot/blaze/USAGE +19 -0
- data/lib/generators/mixin_bot/blaze/blaze_generator.rb +43 -0
- data/lib/generators/mixin_bot/blaze/templates/app/mixin/handlers/text_handler.rb +14 -0
- data/lib/generators/mixin_bot/blaze/templates/config/initializers/mixin_bot_blaze.rb +23 -0
- data/lib/generators/mixin_bot/install/USAGE +24 -0
- data/lib/generators/mixin_bot/install/install_generator.rb +50 -0
- data/lib/generators/mixin_bot/notifications/USAGE +13 -0
- data/lib/generators/mixin_bot/notifications/notifications_generator.rb +49 -0
- data/lib/generators/mixin_bot/notifications/templates/app/notifications/payment_received_notification.rb +24 -0
- data/lib/generators/mixin_bot/outputs/USAGE +29 -0
- data/lib/generators/mixin_bot/outputs/outputs_generator.rb +50 -0
- data/lib/generators/mixin_bot/outputs/templates/app/mixin/processors/deposit_processor.rb +21 -0
- data/lib/generators/mixin_bot/outputs/templates/app/models/mixin_output.rb +8 -0
- data/lib/generators/mixin_bot/outputs/templates/db/migrate/create_mixin_outputs.rb.tt +26 -0
- data/lib/generators/mixin_bot/transfers/USAGE +37 -0
- data/lib/generators/mixin_bot/transfers/templates/app/jobs/mixin_transfers/perform_job.rb +13 -0
- data/lib/generators/mixin_bot/transfers/templates/app/jobs/mixin_transfers/reconcile_job.rb +14 -0
- data/lib/generators/mixin_bot/transfers/templates/app/models/mixin_transfer.rb +11 -0
- data/lib/generators/mixin_bot/transfers/templates/db/migrate/create_mixin_transfers.rb.tt +23 -0
- data/lib/generators/mixin_bot/transfers/transfers_generator.rb +67 -0
- data/lib/generators/mixin_bot.rb +10 -0
- data/lib/mixin_bot/api/app.rb +20 -0
- data/lib/mixin_bot/api/blaze_async.rb +5 -1
- data/lib/mixin_bot/api/snapshot.rb +5 -0
- data/lib/mixin_bot/blaze/reactor.rb +249 -0
- data/lib/mixin_bot/blaze/router.rb +333 -0
- data/lib/mixin_bot/cache_store_adapter.rb +3 -1
- data/lib/mixin_bot/client.rb +2 -1
- data/lib/mixin_bot/computer.rb +3 -1
- data/lib/mixin_bot/configuration.rb +52 -0
- data/lib/mixin_bot/json_decoder.rb +14 -0
- data/lib/mixin_bot/notifications/mixin_channel.rb +80 -0
- data/lib/mixin_bot/outputs/cursor_model.rb +33 -0
- data/lib/mixin_bot/outputs/envelope.rb +160 -0
- data/lib/mixin_bot/outputs/memory_stores.rb +97 -0
- data/lib/mixin_bot/outputs/poller.rb +178 -0
- data/lib/mixin_bot/outputs/processing_job.rb +54 -0
- data/lib/mixin_bot/outputs/processors.rb +124 -0
- data/lib/mixin_bot/outputs/receipt_model.rb +86 -0
- data/lib/mixin_bot/outputs.rb +42 -0
- data/lib/mixin_bot/rails/railtie.rb +44 -0
- data/lib/mixin_bot/rails/tasks/poller.rake +33 -0
- data/lib/mixin_bot/rails.rb +34 -0
- data/lib/mixin_bot/registry.rb +79 -0
- data/lib/mixin_bot/transfers/model.rb +112 -0
- data/lib/mixin_bot/transfers/performer.rb +64 -0
- data/lib/mixin_bot/transfers/reconcile.rb +53 -0
- data/lib/mixin_bot/transfers.rb +23 -0
- data/lib/mixin_bot/version.rb +1 -1
- data/lib/mixin_bot.rb +4 -0
- data/lib/puma/plugin/mixin_blaze.rb +263 -0
- data/llms.txt +1 -1
- metadata +69 -7
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MixinBot
|
|
4
|
+
module Outputs
|
|
5
|
+
##
|
|
6
|
+
# Base class for output processors. Subclass, implement class-level
|
|
7
|
+
# {matches?} and instance-level {#process}:
|
|
8
|
+
#
|
|
9
|
+
# module Mixin
|
|
10
|
+
# module Processors
|
|
11
|
+
# class OrderDeposit < MixinBot::Outputs::Processor
|
|
12
|
+
# def self.matches?(envelope)
|
|
13
|
+
# !envelope.spent? && envelope.memo.to_s.start_with?('ORDER-')
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# def process
|
|
17
|
+
# Order.settle_by_deposit!(envelope)
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# Processors live in app/mixin/processors (Rails autoloads them as
|
|
24
|
+
# Mixin::Processors::*). Every processor whose predicate matches an output
|
|
25
|
+
# gets a job enqueued — matches are independent side effects, and an
|
|
26
|
+
# output matching no processor is recorded only.
|
|
27
|
+
#
|
|
28
|
+
# Enqueue is at-least-once: processors should tolerate rare duplicate
|
|
29
|
+
# runs and can use the envelope's output_id as a natural idempotency key.
|
|
30
|
+
#
|
|
31
|
+
class Processor
|
|
32
|
+
def initialize(envelope)
|
|
33
|
+
@envelope = envelope
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call
|
|
37
|
+
process
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
attr_reader :envelope
|
|
41
|
+
|
|
42
|
+
# Override with the processor's selection predicate.
|
|
43
|
+
def self.matches?(_envelope)
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def process
|
|
48
|
+
raise NotImplementedError, "#{self.class} must implement #process"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
##
|
|
53
|
+
# Processor discovery and selection over the Mixin::Processors namespace.
|
|
54
|
+
#
|
|
55
|
+
module Processors
|
|
56
|
+
NAMESPACE_NAME = 'Mixin::Processors'
|
|
57
|
+
|
|
58
|
+
module_function
|
|
59
|
+
|
|
60
|
+
##
|
|
61
|
+
# All processor classes currently defined under Mixin::Processors. Under
|
|
62
|
+
# Rails the namespace is eager-loaded first so the poller process sees
|
|
63
|
+
# every processor without referencing each one.
|
|
64
|
+
#
|
|
65
|
+
# @return [Array<Class>]
|
|
66
|
+
#
|
|
67
|
+
def discover
|
|
68
|
+
eager_load_namespace! if defined?(::Rails.application) && ::Rails.application.respond_to?(:autoloaders)
|
|
69
|
+
|
|
70
|
+
namespace = namespace_class
|
|
71
|
+
return [] if namespace.nil?
|
|
72
|
+
|
|
73
|
+
namespace.constants.filter_map do |const|
|
|
74
|
+
candidate = namespace.const_get(const)
|
|
75
|
+
candidate if candidate.is_a?(Class) && candidate < Processor
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Finds a processor by fully-qualified class name (the payload the
|
|
80
|
+
# poller's jobs carry).
|
|
81
|
+
#
|
|
82
|
+
# @param name [String]
|
|
83
|
+
# @return [Class]
|
|
84
|
+
# @raise [NameError] when no such processor is loaded
|
|
85
|
+
#
|
|
86
|
+
def resolve(name)
|
|
87
|
+
constant = Object.const_get(name)
|
|
88
|
+
raise NameError, "#{name} is not a MixinBot::Outputs::Processor" unless constant.is_a?(Class) && constant < Processor
|
|
89
|
+
|
|
90
|
+
constant
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
##
|
|
94
|
+
# Processors whose predicate matches the envelope.
|
|
95
|
+
#
|
|
96
|
+
# @return [Array<Class>]
|
|
97
|
+
#
|
|
98
|
+
def for_envelope(envelope, processors:)
|
|
99
|
+
processors.select { |processor| processor.matches?(envelope) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def namespace_class
|
|
103
|
+
Object.const_get(NAMESPACE_NAME)
|
|
104
|
+
rescue NameError
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def eager_load_namespace!
|
|
109
|
+
autoloader = ::Rails.autoloaders.main
|
|
110
|
+
namespace = namespace_class
|
|
111
|
+
return if namespace.nil?
|
|
112
|
+
|
|
113
|
+
if autoloader.respond_to?(:eager_load_namespace)
|
|
114
|
+
autoloader.eager_load_namespace(namespace)
|
|
115
|
+
else
|
|
116
|
+
::Rails.application.eager_load!
|
|
117
|
+
end
|
|
118
|
+
rescue Zeitwerk::SetupRequired, StandardError
|
|
119
|
+
# Best effort: an eager-load problem must not stop polling.
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/concern'
|
|
4
|
+
|
|
5
|
+
module MixinBot
|
|
6
|
+
module Outputs
|
|
7
|
+
##
|
|
8
|
+
# ActiveRecord concern for the generated +MixinOutput+ receipt model
|
|
9
|
+
# (mixin_bot:outputs generator). Implements the receipt-store interface
|
|
10
|
+
# the poller consumes, on top of the table's unique
|
|
11
|
+
# +(bot_app_id, output_id)+ index:
|
|
12
|
+
#
|
|
13
|
+
# class MixinOutput < ApplicationRecord
|
|
14
|
+
# include MixinBot::Outputs::ReceiptModel
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# Including the model also registers it as the integration's receipt
|
|
18
|
+
# store (the mixin_bot:poller rake task) and as the processing job's
|
|
19
|
+
# receipt loader. The poller's resume cursor is derived from the receipts
|
|
20
|
+
# themselves — the newest output sequence per bot — so there is no
|
|
21
|
+
# separate cursor table.
|
|
22
|
+
#
|
|
23
|
+
# Required columns (see the generated migration): bot_app_id, output_id,
|
|
24
|
+
# amount, asset_id, state, transaction_hash, output_index, sequence
|
|
25
|
+
# (the outputs API's pagination key — the poller's cursor), memo,
|
|
26
|
+
# opponent_id, trace_id, snapshot_bridged, enqueued_at, plus Rails
|
|
27
|
+
# timestamps (created_at backs the sweep query).
|
|
28
|
+
#
|
|
29
|
+
module ReceiptModel
|
|
30
|
+
extend ActiveSupport::Concern
|
|
31
|
+
|
|
32
|
+
class_methods do
|
|
33
|
+
# Records the output or reports it as already seen. Uniqueness is
|
|
34
|
+
# enforced by the (bot_app_id, output_id) index, so concurrent pollers
|
|
35
|
+
# race safely.
|
|
36
|
+
def record!(bot_app_id:, output:)
|
|
37
|
+
existing = find_by(bot_app_id:, output_id: output['output_id'])
|
|
38
|
+
return [existing, :duplicate] if existing
|
|
39
|
+
|
|
40
|
+
receipt = create!(
|
|
41
|
+
bot_app_id:,
|
|
42
|
+
output_id: output['output_id'],
|
|
43
|
+
amount: output['amount'],
|
|
44
|
+
asset_id: output['asset_id'],
|
|
45
|
+
state: output['state'],
|
|
46
|
+
transaction_hash: output['transaction_hash'],
|
|
47
|
+
output_index: output['output_index'],
|
|
48
|
+
output_created_at: output['created_at']
|
|
49
|
+
)
|
|
50
|
+
[receipt, :created]
|
|
51
|
+
rescue ActiveRecord::RecordNotUnique
|
|
52
|
+
[find_by!(bot_app_id:, output_id: output['output_id']), :duplicate]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def mark_enqueued!(receipt)
|
|
56
|
+
receipt.update!(enqueued_at: Time.now)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# created_at is the Rails timestamp (when the receipt row was written),
|
|
60
|
+
# so the sweep's cutoff means "recorded before this moment".
|
|
61
|
+
def unenqueued(bot_app_id:, older_than:)
|
|
62
|
+
where(bot_app_id:, enqueued_at: nil).where(created_at: ...older_than)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The poller's resume position: the newest output sequence recorded
|
|
66
|
+
# for this bot — unique and monotonic, and the key the outputs API
|
|
67
|
+
# paginates on (nil = fetch from the beginning). Derived from the
|
|
68
|
+
# receipts themselves — no separate cursor state.
|
|
69
|
+
def cursor_value(bot_app_id:)
|
|
70
|
+
where(bot_app_id:).maximum(:sequence)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Caches the envelope's resolved snapshot data (one bridge attempt per
|
|
75
|
+
# output — including failures, which cache nils and the flag).
|
|
76
|
+
def cache_snapshot!(memo:, opponent_id:, trace_id:)
|
|
77
|
+
update!(memo:, opponent_id:, trace_id:, snapshot_bridged: true) unless frozen?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
included do
|
|
81
|
+
MixinBot::Outputs.receipt_loader = method(:find)
|
|
82
|
+
MixinBot::Outputs.receipt_store = self
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MixinBot
|
|
4
|
+
##
|
|
5
|
+
# Output (transfer) polling integration: delivers every output touching the
|
|
6
|
+
# bot's Safe UTXO set — inbound or outbound — to application processor
|
|
7
|
+
# classes exactly once, via a standalone polling process and the
|
|
8
|
+
# application's job queue.
|
|
9
|
+
#
|
|
10
|
+
# poller = MixinBot::Outputs::Poller.new(api: MixinBot.bot(:shop))
|
|
11
|
+
# poller.run # blocks; see the mixin_bot:poller rake task
|
|
12
|
+
#
|
|
13
|
+
module Outputs
|
|
14
|
+
class << self
|
|
15
|
+
# Shared logger for the outputs integration; called with (level, detail),
|
|
16
|
+
# same shape as MixinBot::Blaze::Reactor's logger. Injectable per
|
|
17
|
+
# component; this default is the fallback.
|
|
18
|
+
attr_accessor :logger
|
|
19
|
+
|
|
20
|
+
# Called with a receipt id to reload the receipt record when a processing
|
|
21
|
+
# job runs — set by the generated receipt model (ActiveRecord host apps).
|
|
22
|
+
# Without a loader, processing jobs raise a clear error explaining how to
|
|
23
|
+
# wire one.
|
|
24
|
+
attr_accessor :receipt_loader
|
|
25
|
+
|
|
26
|
+
# The receipt store the poller process uses — set by the generated
|
|
27
|
+
# receipt model when it loads. The mixin_bot:poller rake task refuses to
|
|
28
|
+
# run without one (polling with an in-memory store would silently lose
|
|
29
|
+
# dedup across restarts).
|
|
30
|
+
attr_accessor :receipt_store
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
self.logger = ->(level, detail) { warn "[mixin_outputs] #{level}: #{detail}" }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
require_relative 'outputs/envelope'
|
|
38
|
+
require_relative 'outputs/memory_stores'
|
|
39
|
+
require_relative 'outputs/processors'
|
|
40
|
+
require_relative 'outputs/poller'
|
|
41
|
+
require_relative 'outputs/processing_job'
|
|
42
|
+
require_relative 'outputs/receipt_model'
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/railtie'
|
|
4
|
+
|
|
5
|
+
module MixinBot
|
|
6
|
+
# Registers the Rails integration's rake tasks and application-code
|
|
7
|
+
# conventions. Lives at lib/mixin_bot/rails/railtie.rb (loaded by
|
|
8
|
+
# `require 'mixin_bot/rails'`), named +MixinBot::Railtie+ — deliberately not
|
|
9
|
+
# +MixinBot::Rails+, which would lexically shadow ::Rails for code written
|
|
10
|
+
# inside module MixinBot.
|
|
11
|
+
#
|
|
12
|
+
# Application conventions:
|
|
13
|
+
# - +app/mixin/handlers+ hosts Blaze message handler classes
|
|
14
|
+
# (+Mixin::Handlers::*+) and +app/mixin/processors+ hosts output
|
|
15
|
+
# processor classes (+Mixin::Processors::*+). Rails' default path
|
|
16
|
+
# inference would make +app/mixin+ itself a Zeitwerk root (mapping files
|
|
17
|
+
# to +Handlers::*+ / +Processors::*+, not +Mixin::Handlers::*+), so the
|
|
18
|
+
# railtie excludes it from the defaults and re-registers it with the
|
|
19
|
+
# +Mixin+ namespace explicitly. Eager loading is the poller process's
|
|
20
|
+
# responsibility (it eager-loads before discovering processors),
|
|
21
|
+
# mirroring how Solid Queue's supervisor boots.
|
|
22
|
+
#
|
|
23
|
+
# Generator discovery needs no registration either: railties scans loaded
|
|
24
|
+
# gems for +lib/generators+ automatically.
|
|
25
|
+
class Railtie < ::Rails::Railtie
|
|
26
|
+
railtie_name 'mixin_bot'
|
|
27
|
+
|
|
28
|
+
initializer 'mixin_bot.exclude_default_mixin_root', before: :set_autoload_paths do |app|
|
|
29
|
+
app.config.paths.add 'app/mixin', autoload: false, eager_load: false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
initializer 'mixin_bot.push_mixin_root', after: :set_autoload_paths, before: :setup_main_autoloader do |app|
|
|
33
|
+
mixin_root = app.root.join('app/mixin')
|
|
34
|
+
next unless mixin_root.directory?
|
|
35
|
+
|
|
36
|
+
Object.const_set(:Mixin, Module.new) unless Object.const_defined?(:Mixin)
|
|
37
|
+
app.autoloaders.main.push_dir(mixin_root, namespace: Mixin)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
rake_tasks do
|
|
41
|
+
load File.expand_path('tasks/poller.rake', __dir__)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :mixin_bot do
|
|
4
|
+
desc 'Poll Mixin outputs for the default bot (or the given [bot_name]) and enqueue processor jobs'
|
|
5
|
+
task :poller, [:bot] => :environment do |_task, args|
|
|
6
|
+
# Processor classes live in app/mixin/processors; eager load so discovery
|
|
7
|
+
# sees them (the poller process never references them directly).
|
|
8
|
+
Rails.application.eager_load!
|
|
9
|
+
|
|
10
|
+
unless MixinBot::Outputs.receipt_store
|
|
11
|
+
raise MixinBot::ArgumentError,
|
|
12
|
+
'no receipt store configured: run `rails g mixin_bot:outputs` (or include ' \
|
|
13
|
+
'MixinBot::Outputs::ReceiptModel in your receipt model) so polling dedup ' \
|
|
14
|
+
'survives restarts'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
api = args[:bot] ? MixinBot.bot(args[:bot]) : MixinBot.api
|
|
18
|
+
poller = MixinBot::Outputs::Poller.new(
|
|
19
|
+
api:,
|
|
20
|
+
receipts: MixinBot::Outputs.receipt_store,
|
|
21
|
+
interval: Integer(ENV.fetch('MIXIN_BOT_POLL_INTERVAL', 5)),
|
|
22
|
+
asset: ENV.fetch('MIXIN_BOT_POLL_ASSET', nil),
|
|
23
|
+
members: ENV['MIXIN_BOT_POLL_MEMBERS']&.split(','),
|
|
24
|
+
threshold: ENV['MIXIN_BOT_POLL_THRESHOLD']&.then { |t| Integer(t) }
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
%w[TERM INT].each { |signal| trap(signal) { poller.stop } }
|
|
28
|
+
|
|
29
|
+
MixinBot::Outputs.logger.call(:info, "poller started (pid=#{Process.pid})")
|
|
30
|
+
poller.run
|
|
31
|
+
MixinBot::Outputs.logger.call(:info, 'poller stopped')
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Opt-in Rails integration layer: generators, railtie, and Rails glue.
|
|
4
|
+
#
|
|
5
|
+
# `require 'mixin_bot'` alone never loads any of this — the gem keeps no
|
|
6
|
+
# runtime dependency on Rails. Host applications opt in explicitly:
|
|
7
|
+
#
|
|
8
|
+
# # Gemfile
|
|
9
|
+
# gem 'mixin_bot'
|
|
10
|
+
# gem 'rails'
|
|
11
|
+
#
|
|
12
|
+
# # config/application.rb (after the bundler groups)
|
|
13
|
+
# require 'mixin_bot/rails'
|
|
14
|
+
#
|
|
15
|
+
# The railtie registers the mixin_bot rake tasks (e.g. `mixin_bot:poller`).
|
|
16
|
+
# Rails discovers the `lib/generators/mixin_bot/*` generators on its own.
|
|
17
|
+
|
|
18
|
+
require 'mixin_bot'
|
|
19
|
+
|
|
20
|
+
begin
|
|
21
|
+
require 'rails'
|
|
22
|
+
rescue LoadError => e
|
|
23
|
+
raise LoadError,
|
|
24
|
+
'mixin_bot/rails requires the rails gem, which is not a runtime ' \
|
|
25
|
+
"dependency of mixin_bot. Add `gem 'rails'` to your Gemfile, or " \
|
|
26
|
+
"drop `require 'mixin_bot/rails'` if you only need the SDK. " \
|
|
27
|
+
"(#{e.message})"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
require_relative 'blaze/router'
|
|
31
|
+
require_relative 'outputs'
|
|
32
|
+
require_relative 'transfers'
|
|
33
|
+
require_relative 'rails/railtie'
|
|
34
|
+
require_relative 'notifications/mixin_channel'
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MixinBot
|
|
4
|
+
##
|
|
5
|
+
# Named registry for running several bots (keystores) inside one process.
|
|
6
|
+
#
|
|
7
|
+
# The default bot stays on the global configuration (+MixinBot.configure+ /
|
|
8
|
+
# +MixinBot.api+). Additional bots register under a name and return a
|
|
9
|
+
# dedicated +MixinBot::API+ whose configuration is frozen after registration:
|
|
10
|
+
#
|
|
11
|
+
# MixinBot.register_bot :shop,
|
|
12
|
+
# app_id: '...',
|
|
13
|
+
# session_id: '...',
|
|
14
|
+
# session_private_key: '...',
|
|
15
|
+
# server_public_key: '...'
|
|
16
|
+
#
|
|
17
|
+
# MixinBot.bot(:shop).me
|
|
18
|
+
#
|
|
19
|
+
# Integration components built on top of the SDK (router, poller, transfers,
|
|
20
|
+
# notifications) accept the bot name and default to the global API client.
|
|
21
|
+
module Registry
|
|
22
|
+
##
|
|
23
|
+
# Registers a bot under +name+ with {MixinBot::API#initialize} kwargs and
|
|
24
|
+
# freezes the resulting client's configuration.
|
|
25
|
+
#
|
|
26
|
+
# @param name [Symbol, String] registry key
|
|
27
|
+
# @param kwargs [Hash] bot credentials (see {Configuration#initialize});
|
|
28
|
+
# empty kwargs would alias the global configuration and are rejected
|
|
29
|
+
# @return [MixinBot::API] the registered client
|
|
30
|
+
# @raise [MixinBot::ArgumentError] when the name is already registered or
|
|
31
|
+
# no credentials are given
|
|
32
|
+
#
|
|
33
|
+
def register_bot(name, **kwargs)
|
|
34
|
+
raise MixinBot::ArgumentError, 'register_bot requires bot credentials' if kwargs.empty?
|
|
35
|
+
raise MixinBot::ArgumentError, "bot #{name.inspect} is already registered" if bots.key?(name.to_sym)
|
|
36
|
+
|
|
37
|
+
api = MixinBot::API.new(**kwargs)
|
|
38
|
+
api.config.freeze
|
|
39
|
+
bots[name.to_sym] = api
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
##
|
|
43
|
+
# Returns the API client registered under +name+.
|
|
44
|
+
#
|
|
45
|
+
# MixinBot.bot(:shop).assets
|
|
46
|
+
#
|
|
47
|
+
# @param name [Symbol, String] registry key
|
|
48
|
+
# @return [MixinBot::API]
|
|
49
|
+
# @raise [KeyError] when nothing is registered under that name
|
|
50
|
+
#
|
|
51
|
+
def bot(name)
|
|
52
|
+
bots.fetch(name.to_sym)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
##
|
|
56
|
+
# Finds a bot client by its app id: the default API when the app id is
|
|
57
|
+
# the global configuration's, else the registered bot carrying it.
|
|
58
|
+
#
|
|
59
|
+
# @param app_id [String, nil]
|
|
60
|
+
# @return [MixinBot::API, nil] nil when no bot carries that app id
|
|
61
|
+
#
|
|
62
|
+
def bot_by_app_id(app_id)
|
|
63
|
+
return nil if app_id.nil?
|
|
64
|
+
return api if config.app_id == app_id
|
|
65
|
+
|
|
66
|
+
bots.each_value.find { |client| client.config.app_id == app_id }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
##
|
|
70
|
+
# Returns the registry contents (bot name => API client). The hash is
|
|
71
|
+
# exposed for introspection; mutate it only between registrations.
|
|
72
|
+
#
|
|
73
|
+
# @return [Hash{Symbol => MixinBot::API}]
|
|
74
|
+
#
|
|
75
|
+
def bots
|
|
76
|
+
@bots ||= {}
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/concern'
|
|
4
|
+
|
|
5
|
+
module MixinBot
|
|
6
|
+
module Transfers
|
|
7
|
+
##
|
|
8
|
+
# ActiveRecord concern for the generated +MixinTransfer+ model
|
|
9
|
+
# (mixin_bot:transfers generator): the transfer ledger's state machine and
|
|
10
|
+
# its execution entry points.
|
|
11
|
+
#
|
|
12
|
+
# class MixinTransfer < ApplicationRecord
|
|
13
|
+
# include MixinBot::Transfers::Model
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# transfer = MixinTransfer.create!(
|
|
17
|
+
# recipient_id: user_id, asset_id: ASSET, amount: '1.5',
|
|
18
|
+
# memo: 'payout', trace_id: SecureRandom.uuid
|
|
19
|
+
# )
|
|
20
|
+
# transfer.perform! # executes the Safe pipeline
|
|
21
|
+
# transfer.reconcile! # resolves unknown outcomes
|
|
22
|
+
# MixinTransfer.reconcile_pending! # recurring-job entry point
|
|
23
|
+
#
|
|
24
|
+
# Required columns (see the generated migration): recipient_id, asset_id,
|
|
25
|
+
# amount, memo, trace_id (unique), state (default 'pending'),
|
|
26
|
+
# previous_state, transaction_hash, error, transitioned_at, bot_app_id
|
|
27
|
+
# (nil = the default bot), plus Rails timestamps.
|
|
28
|
+
#
|
|
29
|
+
module Model
|
|
30
|
+
extend ActiveSupport::Concern
|
|
31
|
+
|
|
32
|
+
STATES = %w[pending broadcast confirmed failed reconciling].freeze
|
|
33
|
+
|
|
34
|
+
# The allowed state graph: pending is pre-submission; broadcast means
|
|
35
|
+
# the transaction was submitted; confirmed and failed are terminal;
|
|
36
|
+
# reconciling means a submission outcome was indeterminate.
|
|
37
|
+
TRANSITIONS = {
|
|
38
|
+
'pending' => %w[broadcast failed reconciling],
|
|
39
|
+
'broadcast' => %w[confirmed reconciling],
|
|
40
|
+
'reconciling' => %w[broadcast confirmed failed],
|
|
41
|
+
'confirmed' => [].freeze,
|
|
42
|
+
'failed' => [].freeze
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
included do
|
|
46
|
+
validates :trace_id, presence: true, uniqueness: true
|
|
47
|
+
validates :recipient_id, :asset_id, :amount, presence: true
|
|
48
|
+
validates :state, inclusion: { in: STATES }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class_methods do
|
|
52
|
+
# Recurring-job entry point: resolve every transfer whose send attempt
|
|
53
|
+
# ended indeterminately (timeout after submission).
|
|
54
|
+
def reconcile_pending!
|
|
55
|
+
reconciling.find_each(&:reconcile!)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
##
|
|
60
|
+
# Audited state transition through the allowed graph. Never mutate
|
|
61
|
+
# #state directly. Same-state transitions are no-ops (idempotent
|
|
62
|
+
# retries); terminal states (confirmed, failed) never change.
|
|
63
|
+
#
|
|
64
|
+
# @param state [String] target state
|
|
65
|
+
# @param transaction_hash [String, nil] set when the network revealed it
|
|
66
|
+
# @param error [String, nil] set when the transition records a failure
|
|
67
|
+
# @raise [MixinBot::ArgumentError] when the transition is not allowed
|
|
68
|
+
#
|
|
69
|
+
def transition_to!(state, transaction_hash: nil, error: nil)
|
|
70
|
+
state = state.to_s
|
|
71
|
+
return self if state == self.state
|
|
72
|
+
|
|
73
|
+
unless TRANSITIONS.fetch(self.state, []).include?(state)
|
|
74
|
+
raise MixinBot::ArgumentError, "illegal transfer transition: #{self.state.inspect} → #{state.inspect}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
update!(
|
|
78
|
+
previous_state: self.state,
|
|
79
|
+
state:,
|
|
80
|
+
transaction_hash: transaction_hash || self.transaction_hash,
|
|
81
|
+
error:,
|
|
82
|
+
transitioned_at: Time.now
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
##
|
|
87
|
+
# Executes the Safe pipeline for this transfer (idempotent per trace
|
|
88
|
+
# id). Definitive rejections mark the transfer failed; indeterminate
|
|
89
|
+
# network outcomes mark it reconciling. {Reconcile} resolves those.
|
|
90
|
+
#
|
|
91
|
+
def perform!
|
|
92
|
+
return self if state == 'confirmed'
|
|
93
|
+
raise MixinBot::ArgumentError, "transfer #{trace_id} is failed and will not be retried" if state == 'failed'
|
|
94
|
+
|
|
95
|
+
Performer.new(self, api: bot_api).perform
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
##
|
|
99
|
+
# Resolves an indeterminate outcome: confirm from the trace's snapshot,
|
|
100
|
+
# or re-send with the same trace id (safe — the network deduplicates).
|
|
101
|
+
#
|
|
102
|
+
def reconcile!
|
|
103
|
+
Reconcile.new(self, api: bot_api).call
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The API client bound to this transfer's bot (bot_app_id or default).
|
|
107
|
+
def bot_api
|
|
108
|
+
MixinBot.bot_by_app_id(bot_app_id) || MixinBot.api
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MixinBot
|
|
4
|
+
module Transfers
|
|
5
|
+
##
|
|
6
|
+
# Executes the Safe pipeline for one transfer record:
|
|
7
|
+
# build_utxos → build_safe_transaction → verify → sign → send, with the
|
|
8
|
+
# transfer's trace id as the request id, so network-side deduplication
|
|
9
|
+
# makes every retry safe.
|
|
10
|
+
#
|
|
11
|
+
# Outcome mapping:
|
|
12
|
+
# - success → broadcast (transaction hash stored), then confirmed when the
|
|
13
|
+
# submission response carries the hash (it normally does);
|
|
14
|
+
# - definitive API rejection (insufficient funds, invalid request, ...) →
|
|
15
|
+
# failed — never retried;
|
|
16
|
+
# - indeterminate network outcome (timeout/connection loss around the
|
|
17
|
+
# submission) → reconciling — {Reconcile} resolves it.
|
|
18
|
+
#
|
|
19
|
+
class Performer
|
|
20
|
+
# Indeterminate-outcome network errors: the submission may or may not
|
|
21
|
+
# have landed, so the transfer goes to reconciling rather than failed.
|
|
22
|
+
NETWORK_ERRORS = [Faraday::Error, SocketError, Errno::ETIMEDOUT, Timeout::Error].freeze
|
|
23
|
+
|
|
24
|
+
attr_reader :transfer, :api
|
|
25
|
+
|
|
26
|
+
def initialize(transfer, api:)
|
|
27
|
+
@transfer = transfer
|
|
28
|
+
@api = api
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
##
|
|
32
|
+
# Runs the pipeline and records the outcome. Re-raises unexpected
|
|
33
|
+
# (non-API, non-network) errors — those are bugs, left to the job
|
|
34
|
+
# backend's retry policy, with the transfer still in its prior state.
|
|
35
|
+
#
|
|
36
|
+
# @return [MixinBot::Transfers::Performer]
|
|
37
|
+
#
|
|
38
|
+
def perform
|
|
39
|
+
response = api.create_safe_transfer(
|
|
40
|
+
asset_id: transfer.asset_id,
|
|
41
|
+
amount: transfer.amount.to_d,
|
|
42
|
+
members: [transfer.recipient_id],
|
|
43
|
+
memo: transfer.memo.to_s,
|
|
44
|
+
trace_id: transfer.trace_id
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
data = response['data']
|
|
48
|
+
data = data.first if data.is_a?(Array)
|
|
49
|
+
data ||= {}
|
|
50
|
+
|
|
51
|
+
transfer.transition_to!('broadcast', transaction_hash: data['transaction_hash'])
|
|
52
|
+
transfer.transition_to!('confirmed', transaction_hash: data['transaction_hash']) if data['transaction_hash']
|
|
53
|
+
self
|
|
54
|
+
rescue MixinBot::APIError => e
|
|
55
|
+
transfer.transition_to!('failed', error: "#{e.class}: #{e.message}")
|
|
56
|
+
self
|
|
57
|
+
rescue Faraday::Error, SocketError, Errno::ETIMEDOUT, Timeout::Error => e
|
|
58
|
+
Transfers.logger.call(:indeterminate, "#{e.class}: #{e.message} (trace #{transfer.trace_id})")
|
|
59
|
+
transfer.transition_to!('reconciling', error: "#{e.class}: #{e.message}")
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MixinBot
|
|
4
|
+
module Transfers
|
|
5
|
+
##
|
|
6
|
+
# Resolves a transfer whose send attempt ended indeterminately (timeout
|
|
7
|
+
# or connection loss around the submission):
|
|
8
|
+
#
|
|
9
|
+
# - a snapshot exists for the trace id → the transfer landed; confirm
|
|
10
|
+
# from the snapshot (no funds ever re-sent);
|
|
11
|
+
# - no snapshot exists → nothing landed; re-send with the same trace id —
|
|
12
|
+
# the network's trace deduplication makes that safe.
|
|
13
|
+
#
|
|
14
|
+
class Reconcile
|
|
15
|
+
attr_reader :transfer, :api
|
|
16
|
+
|
|
17
|
+
def initialize(transfer, api:)
|
|
18
|
+
@transfer = transfer
|
|
19
|
+
@api = api
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
##
|
|
23
|
+
# @return [Symbol] :confirmed (found in the network), :resent (nothing
|
|
24
|
+
# existed; re-sent successfully) or :failed (the re-send was
|
|
25
|
+
# definitively rejected)
|
|
26
|
+
#
|
|
27
|
+
def call
|
|
28
|
+
snapshot = fetch_snapshot_by_trace
|
|
29
|
+
|
|
30
|
+
if snapshot
|
|
31
|
+
transfer.transition_to!('confirmed',
|
|
32
|
+
transaction_hash: snapshot['transaction_hash'] || transfer.transaction_hash)
|
|
33
|
+
:confirmed
|
|
34
|
+
else
|
|
35
|
+
Performer.new(transfer, api:).perform
|
|
36
|
+
transfer.state == 'failed' ? :failed : :resent
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Returns the snapshot hash for the trace id, or nil when the network
|
|
43
|
+
# has none (the only outcome that justifies a re-send).
|
|
44
|
+
def fetch_snapshot_by_trace
|
|
45
|
+
response = api.safe_snapshot_by_trace(transfer.trace_id)
|
|
46
|
+
data = response['data']
|
|
47
|
+
data.is_a?(Hash) && data['snapshot_id'] ? data : nil
|
|
48
|
+
rescue MixinBot::NotFoundError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|