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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +3 -3
  3. data/API_COVERAGE.md +1 -0
  4. data/CHANGELOG.md +22 -1
  5. data/README.md +116 -5
  6. data/docs/agent/cookbook.md +49 -0
  7. data/examples/blaze_async.rb +6 -4
  8. data/lib/generators/mixin_bot/authentication/USAGE +29 -0
  9. data/lib/generators/mixin_bot/authentication/authentication_generator.rb +82 -0
  10. data/lib/generators/mixin_bot/authentication/templates/app/controllers/concerns/authentication.rb.tt +52 -0
  11. data/lib/generators/mixin_bot/authentication/templates/app/controllers/sessions_controller.rb.tt +24 -0
  12. data/lib/generators/mixin_bot/authentication/templates/app/models/current.rb.tt +4 -0
  13. data/lib/generators/mixin_bot/authentication/templates/app/models/session.rb.tt +3 -0
  14. data/lib/generators/mixin_bot/authentication/templates/app/models/user.rb.tt +12 -0
  15. data/lib/generators/mixin_bot/authentication/templates/app/views/sessions/new.html.erb.tt +3 -0
  16. data/lib/generators/mixin_bot/authentication/templates/config/initializers/omniauth.rb.tt +21 -0
  17. data/lib/generators/mixin_bot/blaze/USAGE +19 -0
  18. data/lib/generators/mixin_bot/blaze/blaze_generator.rb +43 -0
  19. data/lib/generators/mixin_bot/blaze/templates/app/mixin/handlers/text_handler.rb +14 -0
  20. data/lib/generators/mixin_bot/blaze/templates/config/initializers/mixin_bot_blaze.rb +23 -0
  21. data/lib/generators/mixin_bot/install/USAGE +24 -0
  22. data/lib/generators/mixin_bot/install/install_generator.rb +50 -0
  23. data/lib/generators/mixin_bot/notifications/USAGE +13 -0
  24. data/lib/generators/mixin_bot/notifications/notifications_generator.rb +49 -0
  25. data/lib/generators/mixin_bot/notifications/templates/app/notifications/payment_received_notification.rb +24 -0
  26. data/lib/generators/mixin_bot/outputs/USAGE +29 -0
  27. data/lib/generators/mixin_bot/outputs/outputs_generator.rb +50 -0
  28. data/lib/generators/mixin_bot/outputs/templates/app/mixin/processors/deposit_processor.rb +21 -0
  29. data/lib/generators/mixin_bot/outputs/templates/app/models/mixin_output.rb +8 -0
  30. data/lib/generators/mixin_bot/outputs/templates/db/migrate/create_mixin_outputs.rb.tt +26 -0
  31. data/lib/generators/mixin_bot/transfers/USAGE +37 -0
  32. data/lib/generators/mixin_bot/transfers/templates/app/jobs/mixin_transfers/perform_job.rb +13 -0
  33. data/lib/generators/mixin_bot/transfers/templates/app/jobs/mixin_transfers/reconcile_job.rb +14 -0
  34. data/lib/generators/mixin_bot/transfers/templates/app/models/mixin_transfer.rb +11 -0
  35. data/lib/generators/mixin_bot/transfers/templates/db/migrate/create_mixin_transfers.rb.tt +23 -0
  36. data/lib/generators/mixin_bot/transfers/transfers_generator.rb +67 -0
  37. data/lib/generators/mixin_bot.rb +10 -0
  38. data/lib/mixin_bot/api/app.rb +20 -0
  39. data/lib/mixin_bot/api/blaze_async.rb +5 -1
  40. data/lib/mixin_bot/api/snapshot.rb +5 -0
  41. data/lib/mixin_bot/blaze/reactor.rb +249 -0
  42. data/lib/mixin_bot/blaze/router.rb +333 -0
  43. data/lib/mixin_bot/cache_store_adapter.rb +3 -1
  44. data/lib/mixin_bot/client.rb +2 -1
  45. data/lib/mixin_bot/computer.rb +3 -1
  46. data/lib/mixin_bot/configuration.rb +52 -0
  47. data/lib/mixin_bot/json_decoder.rb +14 -0
  48. data/lib/mixin_bot/notifications/mixin_channel.rb +80 -0
  49. data/lib/mixin_bot/outputs/cursor_model.rb +33 -0
  50. data/lib/mixin_bot/outputs/envelope.rb +160 -0
  51. data/lib/mixin_bot/outputs/memory_stores.rb +97 -0
  52. data/lib/mixin_bot/outputs/poller.rb +178 -0
  53. data/lib/mixin_bot/outputs/processing_job.rb +54 -0
  54. data/lib/mixin_bot/outputs/processors.rb +124 -0
  55. data/lib/mixin_bot/outputs/receipt_model.rb +86 -0
  56. data/lib/mixin_bot/outputs.rb +42 -0
  57. data/lib/mixin_bot/rails/railtie.rb +44 -0
  58. data/lib/mixin_bot/rails/tasks/poller.rake +33 -0
  59. data/lib/mixin_bot/rails.rb +34 -0
  60. data/lib/mixin_bot/registry.rb +79 -0
  61. data/lib/mixin_bot/transfers/model.rb +112 -0
  62. data/lib/mixin_bot/transfers/performer.rb +64 -0
  63. data/lib/mixin_bot/transfers/reconcile.rb +53 -0
  64. data/lib/mixin_bot/transfers.rb +23 -0
  65. data/lib/mixin_bot/version.rb +1 -1
  66. data/lib/mixin_bot.rb +4 -0
  67. data/lib/puma/plugin/mixin_blaze.rb +263 -0
  68. data/llms.txt +1 -1
  69. 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